diff --git a/features/steps/tui_app_coverage_steps.py b/features/steps/tui_app_coverage_steps.py index ad9227bd2..fcf125b6c 100644 --- a/features/steps/tui_app_coverage_steps.py +++ b/features/steps/tui_app_coverage_steps.py @@ -82,20 +82,20 @@ def _build_mock_textual(): def update(self, text): self._text = text - class MockInput: - """Minimal Input stand-in for the Textual base class.""" + 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 +114,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 +142,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 +370,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}"') @@ -425,11 +425,11 @@ def step_persona_bar_shows_name(context): # on_input_submitted helpers # --------------------------------------------------------------------------- def _submit_text(context, text): - """Set prompt value and fire on_input_submitted.""" + """Set prompt text and fire on_input_submitted.""" 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) diff --git a/features/steps/tui_prompt_textarea_steps.py b/features/steps/tui_prompt_textarea_steps.py new file mode 100644 index 000000000..81dabaefc --- /dev/null +++ b/features/steps/tui_prompt_textarea_steps.py @@ -0,0 +1,202 @@ +"""Step definitions for tui_prompt_textarea.feature. + +Tests that PromptInput uses TextArea (multi-line) instead of Input (single-line). +""" + +from __future__ import annotations + +import importlib +import sys +from types import ModuleType + +from behave import given, then, when + +_MOCK_TEXTUAL_KEYS = [ + "textual", + "textual.app", + "textual.containers", + "textual.widgets", +] + + +def _build_mock_textual_with_textarea(): + """Build mock textual modules that expose TextArea.""" + mock_textual = ModuleType("textual") + mock_textual_app = ModuleType("textual.app") + mock_textual_containers = ModuleType("textual.containers") + mock_textual_widgets = ModuleType("textual.widgets") + + class MockTextArea: + """Minimal TextArea stand-in for the Textual base class.""" + + text: str = "" + + def __init__(self, *args: object, **kwargs: object) -> None: + self.text = "" + + mock_textual_app.App = object + mock_textual_containers.Vertical = object + mock_textual_widgets.Header = object + mock_textual_widgets.Footer = object + mock_textual_widgets.Static = object + mock_textual_widgets.TextArea = MockTextArea + + return { + "textual": mock_textual, + "textual.app": mock_textual_app, + "textual.containers": mock_textual_containers, + "textual.widgets": mock_textual_widgets, + }, MockTextArea + + +def _install_mock_textual(context): + """Inject mock textual into sys.modules and reload the prompt module.""" + mocks, mock_textarea_cls = _build_mock_textual_with_textarea() + context._prompt_saved_modules = {} + for key in _MOCK_TEXTUAL_KEYS: + context._prompt_saved_modules[key] = sys.modules.pop(key, None) + for key, mod in mocks.items(): + sys.modules[key] = mod + + import cleveragents.tui.widgets.prompt as prompt_mod + + importlib.reload(prompt_mod) + context._prompt_mod = prompt_mod + context._mock_textarea_cls = mock_textarea_cls + + +def _restore_modules(context): + """Restore original sys.modules and reload the prompt module.""" + for key, val in getattr(context, "_prompt_saved_modules", {}).items(): + if val is None: + sys.modules.pop(key, None) + else: + sys.modules[key] = val + + import cleveragents.tui.widgets.prompt as prompt_mod + + importlib.reload(prompt_mod) + + +# --------------------------------------------------------------------------- +# Background +# --------------------------------------------------------------------------- + + +@given("the prompt module is loaded with a mocked TextArea") +def step_load_prompt_with_mock_textarea(context): + """Install mock Textual with TextArea, reload prompt module.""" + _install_mock_textual(context) + context.add_cleanup(lambda: _restore_modules(context)) + + +@given("the prompt module is loaded without textual") +def step_load_prompt_without_textual(context): + """Remove textual from sys.modules so the fallback path is used.""" + context._prompt_saved_modules_fallback = {} + for key in _MOCK_TEXTUAL_KEYS: + context._prompt_saved_modules_fallback[key] = sys.modules.pop(key, None) + + import cleveragents.tui.widgets.prompt as prompt_mod + + importlib.reload(prompt_mod) + context._prompt_mod_fallback = prompt_mod + + def restore(): + for key, val in context._prompt_saved_modules_fallback.items(): + if val is None: + sys.modules.pop(key, None) + else: + sys.modules[key] = val + importlib.reload(prompt_mod) + + context.add_cleanup(restore) + + +# --------------------------------------------------------------------------- +# Scenario: PromptInput base class is TextArea not Input +# --------------------------------------------------------------------------- + + +@then("the PromptInput base class should be the mocked TextArea") +def step_base_class_is_textarea(context): + PromptInput = context._prompt_mod.PromptInput + assert issubclass(PromptInput, context._mock_textarea_cls), ( + f"Expected PromptInput to subclass MockTextArea, " + f"but got bases: {PromptInput.__bases__}" + ) + + +# --------------------------------------------------------------------------- +# Scenario: PromptInput exposes a text property not value +# --------------------------------------------------------------------------- + + +@when("I create a PromptInput instance") +def step_create_prompt_input(context): + context._prompt_instance = context._prompt_mod.PromptInput() + + +@then("the PromptInput instance should have a text attribute") +def step_has_text_attribute(context): + assert hasattr(context._prompt_instance, "text"), ( + "PromptInput instance should have a 'text' attribute" + ) + + +# --------------------------------------------------------------------------- +# Scenario: consume_text returns the current text content +# --------------------------------------------------------------------------- + + +@when('I set the PromptInput text to "{text}"') +def step_set_prompt_input_text(context, text): + context._prompt_instance.text = text + + +@when("I call consume_text on the PromptInput") +def step_call_consume_text(context): + context._prompt_submitted = context._prompt_instance.consume_text() + + +@then('the PromptSubmitted text should be "{expected}"') +def step_prompt_submitted_text(context, expected): + assert context._prompt_submitted.text == expected, ( + f"Expected '{expected}', got '{context._prompt_submitted.text}'" + ) + + +# --------------------------------------------------------------------------- +# Scenario: consume_text clears the text after consuming +# --------------------------------------------------------------------------- + + +@then("the PromptInput text should be empty") +def step_prompt_input_text_empty(context): + assert context._prompt_instance.text == "", ( + f"Expected empty text, got '{context._prompt_instance.text}'" + ) + + +# --------------------------------------------------------------------------- +# Scenario: PromptInput fallback uses text attribute when TextArea unavailable +# --------------------------------------------------------------------------- + + +@when("I create a PromptInput instance from the fallback") +def step_create_fallback_prompt_input(context): + context._fallback_prompt_instance = context._prompt_mod_fallback.PromptInput() + + +@then("the fallback PromptInput instance should have a text attribute") +def step_fallback_has_text_attribute(context): + assert hasattr(context._fallback_prompt_instance, "text"), ( + "Fallback PromptInput instance should have a 'text' attribute" + ) + + +@then("the fallback PromptInput text should be empty string") +def step_fallback_text_empty(context): + assert context._fallback_prompt_instance.text == "", ( + f"Expected empty string, got '{context._fallback_prompt_instance.text}'" + ) diff --git a/features/tui_prompt_textarea.feature b/features/tui_prompt_textarea.feature new file mode 100644 index 000000000..e6fa19bde --- /dev/null +++ b/features/tui_prompt_textarea.feature @@ -0,0 +1,37 @@ +Feature: PromptInput uses multi-line TextArea widget + The PromptInput widget must use a multi-line TextArea widget (not a + single-line Input widget) to enable multi-line prompt composition. + + Background: + Given the prompt module is loaded with a mocked TextArea + + Scenario: PromptInput base class is TextArea not Input + Then the PromptInput base class should be the mocked TextArea + + Scenario: PromptInput exposes a text property not value + When I create a PromptInput instance + Then the PromptInput instance should have a text attribute + + Scenario: consume_text returns the current text content + 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" + + Scenario: consume_text clears the text after consuming + When I create a PromptInput instance + And I set the PromptInput text to "some prompt" + And I call consume_text on the PromptInput + Then the PromptInput text should be empty + + Scenario: consume_text supports multi-line text + When I create a PromptInput instance + And I set the PromptInput text to "line one\nline two\nline three" + And I call consume_text on the PromptInput + Then the PromptSubmitted text should be "line one\nline two\nline three" + + Scenario: PromptInput fallback uses text attribute when TextArea unavailable + Given the prompt module is loaded without textual + When I create a PromptInput instance from the fallback + Then the fallback PromptInput instance should have a text attribute + And the fallback PromptInput text should be empty string diff --git a/src/cleveragents/tui/app.py b/src/cleveragents/tui/app.py index 4a8c66dcf..ca663fa8f 100644 --- a/src/cleveragents/tui/app.py +++ b/src/cleveragents/tui/app.py @@ -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: diff --git a/src/cleveragents/tui/widgets/prompt.py b/src/cleveragents/tui/widgets/prompt.py index f7d1943b1..099c9c666 100644 --- a/src/cleveragents/tui/widgets/prompt.py +++ b/src/cleveragents/tui/widgets/prompt.py @@ -9,14 +9,14 @@ from typing import Any def _load_input_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 = "" + text = "" def __init__(self, *args: object, **kwargs: object) -> None: - self.value = "" + self.text = "" return _FallbackInput @@ -32,9 +32,9 @@ class PromptSubmitted: class PromptInput(_InputBase): - """Input widget wrapper with helper methods.""" + """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)