fix(tui): replace single-line Input with multi-line TextArea in PromptInput #10753

Open
HAL9000 wants to merge 6 commits from tdd/prompt-input-textarea into master
5 changed files with 140 additions and 21 deletions
@@ -0,0 +1,95 @@
"""Step definitions for tdd_prompt_input_textarea.feature.
Tests for:
- PromptInput inherits from TextArea (not Input)
- PromptInput.consume_text() returns PromptSubmitted with correct text
- PromptInput text is cleared after consume_text()
"""
from __future__ import annotations
from behave import then, when
from behave.runner import Context
import cleveragents.tui.widgets.prompt as _prompt_mod
from cleveragents.tui.widgets.prompt import PromptInput, PromptSubmitted
@when("I inspect the PromptInput base class")
def step_inspect_prompt_input_base(context: Context) -> None:
"""Load the PromptInput class for inspection."""
context.prompt_input_cls = PromptInput
@then("PromptInput should be a subclass of TextArea")
def step_prompt_input_is_textarea(context: Context) -> None:
"""Verify PromptInput inherits from textual.widgets.TextArea."""
try:
import textual.widgets as tw
assert issubclass(context.prompt_input_cls, tw.TextArea), (
f"PromptInput must inherit from TextArea, "
f"but its MRO is: {[c.__name__ for c in context.prompt_input_cls.__mro__]}"
)
except ImportError: # pragma: no cover
# Textual not installed — check the fallback base name
base_name = context.prompt_input_cls.__bases__[0].__name__
assert "TextArea" in base_name, (
f"PromptInput fallback base must be TextArea-like, got: {base_name}"
)
@then("PromptInput should not be a subclass of Input")
def step_prompt_input_not_input(context: Context) -> None:
"""Verify PromptInput does NOT inherit from textual.widgets.Input."""
try:
import textual.widgets as tw
assert not issubclass(context.prompt_input_cls, tw.Input), (
"PromptInput must NOT inherit from Input (single-line widget). "
"Use TextArea for multi-line support."
)
except ImportError: # pragma: no cover
# Textual not installed — check the fallback base name
base_name = context.prompt_input_cls.__bases__[0].__name__
assert "Input" not in base_name or "TextArea" in base_name, (
f"PromptInput fallback base must not be Input-like, got: {base_name}"
)
@when("I create a PromptInput instance")
def step_create_prompt_input(context: Context) -> None:
"""Create a PromptInput instance using the fallback (no Textual needed)."""
# Create a PromptInput instance directly
# The PromptInput class is already loaded with the appropriate base class
context.prompt_input = _prompt_mod.PromptInput()
context.prompt_submitted: PromptSubmitted | None = None
@when('I set the PromptInput text to "{text}"')
def step_set_prompt_input_text(context: Context, text: str) -> None:
"""Set the text on the PromptInput instance."""
context.prompt_input.text = text
@when("I call consume_text on the PromptInput")
def step_call_consume_text(context: Context) -> None:
"""Call consume_text() and store the result."""
context.prompt_submitted = context.prompt_input.consume_text()
@then('the PromptSubmitted text should be "{expected}"')
def step_prompt_submitted_text(context: Context, expected: str) -> None:
"""Verify the PromptSubmitted payload has the expected text."""
assert context.prompt_submitted is not None, "consume_text() returned None"
assert context.prompt_submitted.text == expected, (
f"Expected text '{expected}', got '{context.prompt_submitted.text}'"
)
@then("the PromptInput text should be empty after consume")
def step_prompt_input_empty_after_consume(context: Context) -> None:
"""Verify the PromptInput text is cleared after consume_text()."""
assert context.prompt_input.text == "", (
f"Expected empty text after consume_text(), got '{context.prompt_input.text}'"
)
+10 -9
View File
@@ -82,20 +82,21 @@ def _build_mock_textual():
def update(self, text):
self._text = text
class MockInput:
"""Minimal Input stand-in for the Textual base class."""
class MockTextArea:
class MockTextArea:
"""Minimal TextArea stand-in for the Textual base class."""
value = ""
text = ""
def __init__(self, *args, **kwargs):
self.value = ""
self.text = ""
mock_textual_app.App = MockApp
mock_textual_containers.Vertical = MockVertical
mock_textual_widgets.Header = MockHeader
mock_textual_widgets.Footer = MockFooter
mock_textual_widgets.Static = MockStatic
mock_textual_widgets.Input = MockInput
mock_textual_widgets.TextArea = MockTextArea
return {
"textual": mock_textual,
@@ -114,7 +115,7 @@ def _install_mock_textual(context):
for key, mod in mocks.items():
sys.modules[key] = mod
# Reload widget modules so they pick up the mock Static/Input base class
# Reload widget modules so they pick up the mock Static/TextArea base class
import cleveragents.tui.widgets.help_panel_overlay as hp_mod
import cleveragents.tui.widgets.persona_bar as pb_mod
import cleveragents.tui.widgets.prompt as prompt_mod
@@ -142,7 +143,7 @@ def _restore_modules(context):
else:
sys.modules[key] = val
# Reload widget modules so they pick up the real Static/Input base class again
# Reload widget modules so they pick up the real Static/TextArea base class again
import cleveragents.tui.widgets.help_panel_overlay as hp_mod
import cleveragents.tui.widgets.persona_bar as pb_mod
import cleveragents.tui.widgets.prompt as prompt_mod
@@ -370,7 +371,7 @@ def step_set_prompt_text(context, text):
from cleveragents.tui.widgets.prompt import PromptInput
prompt = context._tui_app.query_one("#prompt", PromptInput)
prompt.value = text
prompt.text = text
@then('the conversation widget should contain "{text}"')
@@ -429,7 +430,7 @@ def _submit_text(context, text):
from cleveragents.tui.widgets.prompt import PromptInput
prompt = context._tui_app.query_one("#prompt", PromptInput)
prompt.value = text
prompt.text = text
event = SimpleNamespace()
context._tui_app.on_input_submitted(event)
@@ -0,0 +1,23 @@
@tdd_issue @tdd_issue_10411
Feature: TDD Issue #10411 - PromptInput uses single-line Input instead of multi-line TextArea
As a CleverAgents TUI user
I want the PromptInput widget to use TextArea (multi-line)
So that I can enter multi-line prompts in the TUI
This test captures the bug described in issue #10411.
PromptInput was inheriting from textual.widgets.Input (single-line)
instead of textual.widgets.TextArea (multi-line).
@tdd_issue @tdd_issue_10411
Scenario: PromptInput inherits from TextArea not Input
When I inspect the PromptInput base class
Then PromptInput should be a subclass of TextArea
And PromptInput should not be a subclass of Input
@tdd_issue @tdd_issue_10411
Scenario: PromptInput consume_text returns PromptSubmitted with text
When I create a PromptInput instance
And I set the PromptInput text to "hello world"
And I call consume_text on the PromptInput
Then the PromptSubmitted text should be "hello world"
And the PromptInput text should be empty after consume
+1 -1
View File
@@ -145,7 +145,7 @@ if _TEXTUAL_AVAILABLE:
def action_help(self) -> None:
prompt = self.query_one("#prompt", PromptInput)
help_panel = self.query_one("#help-panel", HelpPanelOverlay)
context_name = resolve_help_context(prompt.value)
context_name = resolve_help_context(prompt.text)
help_panel.toggle(context_name)
def action_cycle_preset(self) -> None:
+11 -11
View File
@@ -7,21 +7,21 @@ from dataclasses import dataclass
from typing import Any
def _load_input_base() -> type[Any]:
def _load_textarea_base() -> type[Any]:
try:
return importlib.import_module("textual.widgets").Input
return importlib.import_module("textual.widgets").TextArea
except Exception: # pragma: no cover
class _FallbackInput:
value = ""
class _FallbackTextArea:
text = ""
def __init__(self, *args: object, **kwargs: object) -> None:
self.value = ""
self.text = ""
return _FallbackInput
return _FallbackTextArea
_InputBase = _load_input_base()
_TextAreaBase = _load_textarea_base()
@dataclass(slots=True, frozen=True)
@@ -31,10 +31,10 @@ class PromptSubmitted:
text: str
class PromptInput(_InputBase):
"""Input widget wrapper with helper methods."""
class PromptInput(_TextAreaBase):
"""TextArea widget wrapper with helper methods."""
def consume_text(self) -> PromptSubmitted:
text = self.value
self.value = ""
text = self.text
self.text = ""
return PromptSubmitted(text=text)