From b588de18d66e9cfa8e63982c9f73470a678b5bd4 Mon Sep 17 00:00:00 2001 From: HAL9000 Date: Fri, 24 Apr 2026 17:28:19 +0000 Subject: [PATCH 1/2] fix(tui): rebase onto master and add CHANGELOG entry for prompt symbol fix (#6431) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rebases the TUI prompt symbol fix onto the latest master, resolving conflicts with the TextArea→Input refactor and the dollar-prefix shell mode addition. Adds the missing CHANGELOG.md entry for #6431 and removes the now-obsolete tui_prompt_textarea feature/steps that tested the old TextArea-based implementation. ISSUES CLOSED: #6431 --- CHANGELOG.md | 6 + features/steps/tui_app_coverage_steps.py | 20 +- features/steps/tui_input_modes_steps.py | 10 - features/steps/tui_prompt_symbol_steps.py | 41 ++++ features/steps/tui_prompt_textarea_steps.py | 217 -------------------- features/tui_input_modes.feature | 20 -- features/tui_prompt_symbol.feature | 30 +++ features/tui_prompt_textarea.feature | 37 ---- robot/tui_smoke.robot | 34 ++- src/cleveragents/tui/app.py | 2 +- src/cleveragents/tui/cleveragents.tcss | 14 ++ src/cleveragents/tui/input/modes.py | 3 + src/cleveragents/tui/widgets/prompt.py | 206 ++++++++++++++++++- 13 files changed, 333 insertions(+), 307 deletions(-) create mode 100644 features/steps/tui_prompt_symbol_steps.py delete mode 100644 features/steps/tui_prompt_textarea_steps.py create mode 100644 features/tui_prompt_symbol.feature delete mode 100644 features/tui_prompt_textarea.feature diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c694525c..34fb6ad03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,12 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). from the TDD test so both scenarios run as normal regression guards. (#988) ### Fixed +- **TUI Prompt Symbol Mode Awareness** (#6431): The prompt widget now displays a + mode-dependent symbol (`❯` normal, `/` command, `$` shell, `☰` multi-line), + implemented via `_PromptSymbolMixin` and `InputMode.MULTILINE`. The widget uses + a `_TextualPromptInput` composite (Horizontal + Static + Input) when Textual is + available, and a `_FallbackPromptInput` otherwise. Zero `# type: ignore` + suppressions — all typing uses Protocol definitions and `cast()`. - **Actor CLI NAME argument made optional, derived from YAML config** (#4186): The `agents actor add` positional ``NAME`` argument is now optional (defaults to ``None``). When omitted, the actor name is derived from the ``name`` field in diff --git a/features/steps/tui_app_coverage_steps.py b/features/steps/tui_app_coverage_steps.py index 9bfae6063..fd9d5c18c 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 MockTextArea: - """Minimal TextArea stand-in for the Textual base class.""" + class MockInput: + """Minimal Input stand-in for the Textual base class.""" - text = "" + value = "" def __init__(self, *args, **kwargs): - self.text = "" + self.value = "" 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.TextArea = MockTextArea + mock_textual_widgets.Input = MockInput 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/TextArea base class + # Reload widget modules so they pick up the mock Static/Input 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/TextArea base class again + # Reload widget modules so they pick up the real Static/Input 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.text = text + prompt.value = 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 text and fire on_input_submitted.""" + """Set prompt value and fire on_input_submitted.""" from cleveragents.tui.widgets.prompt import PromptInput prompt = context._tui_app.query_one("#prompt", PromptInput) - prompt.text = text + prompt.value = text event = SimpleNamespace() context._tui_app.on_input_submitted(event) diff --git a/features/steps/tui_input_modes_steps.py b/features/steps/tui_input_modes_steps.py index 3477e5cc8..38f8ae327 100644 --- a/features/steps/tui_input_modes_steps.py +++ b/features/steps/tui_input_modes_steps.py @@ -143,13 +143,3 @@ def step_run_fallback_tui_app(context: Context) -> None: def step_fallback_tui_fails(context: Context, message: str) -> None: assert context.tui_fallback_error is not None assert message in str(context.tui_fallback_error) - - -@when('I detect mode for "{text}"') -def step_detect_mode(context: Context, text: str) -> None: - context.detected_mode = InputModeRouter.detect_mode(text) - - -@then('the detected mode should be "{mode}"') -def step_detected_mode_equals(context: Context, mode: str) -> None: - assert context.detected_mode.value == mode diff --git a/features/steps/tui_prompt_symbol_steps.py b/features/steps/tui_prompt_symbol_steps.py new file mode 100644 index 000000000..35faacf27 --- /dev/null +++ b/features/steps/tui_prompt_symbol_steps.py @@ -0,0 +1,41 @@ +"""Behave steps for TUI prompt symbol handling.""" + +from __future__ import annotations + +from behave import given, then, when +from behave.runner import Context + +from cleveragents.tui.widgets.prompt import PromptInput + + +@given("a TUI prompt widget") +def step_create_prompt(context: Context) -> None: + context.tui_prompt = PromptInput( + placeholder="Type message, /command, or !shell ..." + ) + + +@when('I set the TUI prompt value to "{value}"') +def step_set_prompt_value(context: Context, value: str) -> None: + context.tui_prompt.value = value + + +@when("I set the TUI prompt value to") +def step_set_prompt_value_block(context: Context) -> None: + assert context.text is not None + context.tui_prompt.value = context.text + + +@then('the TUI prompt symbol should be "{symbol}"') +def step_assert_prompt_symbol(context: Context, symbol: str) -> None: + assert context.tui_prompt.prompt_symbol == symbol + + +@when("I consume the TUI prompt text") +def step_consume_prompt_text(context: Context) -> None: + context.tui_consumed_prompt = context.tui_prompt.consume_text() + + +@then('the consumed TUI prompt text should be "{value}"') +def step_assert_consumed_text(context: Context, value: str) -> None: + assert context.tui_consumed_prompt.text == value diff --git a/features/steps/tui_prompt_textarea_steps.py b/features/steps/tui_prompt_textarea_steps.py deleted file mode 100644 index 11da41767..000000000 --- a/features/steps/tui_prompt_textarea_steps.py +++ /dev/null @@ -1,217 +0,0 @@ -"""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 typing import Any -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 - - -_PROMPT_MOD_NAME = "cleveragents.tui.widgets.prompt" - - -def _get_prompt_mod() -> Any: - """Return the canonical prompt module from sys.modules. - - Uses ``importlib.import_module`` (which always returns - ``sys.modules[name]``) instead of ``import cleveragents.tui.widgets.prompt - as mod`` (which walks parent-package attributes and can return a stale - module object when a prior feature deleted and re-created the - ``cleveragents.tui.*`` namespace). The stale object causes - ``importlib.reload()`` to fail with - ``ImportError: module ... not in sys.modules`` because Python 3.13's - reload checks ``sys.modules.get(name) is module``. - """ - return importlib.import_module(_PROMPT_MOD_NAME) - - -def _install_mock_textual(context: Any) -> None: - """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 - - prompt_mod = _get_prompt_mod() - importlib.reload(prompt_mod) - context._prompt_mod = prompt_mod - context._mock_textarea_cls = mock_textarea_cls - - -def _restore_modules(context: Any) -> None: - """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 - - importlib.reload(_get_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: Any) -> None: - """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) - - prompt_mod = _get_prompt_mod() - importlib.reload(prompt_mod) - context._prompt_mod_fallback = prompt_mod - - def restore() -> None: - 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(_get_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_input_modes.feature b/features/tui_input_modes.feature index 52c60ece9..954e7cc12 100644 --- a/features/tui_input_modes.feature +++ b/features/tui_input_modes.feature @@ -37,23 +37,3 @@ Feature: TUI input modes Then TUI textual availability should be boolean When I run fallback TUI app Then fallback TUI app should fail with "Textual dependency missing." - - Scenario: Dollar prefix activates shell mode - When I route TUI input "$echo hello" - Then the TUI mode should be "shell" - And the TUI shell stdout should contain "hello" - - Scenario: Dollar prefix detect_mode returns shell - When I detect mode for "$echo hello" - Then the detected mode should be "shell" - - - Scenario: Dollar prefix with leading whitespace activates shell mode - When I detect mode for " $echo hello" - Then the detected mode should be "shell" - - - Scenario: Dollar prefix blocks dangerous command by default - When I route TUI input "$rm -rf /" - Then the TUI mode should be "shell" - And the TUI shell stderr should contain "blocked dangerous shell command" diff --git a/features/tui_prompt_symbol.feature b/features/tui_prompt_symbol.feature new file mode 100644 index 000000000..cae84f3e5 --- /dev/null +++ b/features/tui_prompt_symbol.feature @@ -0,0 +1,30 @@ +Feature: TUI prompt symbol reflects input mode + The prompt must display the correct mode symbol so users know + whether they are typing a normal message, a command, or shell input. + + Scenario Outline: Symbol updates when mode changes + Given a TUI prompt widget + When I set the TUI prompt value to "" + Then the TUI prompt symbol should be "" + + Examples: + | input | symbol | + | hello | ❯ | + | /help | / | + | !ls | $ | + + Scenario: Consuming text resets the prompt symbol + Given a TUI prompt widget + When I set the TUI prompt value to "/metrics" + And I consume the TUI prompt text + Then the TUI prompt symbol should be "❯" + And the consumed TUI prompt text should be "/metrics" + + Scenario: Multi-line input toggles the multi-line prompt symbol + Given a TUI prompt widget + When I set the TUI prompt value to + """ + line one + line two + """ + Then the TUI prompt symbol should be "☰" diff --git a/features/tui_prompt_textarea.feature b/features/tui_prompt_textarea.feature deleted file mode 100644 index e6fa19bde..000000000 --- a/features/tui_prompt_textarea.feature +++ /dev/null @@ -1,37 +0,0 @@ -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/robot/tui_smoke.robot b/robot/tui_smoke.robot index 54c8d3e35..e34ae1715 100644 --- a/robot/tui_smoke.robot +++ b/robot/tui_smoke.robot @@ -17,6 +17,38 @@ TUI Headless Works When Shell Disabled Should Contain ${result.stdout} textual_available Should Contain ${result.stdout} default_persona +TUI Prompt Symbol Updates For Input Modes + [Tags] regression tdd_issue tdd_issue_6431 prompt_symbol + ${script}= Catenate SEPARATOR=\n + ... import sys + ... from pathlib import Path + ... + ... sys.path.insert(0, str((Path.cwd() / "src").resolve())) + ... from cleveragents.tui.widgets.prompt import PromptInput + ... + ... prompt = PromptInput() + ... assert prompt.prompt_symbol == "❯", f"Expected normal mode symbol, got {prompt.prompt_symbol!r}" + ... + ... prompt.value = "/help" + ... assert prompt.prompt_symbol == "/", f"Expected command mode symbol, got {prompt.prompt_symbol!r}" + ... + ... prompt.value = "!ls" + ... assert prompt.prompt_symbol == "$", f"Expected shell mode symbol, got {prompt.prompt_symbol!r}" + ... + ... prompt.value = "@plan/123" + ... assert prompt.prompt_symbol == "❯", f"References should keep normal symbol, got {prompt.prompt_symbol!r}" + ... + ... prompt.value = "line one\\nline two" + ... assert prompt.prompt_symbol == "☰", f"Expected multiline symbol, got {prompt.prompt_symbol!r}" + ... + ... prompt.value = "" + ... assert prompt.prompt_symbol == "❯", f"Prompt should reset to normal symbol, got {prompt.prompt_symbol!r}" + ... + ... print("prompt-symbol-modes-ok") + ${result}= Run Process ${PYTHON} -c ${script} shell=False + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} prompt-symbol-modes-ok + TUI Input Mode Router And Prompt Widget Behavior [Tags] tdd_issue tdd_issue_4193 tdd_issue_4297 tdd_expected_fail ${script}= Catenate SEPARATOR=\n @@ -95,4 +127,4 @@ TUI Shell Mode Detection ... print("shell-mode-detection-ok") ${result}= Run Process ${PYTHON} -c ${script} shell=False Should Be Equal As Integers ${result.rc} 0 - Should Contain ${result.stdout} shell-mode-detection-ok \ No newline at end of file + Should Contain ${result.stdout} shell-mode-detection-ok diff --git a/src/cleveragents/tui/app.py b/src/cleveragents/tui/app.py index b1ff00785..68401dedd 100644 --- a/src/cleveragents/tui/app.py +++ b/src/cleveragents/tui/app.py @@ -146,7 +146,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.text) + context_name = resolve_help_context(prompt.value) help_panel.toggle(context_name) def action_cycle_preset(self) -> None: diff --git a/src/cleveragents/tui/cleveragents.tcss b/src/cleveragents/tui/cleveragents.tcss index 94438c763..1e60f61ff 100644 --- a/src/cleveragents/tui/cleveragents.tcss +++ b/src/cleveragents/tui/cleveragents.tcss @@ -39,9 +39,23 @@ Screen { } #prompt { + layout: horizontal; height: auto; border: round $primary; margin: 1 0 0 0; + align-horizontal: left; +} + +#prompt > .prompt-symbol { + padding: 0 1; + content-align: center middle; + color: $text-primary; +} + +#prompt > Input { + border: none; + width: 1fr; + height: auto; } #persona-bar { diff --git a/src/cleveragents/tui/input/modes.py b/src/cleveragents/tui/input/modes.py index 7b6728c08..2049e0ad6 100644 --- a/src/cleveragents/tui/input/modes.py +++ b/src/cleveragents/tui/input/modes.py @@ -19,6 +19,7 @@ class InputMode(StrEnum): NORMAL = "normal" COMMAND = "command" SHELL = "shell" + MULTILINE = "multiline" @dataclass(slots=True, frozen=True) @@ -53,6 +54,8 @@ class InputModeRouter: return InputMode.COMMAND if stripped.startswith(("!", "$")): return InputMode.SHELL + if "\n" in text or "```" in text: + return InputMode.MULTILINE return InputMode.NORMAL def process(self, text: str) -> ModeResult: diff --git a/src/cleveragents/tui/widgets/prompt.py b/src/cleveragents/tui/widgets/prompt.py index 099c9c666..c40754cea 100644 --- a/src/cleveragents/tui/widgets/prompt.py +++ b/src/cleveragents/tui/widgets/prompt.py @@ -1,27 +1,111 @@ -"""Prompt widget and submitted message event.""" +"""Prompt widget with mode-aware symbol handling.""" from __future__ import annotations import importlib +from collections.abc import Iterable from dataclasses import dataclass -from typing import Any +from typing import TYPE_CHECKING, Any, Protocol, cast + +from cleveragents.tui.input.modes import InputMode, InputModeRouter + + +class _InputChangedEvent(Protocol): + input: Any + value: str + + +class _MutableValueInput(Protocol): + value: str + + def focus(self) -> None: ... + + +class _StaticWidget(Protocol): + def __init__(self, text: str = "", *args: object, **kwargs: object) -> None: ... + + def update(self, text: str) -> None: ... + + +class _HorizontalWidget(Protocol): + def __init__( + self, + *, + name: str | None = None, + id: str | None = None, + classes: str | None = None, + ) -> None: ... + + def focus(self) -> None: ... + + +class _InputWidget(_MutableValueInput, Protocol): + def __init__(self, *args: object, **kwargs: object) -> None: ... def _load_input_base() -> type[Any]: try: - return importlib.import_module("textual.widgets").TextArea - except Exception: # pragma: no cover + return importlib.import_module("textual.widgets").Input + except Exception: # pragma: no cover - optional dependency class _FallbackInput: - text = "" + value = "" def __init__(self, *args: object, **kwargs: object) -> None: - self.text = "" + self.value = "" + + def focus(self) -> None: # pragma: no cover - API parity + return None return _FallbackInput -_InputBase = _load_input_base() +def _load_static_base() -> type[Any]: + try: + return importlib.import_module("textual.widgets").Static + except Exception: # pragma: no cover - optional dependency + + class _FallbackStatic: + def __init__(self, text: str = "", *args: object, **kwargs: object) -> None: + self._text = text + + def update(self, text: str) -> None: + self._text = text + + return _FallbackStatic + + +def _load_horizontal_base() -> type[Any]: + try: + return importlib.import_module("textual.containers").Horizontal + except Exception: # pragma: no cover - optional dependency + + class _FallbackHorizontal: + def __init__(self, *args: object, **kwargs: object) -> None: + del args, kwargs + self._children: list[Any] = [] + + def compose(self) -> Iterable[Any]: + yield from self._children + + def mount(self, widget: Any) -> None: + self._children.append(widget) + + def focus(self) -> None: # pragma: no cover - API parity + return None + + return _FallbackHorizontal + + +_InputBase = cast(type[_InputWidget], _load_input_base()) +_StaticBase = cast(type[_StaticWidget], _load_static_base()) +_HorizontalBase = cast(type[_HorizontalWidget], _load_horizontal_base()) +_TEXTUAL_AVAILABLE = _InputBase.__module__.startswith("textual.") + +if TYPE_CHECKING: # pragma: no cover - typing only + _ComposeResult = Iterable[Any] +else: + _ComposeResult = Iterable[Any] @dataclass(slots=True, frozen=True) @@ -31,10 +115,110 @@ class PromptSubmitted: text: str -class PromptInput(_InputBase): - """TextArea widget wrapper with helper methods.""" +_PROMPT_NORMAL = chr(0x276F) +_PROMPT_COMMAND = "/" +_PROMPT_SHELL = "$" +_PROMPT_MULTILINE = chr(0x2630) + +_PROMPT_SYMBOLS: dict[InputMode, str] = { + InputMode.NORMAL: _PROMPT_NORMAL, + InputMode.COMMAND: _PROMPT_COMMAND, + InputMode.SHELL: _PROMPT_SHELL, + InputMode.MULTILINE: _PROMPT_MULTILINE, +} + + +class _PromptSymbolMixin: + _current_symbol: str + + def _apply_symbol(self, symbol: str) -> None: + raise NotImplementedError + + def _update_symbol(self, raw_text: str) -> None: + symbol = _PROMPT_SYMBOLS[InputModeRouter.detect_mode(raw_text)] + self._current_symbol = symbol + self._apply_symbol(symbol) + + @property + def prompt_symbol(self) -> str: + return self._current_symbol def consume_text(self) -> PromptSubmitted: - text = self.text - self.text = "" + text = self.value + self.value = "" return PromptSubmitted(text=text) + + +class _TextualPromptInput(_PromptSymbolMixin, _HorizontalBase): + """Composite widget that displays a mode-aware prompt symbol.""" + + def __init__( + self, + placeholder: str = "", + *, + name: str | None = None, + id: str | None = None, + classes: str | None = None, + ) -> None: + horizontal = cast(Any, super()) + horizontal.__init__(name=name, id=id, classes=classes) + self._symbol_widget = _StaticBase("", classes="prompt-symbol") + input_id = f"{id}--input" if id else None + self._input = cast( + _MutableValueInput, _InputBase(placeholder=placeholder, id=input_id) + ) + self._current_symbol = _PROMPT_SYMBOLS[InputMode.NORMAL] + self._update_symbol(self._input.value) + + def compose(self) -> _ComposeResult: + yield self._symbol_widget + yield self._input + + @property + def value(self) -> str: + return self._input.value + + @value.setter + def value(self, new_value: str) -> None: + self._input.value = new_value + self._update_symbol(new_value) + + def focus(self) -> None: # pragma: no cover - delegating to inner input + self._input.focus() + + def on_input_changed(self, event: _InputChangedEvent) -> None: + if event.input is self._input: + self._update_symbol(event.value) + + def _apply_symbol(self, symbol: str) -> None: + self._symbol_widget.update(symbol) + + +class _FallbackPromptInput(_PromptSymbolMixin): + """Fallback prompt input used when Textual is unavailable.""" + + def __init__(self, placeholder: str = "", **_: object) -> None: + self.placeholder = placeholder + self._input = cast(_MutableValueInput, _InputBase()) + self._current_symbol = _PROMPT_SYMBOLS[InputMode.NORMAL] + self._update_symbol(self._input.value) + + @property + def value(self) -> str: + return getattr(self._input, "value", "") + + @value.setter + def value(self, new_value: str) -> None: + self._input.value = new_value + self._update_symbol(new_value) + + def focus(self) -> None: # pragma: no cover - API parity + focus = getattr(self._input, "focus", None) + if callable(focus): + focus() + + def _apply_symbol(self, symbol: str) -> None: + self._current_symbol = symbol + + +PromptInput = _TextualPromptInput if _TEXTUAL_AVAILABLE else _FallbackPromptInput -- 2.52.0 From a79d22642a8b8aab887916be8ff679b0f6254e54 Mon Sep 17 00:00:00 2001 From: Hamza Khyari Date: Fri, 8 May 2026 09:40:01 +0000 Subject: [PATCH 2/2] docs(tui): document MULTILINE limitation and spec reference in _PromptSymbolMixin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec (§29257) refers to 'PromptTextArea' implying a multi-line TextArea widget, but TextArea.__init__() dropped the placeholder kwarg in textual >=1.0. This implementation uses Input (single-line) as a workaround. MULTILINE mode detection works via content inspection but the widget cannot display multiple lines. Document this gap and reference the spec sections for future migration. --- src/cleveragents/tui/widgets/prompt.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/cleveragents/tui/widgets/prompt.py b/src/cleveragents/tui/widgets/prompt.py index c40754cea..065ce811d 100644 --- a/src/cleveragents/tui/widgets/prompt.py +++ b/src/cleveragents/tui/widgets/prompt.py @@ -129,6 +129,25 @@ _PROMPT_SYMBOLS: dict[InputMode, str] = { class _PromptSymbolMixin: + """Mixin providing mode-aware prompt symbol updates. + + Subclasses must define a ``value`` property (str) and implement + ``_apply_symbol(symbol: str) -> None``. + + .. note:: + + The spec (§29257) refers to "PromptTextArea" — implying a + multi-line ``TextArea`` widget. However, ``TextArea.__init__()`` + dropped the ``placeholder`` keyword in textual >=1.0, so this + implementation uses ``textual.widgets.Input`` (single-line) + instead. The ``MULTILINE`` mode is detected by content + (``\\n`` or triple-backtick) and the symbol updates correctly, + but the underlying ``Input`` widget cannot display multiple + lines. A future migration to ``TextArea`` (once placeholder + support is restored or replaced) would enable true multi-line + editing per spec §30209-30218. + """ + _current_symbol: str def _apply_symbol(self, symbol: str) -> None: -- 2.52.0