fix(tui): fix prompt symbol to change based on input mode #6722
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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}'"
|
||||
)
|
||||
@@ -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"
|
||||
|
||||
@@ -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 "<input>"
|
||||
Then the TUI prompt symbol should be "<symbol>"
|
||||
|
||||
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 "☰"
|
||||
@@ -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
|
||||
+33
-1
@@ -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
|
||||
Should Contain ${result.stdout} shell-mode-detection-ok
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user