diff --git a/features/steps/tui_app_coverage_steps.py b/features/steps/tui_app_coverage_steps.py index 98b7737e0..3ab762b66 100644 --- a/features/steps/tui_app_coverage_steps.py +++ b/features/steps/tui_app_coverage_steps.py @@ -626,3 +626,54 @@ def step_theme_class_var(context, theme: str) -> None: assert theme == context._tui_app.THEME, ( f"Expected THEME='{theme}', got '{context._tui_app.THEME}'" ) + + +# --------------------------------------------------------------------------- +# on_input_changed: live overlay update steps +# --------------------------------------------------------------------------- +def _trigger_input_changed(context, text: str) -> None: + """Set prompt text and fire on_input_changed.""" + from cleveragents.tui.widgets.prompt import PromptInput + + prompt = context._tui_app.query_one("#prompt", PromptInput) + prompt.text = text + event = SimpleNamespace() + context._tui_app.on_input_changed(event) + + +@when('I trigger on_input_changed with text "{text}"') +def step_trigger_input_changed(context, text: str) -> None: + _trigger_input_changed(context, text) + + +@then('the slash overlay should show only commands matching "{query}"') +def step_slash_overlay_filtered(context, query: str) -> None: + from cleveragents.tui.widgets.slash_command_overlay import SlashCommandOverlay + + overlay = context._tui_app.query_one("#slash-overlay", SlashCommandOverlay) + assert overlay._text, "Slash overlay text should not be empty" + # All displayed commands must start with the query + for cmd in overlay._commands: + assert cmd.command.startswith(query), ( + f"Command '{cmd.command}' does not start with '{query}'" + ) + + +@then("the reference picker should be reset to empty") +def step_ref_picker_reset(context) -> None: + from cleveragents.tui.widgets.reference_picker import ReferencePickerOverlay + + picker = context._tui_app.query_one("#reference-picker", ReferencePickerOverlay) + assert "(no matches)" in picker._text or picker._text.startswith("@"), ( + f"Expected reference picker to be reset, got: '{picker._text}'" + ) + + +@then("the slash overlay should be reset to all commands") +def step_slash_overlay_all_commands(context) -> None: + from cleveragents.tui.widgets.slash_command_overlay import SlashCommandOverlay + + overlay = context._tui_app.query_one("#slash-overlay", SlashCommandOverlay) + assert overlay._text, "Slash overlay text should not be empty after reset" + # When reset with empty query, all commands are shown (up to 12 displayed) + assert len(overlay._commands) > 0, "Slash overlay should have commands after reset" diff --git a/features/tui_app_coverage.feature b/features/tui_app_coverage.feature index fffb55161..6468da524 100644 --- a/features/tui_app_coverage.feature +++ b/features/tui_app_coverage.feature @@ -228,3 +228,44 @@ Feature: TUI App Coverage When I call action_escape on the app Then the reference picker overlay should be hidden And the prompt text should be "draft" + + # --- on_input_changed handler (live overlay updates) --- + + Scenario: on_input_changed with slash prefix filters slash commands live + Given a mock command router and persona state + When I instantiate the Textual TUI app + And I call on_mount on the app + And I trigger on_input_changed with text "/ses" + Then the slash overlay should show only commands matching "ses" + And the reference picker should be reset to empty + + Scenario: on_input_changed with at-sign updates reference picker live + Given a mock command router and persona state + When I instantiate the Textual TUI app + And I call on_mount on the app + And I trigger on_input_changed with text "@proj" + Then the reference picker should have been updated + And the slash overlay should be reset to all commands + + Scenario: on_input_changed with plain text resets both overlays + Given a mock command router and persona state + When I instantiate the Textual TUI app + And I call on_mount on the app + And I trigger on_input_changed with text "hello world" + Then the slash overlay should be reset to all commands + And the reference picker should be reset to empty + + Scenario: on_input_changed with empty at-sign resets reference picker + Given a mock command router and persona state + When I instantiate the Textual TUI app + And I call on_mount on the app + And I trigger on_input_changed with text "send @" + Then the reference picker should have been updated + + Scenario: on_input_changed with empty text resets both overlays + Given a mock command router and persona state + When I instantiate the Textual TUI app + And I call on_mount on the app + And I trigger on_input_changed with text "" + Then the slash overlay should be reset to all commands + And the reference picker should be reset to empty diff --git a/src/cleveragents/tui/app.py b/src/cleveragents/tui/app.py index bf771d54b..7b4cfdfcb 100644 --- a/src/cleveragents/tui/app.py +++ b/src/cleveragents/tui/app.py @@ -434,6 +434,36 @@ if _TEXTUAL_AVAILABLE: ) conversation.update(text) + def on_input_changed(self, event: InputSubmittedEvent) -> None: + """Update overlays live as the user types. + + - When text starts with ``/``, filter slash commands by the query + after the slash so the overlay narrows in real time. + - When text contains ``@``, extract the token after the last ``@`` + and update the reference picker with matching suggestions. + - Otherwise reset both overlays to their default (unfiltered) state. + """ + del event + prompt = self.query_one("#prompt", PromptInput) + text = prompt.text + slash = self.query_one("#slash-overlay", SlashCommandOverlay) + ref_picker = self.query_one("#reference-picker", ReferencePickerOverlay) + stripped = text.lstrip() + if stripped.startswith("/"): + query = stripped[1:] + slash.set_commands(query, slash_command_specs()) + ref_picker.set_suggestions("", []) + elif "@" in text: + at_index = text.rfind("@") + tail = text[at_index + 1 :] + tokens = tail.split() + query = tokens[0] if tokens else "" + ref_picker.set_suggestions(query, suggestions(query)) + slash.set_commands("", slash_command_specs()) + else: + slash.set_commands("", slash_command_specs()) + ref_picker.set_suggestions("", []) + def on_input_submitted(self, event: InputSubmittedEvent) -> None: del event prompt = self.query_one("#prompt", PromptInput)