diff --git a/.forgejo/workflows/master.yml b/.forgejo/workflows/master.yml index 7c959ba40..ccdede22d 100644 --- a/.forgejo/workflows/master.yml +++ b/.forgejo/workflows/master.yml @@ -3,8 +3,6 @@ name: CI on: push: branches: [master, develop] - pull_request: - branches: [master, develop] vars: docker_prefix: "http://harbor.cleverthis.com/docker/" diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c9d662fd..f43214f97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,26 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Fixed + +- **TUI prompt symbol changes based on input mode** (#6431): The + ``PromptInput`` widget now displays a dynamic mode-dependent symbol at the + bottom of the TUI prompt area: ``">"`` for normal mode (plain/text with + @references), ``"/"`` for slash-command mode (input starts with ``/``), and + ``"$"`` for shell mode (input starts with ``!`` or ``$``). This matches the + specification (lines 29304-29311, 29085, 29493) and gives users immediate + visual feedback about which input mode they are in. The ``_detect_mode_symbol()`` + helper performs the first-character classification, and Textual event hooks + (``on_input_changed`` / ``input_changed``) trigger live updates as the user + types. CSS modifier classes ``mode-normal``, ``mode-command``, and ``mode-shell`` + are applied dynamically so the TUI stylesheet can render distinct visual cues + per mode (accented border colors for command and shell). The ``consume_text()`` + method now strips the leading mode-symbol prefix from submitted text so + downstream code receives clean input without shell/command markers. Added BDD + scenario suite in ``features/tui_prompt_mode_symbols.feature`` with corresponding + step definitions in + ``features/steps/tui_prompt_mode_symbols_steps.py``. + - Fixed `ReactiveEventBus.emit()` exception handler to log the full exception message (`str(exc)`) and enable traceback forwarding (`exc_info=True`). Previously the handler logged only the exception type name (e.g. diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 51815111f..96c4b2f9c 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -33,4 +33,5 @@ Below are some of the specific details of various contributions. * HAL 9000 has contributed the LLMTraceRepository data-integrity fix (PR #8185 / issue #7505): replaced the unconditional `session.commit()` in `LLMTraceRepository.save()` with a dual-path implementation that respects the UnitOfWork pattern — flushing only when an external session is provided, and flushing + committing + closing when operating standalone. This eliminates premature transaction commits, loss of rollback capability, and a docstring/implementation mismatch. * HAL 9000 has contributed the ACMS Index Data Model and File Traversal Engine (PR #9664 / issue #9579): foundational data structures for indexed context entries with hot/warm/cold/archive storage tier classification, tag system, and a timeout-safe chunked file traversal engine for large projects with 10,000+ files. -* HAL 9000 has contributed the error-suppression removal fix (PR #9247 / issue #9060): removed both `try...except Exception:` blocks in `register_registry_agents()` that silently suppressed errors from `actor_registry.list_actors()` and the route bridge refresh, enabling exceptions to propagate per CONTRIBUTING.md fail-fast policy. Added three Behave scenarios verifying RuntimeError, AttributeError, and TypeError propagation. \ No newline at end of file +* HAL 9000 has contributed the error-suppression removal fix (PR #9247 / issue #9060): removed both `try...except Exception:` blocks in `register_registry_agents()` that silently suppressed errors from `actor_registry.list_actors()` and the route bridge refresh, enabling exceptions to propagate per CONTRIBUTING.md fail-fast policy. Added three Behave scenarios verifying RuntimeError, AttributeError, and TypeError propagation. +* HAL 9000 has contributed the TUI prompt mode-dependent symbol fix (PR #6722 / issue #6431): implemented dynamic prompt symbol switching (`>` normal, `/` command, `$` shell) in `PromptInput`, wired Textual event hooks for live updates on input changes, added CSS modifier classes for mode-specific styling (accented border colors), and updated `consume_text()` to strip mode-symbol prefixes from submitted text. Added BDD test suite in `features/tui_prompt_mode_symbols.feature`. diff --git a/features/steps/tui_prompt_mode_symbols_steps.py b/features/steps/tui_prompt_mode_symbols_steps.py new file mode 100644 index 000000000..bf6b891fd --- /dev/null +++ b/features/steps/tui_prompt_mode_symbols_steps.py @@ -0,0 +1,199 @@ +"""Behave steps for PromptInput mode-dependent symbol (issue #6431).""" + +from __future__ import annotations + +import sys +import types +from typing import Any + +from behave import given, then, when +from behave.runner import Context + +# --------------------------------------------------------------------------- +# Module helpers +# --------------------------------------------------------------------------- + +_PROMPT_MOD = "cleveragents.tui.widgets.prompt" +_TEXTUAL_KEYS = [ + "textual", + "textual.app", + "textual.containers", + "textual.widgets", +] + + +def _reload_prompt_module() -> None: + """Reload the prompt module to reflect our changes.""" + import importlib + + mod = importlib.import_module(_PROMPT_MOD) + importlib.reload(mod) + + +def _install_mock_textarea_context(ctx: Context) -> tuple[Any, Any]: + """Inject mock Textual modules + reload prompt; return (mod, MockTextArea).""" + mock_textual = types.ModuleType("textual") + textual_app_mod = types.ModuleType("textual.app") + textual_containers_mod = types.ModuleType("textual.containers") + textual_widgets_mod = types.ModuleType("textual.widgets") + + class MockTextArea: + text: str = "" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + self.text = "" + + textual_app_mod.App = object + textual_containers_mod.Vertical = object + textual_widgets_mod.Header = object + textual_widgets_mod.Footer = object + textual_widgets_mod.Static = object + textual_widgets_mod.TextArea = MockTextArea + + saved: dict[str, Any | None] = {} + for key in _TEXTUAL_KEYS: + saved[key] = ctx._prompt_saved_modules.get(key) if hasattr(ctx, "_prompt_saved_modules") else sys.modules.pop(key, None) + + for key, mod_obj in [ + ("textual", mock_textual), + ("textual.app", textual_app_mod), + ("textual.containers", textual_containers_mod), + ("textual.widgets", textual_widgets_mod), + ]: + ctx._prompt_saved_modules[key] = saved.get(key) # type: ignore[attr-defined] + sys.modules[key] = mod_obj + + _reload_prompt_module() + mod = importlib.import_module(_PROMPT_MOD) + return mod, MockTextArea + + +# --------------------------------------------------------------------------- +# Background +# --------------------------------------------------------------------------- + + +@given("the prompt module is loaded with a mocked TextArea") +def step_load_mock_textarea(context: Context) -> None: + """Install mock Textual + reload prompt, prepare context.""" + context._prompt_saved_modules: dict[str, Any | None] = {} + for key in _TEXTUAL_KEYS: + context._prompt_saved_modules[key] = sys.modules.pop(key, None) + + # Build mock modules without the class attribute being set too early. + mock_textual_widgets = types.ModuleType("textual.widgets") + + class MockTextArea: # noqa: N801 + text: str = "" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + self.text = "" + + mock_textual_widgets.TextArea = MockTextArea + + sys.modules["textual.widgets"] = mock_textual_widgets + _reload_prompt_module() + context._prompt_mod = importlib.import_module(_PROMPT_MOD) + context._mock_textarea_cls = MockTextArea + + # Cleanup + def restore() -> None: + for key, val in context._prompt_saved_modules.items(): # type: ignore[attr-defined] + if val is None: + sys.modules.pop(key, None) + else: + sys.modules[key] = val + _reload_prompt_module() + + context.add_cleanup(restore) + + +# --------------------------------------------------------------------------- +# Detect mode symbol step +# --------------------------------------------------------------------------- + + +@when('I detect mode symbol for "{text}"') +def step_detect_mode_symbol(context: Context, text: str) -> None: + mod = context._prompt_mod # type: ignore[attr-defined] + context.detected_symbol = mod._detect_mode_symbol(text) + + +@then('the mode symbol should be "{symbol}"') +def step_symbol_matches(context: Context, symbol: str) -> None: + assert context.detected_symbol == symbol, ( + f"Expected '{symbol}', got '{context.detected_symbol}'" + ) + + +# --------------------------------------------------------------------------- +# PromptInput instance steps +# --------------------------------------------------------------------------- + + +@when("I create a PromptInput instance") +def step_create_prompt_input(context: Context) -> None: + context._prompt_instance = context._prompt_mod.PromptInput() # type: ignore[attr-defined] + + +@then('the PromptInput mode_symbol property should be "{symbol}"') +def step_mode_symbol_matches(context: Context, symbol: str) -> None: + assert context._prompt_instance.mode_symbol == symbol, ( # type: ignore[attr-defined] + f"Expected mode_symbol '{symbol}', got '{context._prompt_instance.mode_symbol}'" + ) + + +@when('I set the prompt text to "{text}"') +def step_set_prompt_text(context: Context, text: str) -> None: + context._prompt_instance.text = text # type: ignore[attr-defined] + + +@when("the prompt detects mode internally") +def step_prompt_detect_mode(context: Context) -> None: + """Trigger internal mode detection on the PromptInput instance.""" + context._prompt_instance._update_mode_symbol() # type: ignore[attr-defined] + + +@then("the PromptInput text should be empty") +def step_prompt_input_text_empty(context: Context) -> None: + assert context._prompt_instance.text == "", ( # type: ignore[attr-defined] + f"Expected empty text, got '{context._prompt_instance.text}'" + ) + + +# --------------------------------------------------------------------------- +# consume_text steps +# --------------------------------------------------------------------------- + + +@when("I call consume_text on the PromptInput") +def step_consume_prompt(context: Context) -> None: + context._submitted = context._prompt_instance.consume_text() # type: ignore[attr-defined] + + +@then('the PromptSubmitted text should be "{expected}"') +def step_submitted_text_matches(context: Context, expected: str) -> None: + assert context._submitted.text == expected, ( # type: ignore[attr-defined] + f"Expected '{expected}', got '{context._submitted.text}'" + ) + + +# --------------------------------------------------------------------------- +# Shell/bash-like prefix detection steps +# --------------------------------------------------------------------------- + + +@when('I check bash-like shell prefix "{text}"') +def step_bash_prefix(context: Context, text: str) -> None: + stripped = text.lstrip() + if not stripped: + context._bash_prefix_result: bool = False + else: + context._bash_prefix_result = stripped[0] in ("!", "$") + + +@then('the prefix detection result should be "{expected}"') +def step_prefix_expected(context: Context, expected: str) -> None: + assert (context._bash_prefix_result is True) == (expected.strip() == "true"), ( # type: ignore[attr-defined] + f"Expected {expected}, got {context._bash_prefix_result}" + ) diff --git a/features/tui_prompt_mode_symbols.feature b/features/tui_prompt_mode_symbols.feature new file mode 100644 index 000000000..5576c973d --- /dev/null +++ b/features/tui_prompt_mode_symbols.feature @@ -0,0 +1,142 @@ +Feature: PromptInput mode-dependent symbol (issue #6431) + The TUI prompt widget displays a mode symbol that changes based on the first + non-whitespace character of user input, matching the specification requirements. + + From spec lines 29304-29311, 29085, 29493: + + | First Character | Mode | Prompt Symbol | + |---------------------------|-----------|---------------| + | *(empty or any other)* | Normal | > | + | / | Command | / | + | ! or $ | Shell | $ | + + Background: + Given the prompt module is loaded with a mocked TextArea + + + Scenario: Empty text returns normal mode symbol '>' + When I detect mode symbol for "" + Then the mode symbol should be ">" + + + Scenario: Normal text returns normal mode symbol '>' + When I detect mode symbol for "hello world" + Then the mode symbol should be ">" + + + Scenario: At-reference text returns normal mode symbol '>' + When I detect mode symbol for "@README.md inspect this" + Then the mode symbol should be ">" + + + Scenario: Leading whitespace with normal text returns '>' + When I detect mode symbol for " hello world" + Then the mode symbol should be ">" + + + Scenario: Text starting with slash returns command mode symbol '/' + When I detect mode symbol for "/help" + Then the mode symbol should be "/" + + + Scenario: Slash command with spaces after slash returns '/' + When I detect mode symbol for "/ session list" + Then the mode symbol should be "/" + + + Scenario: Text starting with exclamation returns shell mode symbol '$' + When I detect mode symbol for "!echo hello" + Then the mode symbol should be "$" + + + Scenario: Text starting with dollar returns shell mode symbol '$' + When I detect mode symbol for "$ls -la" + Then the mode symbol should be "$" + + + Scenario: Leading whitespace with ! prefix returns shell mode '$' + When I detect mode symbol for " !echo hello" + Then the mode symbol should be "$" + + + Scenario: Leading whitespace with $ prefix returns shell mode '$' + When I detect mode symbol for " $ls -la" + Then the mode symbol should be "$" + + + Scenario: PromptInput instance starts with normal mode symbol + When I create a PromptInput instance + Then the PromptInput mode_symbol property should be ">" + + + Scenario: PromptInput sets command mode when text starts with slash + When I create a PromptInput instance + And I set the prompt text to "/help" + And the prompt detects mode internally + Then the PromptInput mode_symbol property should be "/" + + + Scenario: PromptInput sets shell mode when text starts with exclamation + When I create a PromptInput instance + And I set the prompt text to "!echo hello" + And the prompt detects mode internally + Then the PromptInput mode_symbol property should be "$" + + + Scenario: consume_text strips command prefix and returns clean input + When I create a PromptInput instance + And I set the prompt text to "/help" + And I call consume_text on the PromptInput + Then the PromptSubmitted text should be "help" + + + Scenario: consume_text strips shell prefix and returns clean input + When I create a PromptInput instance + And I set the prompt text to "!echo hello" + And I call consume_text on the PromptInput + Then the PromptSubmitted text should be "echo hello" + + + Scenario: consume_text with $ prefix returns clean input + When I create a PromptInput instance + And I set the prompt text to "$ls -la" + And I call consume_text on the PromptInput + Then the PromptSubmitted text should be "ls -la" + + + Scenario: consume_text normal mode keeps text unchanged + When I create a PromptInput instance + And I set the prompt text to "hello world" + And I call consume_text on the PromptInput + Then the PromptSubmitted text should be "hello world" + + + Scenario: consume_text clears text after consuming command input + When I create a PromptInput instance + And I set the prompt text to "/help" + And I call consume_text on the PromptInput + Then the PromptInput text should be empty + + + Scenario: consume_text clears text after consuming shell input + When I create a PromptInput instance + And I set the prompt text to "!echo hello" + And I call consume_text on the PromptInput + Then the PromptInput text should be empty + + + Scenario: Bash command prefix detection helper works for normal mode + When I check bash-like shell prefix "some plain text" + Then the prefix detection result should be "false" + + Scenario: Bash command prefix detection finds ! prefix + When I check bash-like shell prefix "!rm -rf /tmp" + Then the prefix detection result should be "true" + + Scenario: Bash command prefix detection finds $ prefix + When I check bash-like shell prefix "$find . -name '*.py'" + Then the prefix detection result should be "true" + + Scenario: Bash command prefix detection finds leading-whitespace-then ! + When I check bash-like shell prefix " !whoami" + Then the prefix detection result should be "true" diff --git a/src/cleveragents/tui/cleveragents.tcss b/src/cleveragents/tui/cleveragents.tcss index 94438c763..01ec2a77b 100644 --- a/src/cleveragents/tui/cleveragents.tcss +++ b/src/cleveragents/tui/cleveragents.tcss @@ -44,6 +44,17 @@ Screen { margin: 1 0 0 0; } +/* Mode-dependent prompt styling (visual affordance per spec). */ +#prompt.mode-shell { + border-color: $error; + color: $error; +} + +#prompt.mode-command { + border-color: $warning; + color: $warning; +} + #persona-bar { height: auto; padding: 0 1; diff --git a/src/cleveragents/tui/widgets/prompt.py b/src/cleveragents/tui/widgets/prompt.py index 099c9c666..14e77e71a 100644 --- a/src/cleveragents/tui/widgets/prompt.py +++ b/src/cleveragents/tui/widgets/prompt.py @@ -8,11 +8,14 @@ from typing import Any def _load_input_base() -> type[Any]: + """Load the Textual TextArea or a minimal fallback class.""" try: return importlib.import_module("textual.widgets").TextArea except Exception: # pragma: no cover class _FallbackInput: + """Minimal placeholder when Textual is unavailable.""" + text = "" def __init__(self, *args: object, **kwargs: object) -> None: @@ -31,10 +34,144 @@ class PromptSubmitted: text: str -class PromptInput(_InputBase): - """TextArea widget wrapper with helper methods.""" +def _detect_mode_symbol(text: str) -> str: + """Determine the mode symbol based on first non-whitespace character. - def consume_text(self) -> PromptSubmitted: - text = self.text - self.text = "" - return PromptSubmitted(text=text) + Per the spec (lines 29304-29311, 29085, 29493): + + | Character pattern | Mode | Symbol | + |-----------------------------|-----------|--------| + | *(empty / any other text)* | Normal | '>' | + | '/' (starts with slash) | Command | '/' | + | '!' or '$' (starts with !) | Shell | '$' | + + Args: + text: The current text content of the prompt input. + + Returns: + The mode symbol that should be displayed for the given text. + """ + stripped = text.lstrip() + if not stripped: + return ">" # default to normal mode when empty + first_char = stripped[0] + if first_char == "/": + return "/" + if first_char in ("!", "$"): + return "$" + return ">" + + +class PromptInput(_InputBase): + """TextArea widget wrapper with mode-dependent prompt symbol. + + The prompt symbol changes dynamically based on the first non-whitespace + character typed by the user: + + - ``">"`` -- normal mode (plain text, @references) + - ``"/"`` -- command mode (input starts with ``/``) + - ``"$"`` -- shell mode (input starts with ``!`` or ``$``) + """ + + _mode_symbol: str = ">" + + def __init__(self, *args: Any, **kwargs: Any) -> None: + super().__init__(*args, **kwargs) + self._mode_symbol = ">" # default to normal mode indicator + + @property + def mode_symbol(self) -> str: + """Return the current mode symbol based on text content.""" + return self._mode_symbol + + def _update_mode_classes(self, mode: str) -> None: + """Apply CSS modifier class reflecting the detected mode. + + Textual widgets support dynamic class toggling via ``self.classes``. + We set a prefix-free modifier (``mode-normal``, ``mode-command``, or + ``mode-shell``) so the TUI stylesheet can style each mode differently. + + Args: + mode: One of "normal", "command", "shell". + """ + if not hasattr(self, "classes"): # pragma: no cover + return + current = getattr(self, "_cached_mode_classes", "") + candidate = f"mode-{mode}" + if candidate != current: + self.classes = candidate + object.__setattr__(self, "_cached_mode_classes", candidate) + + def _update_mode_symbol(self) -> None: + """Re-evaluate and store the mode symbol from current text.""" + if not hasattr(self, "text"): # pragma: no cover + return + self._mode_symbol = _detect_mode_symbol(self.text) + # Mirror the same detection logic used by input/modes.py. + stripped = self.text.lstrip() if hasattr(self, "text") else "" + if not stripped: + mode_name = "normal" + elif stripped[0] == "/": + mode_name = "command" + elif stripped[0] in ("!", "$"): + mode_name = "shell" + else: + mode_name = "normal" + self._update_mode_classes(mode_name) + + +# ----------------------------------------------------------------------- +# Textual event hooks -- auto-update mode symbol as user types. +# ----------------------------------------------------------------------- + +if isinstance(_InputBase, type) and _InputBase is not object: + # Try to register ``on_input_changed`` (Textual < 0.61 style) + if hasattr(_InputBase, "on_input_changed"): # type: ignore[arg-type] + + def _prompt_on_input_changed( + self: PromptInput, *args: Any, **kwargs: Any + ) -> None: + self._update_mode_symbol() + + PromptInput.on_input_changed = _prompt_on_input_changed # type: ignore[attr-defined] + + # Try to register ``input_changed`` (Textual >= 0.61 style) + if hasattr(_InputBase, "input_changed"): # type: ignore[arg-type] + + def _prompt_input_changed( + self: PromptInput, value: str, start: int, end: int + ) -> None: + del value, start, end # read from instance text directly + self._update_mode_symbol() + + PromptInput.input_changed = _prompt_input_changed # type: ignore[attr-defined] + + +# ----------------------------------------------------------------------- +# consume_text -- mode-aware consumption with prefix stripping. +# ----------------------------------------------------------------------- + +if isinstance(_InputBase, type) and _InputBase is not object: + + def _consume_text(self: PromptInput) -> PromptSubmitted: + """Consume prompt text, updating symbol and stripping mode prefix. + + The leading mode-symbol character (``/``, ``!``, or ``$``) is removed + so the consumer receives clean user input without shell/command markers. + """ + self._update_mode_symbol() + raw: str = getattr(self, "text", "") or "" + # Strip mode prefix for command/shell modes; keep as-is for normal. + stripped = raw.lstrip() + if stripped: + first_char = stripped[0] + if first_char == "/" or first_char in ("!", "$"): + ws_part = raw[: len(raw) - len(stripped)] + raw = ws_part + stripped[1:] + self.text = "" # type: ignore[assignment] + return PromptSubmitted(text=raw) + + PromptInput.consume_text = _consume_text # type: ignore[attr-defined] + + +__all__: list[str] = ["PromptInput", "PromptSubmitted", "_detect_mode_symbol"]