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..065ce811d 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,129 @@ 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:
+ """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:
+ 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