diff --git a/features/steps/tui_app_coverage_steps.py b/features/steps/tui_app_coverage_steps.py index fd9d5c18c..98b7737e0 100644 --- a/features/steps/tui_app_coverage_steps.py +++ b/features/steps/tui_app_coverage_steps.py @@ -348,13 +348,13 @@ def step_ref_picker_initialised(context): assert hasattr(picker, "_text") -@then("the slash overlay should have commands initialised") -def step_slash_overlay_initialised(context): +@then("the slash overlay should be hidden on mount") +def step_slash_overlay_hidden_on_mount(context): from cleveragents.tui.widgets.slash_command_overlay import SlashCommandOverlay overlay = context._tui_app.query_one("#slash-overlay", SlashCommandOverlay) - assert hasattr(overlay, "_text") - assert overlay._text # non-empty + assert overlay.visible is False + assert overlay._text == "" # --------------------------------------------------------------------------- @@ -499,6 +499,112 @@ def step_ref_picker_updated(context): assert picker._text # updated with suggestions +# --------------------------------------------------------------------------- +# action_escape cascade (issue #6450) +# --------------------------------------------------------------------------- +@when("I show the actor selection overlay") +def step_show_actor_selection(context): + from cleveragents.tui.widgets.actor_selection_overlay import ( + ActorSelectionOverlay, + ) + + overlay = context._tui_app.query_one("#actor-selection", ActorSelectionOverlay) + overlay.show() + + +@when('I show the help panel overlay for context "{context_name}"') +def step_show_help_panel_context(context, context_name): + from cleveragents.tui.widgets.help_panel_overlay import HelpPanelOverlay + + panel = context._tui_app.query_one("#help-panel", HelpPanelOverlay) + panel.show_context(context_name) + + +@when('I show the slash overlay for query "{query}"') +def step_show_slash_overlay(context, query): + from cleveragents.tui.widgets.slash_command_overlay import SlashCommandOverlay + + overlay = context._tui_app.query_one("#slash-overlay", SlashCommandOverlay) + commands = [SimpleNamespace(command=query, description=f"/{query} command")] + overlay.set_commands(query, commands) + + +@when('I show the reference picker overlay for query "{query}"') +def step_show_reference_picker(context, query): + from cleveragents.tui.widgets.reference_picker import ReferencePickerOverlay + + picker = context._tui_app.query_one("#reference-picker", ReferencePickerOverlay) + picker.set_suggestions(query, [query]) + + +@when("I call action_escape on the app") +def step_call_action_escape(context): + context._tui_app.action_escape() + + +@then("the actor selection overlay should be hidden") +def step_actor_overlay_hidden(context): + from cleveragents.tui.widgets.actor_selection_overlay import ( + ActorSelectionOverlay, + ) + + overlay = context._tui_app.query_one("#actor-selection", ActorSelectionOverlay) + assert overlay.visible is False + + +@then("the help panel should remain visible") +def step_help_panel_visible(context): + from cleveragents.tui.widgets.help_panel_overlay import HelpPanelOverlay + + panel = context._tui_app.query_one("#help-panel", HelpPanelOverlay) + assert panel.visible is True + assert panel._text + + +@then("the slash overlay should remain visible") +def step_slash_overlay_visible(context): + from cleveragents.tui.widgets.slash_command_overlay import SlashCommandOverlay + + overlay = context._tui_app.query_one("#slash-overlay", SlashCommandOverlay) + assert overlay.visible is True + assert overlay._text + + +@then("the slash overlay should be hidden") +def step_slash_overlay_hidden(context): + from cleveragents.tui.widgets.slash_command_overlay import SlashCommandOverlay + + overlay = context._tui_app.query_one("#slash-overlay", SlashCommandOverlay) + assert overlay.visible is False + assert overlay._text == "" + + +@then("the reference picker overlay should remain visible") +def step_reference_picker_visible(context): + from cleveragents.tui.widgets.reference_picker import ReferencePickerOverlay + + picker = context._tui_app.query_one("#reference-picker", ReferencePickerOverlay) + assert picker.visible is True + assert picker._text + + +@then("the reference picker overlay should be hidden") +def step_reference_picker_hidden(context): + from cleveragents.tui.widgets.reference_picker import ReferencePickerOverlay + + picker = context._tui_app.query_one("#reference-picker", ReferencePickerOverlay) + assert picker.visible is False + assert picker._text == "" + + +@then('the prompt text should be "{text}"') +def step_prompt_text_equals(context, text): + from cleveragents.tui.widgets.prompt import PromptInput + + prompt = context._tui_app.query_one("#prompt", PromptInput) + assert prompt.value == text + + # --------------------------------------------------------------------------- # CleverAgentsTuiApp alias (line 189) # --------------------------------------------------------------------------- diff --git a/features/steps/tui_slash_command_overlay_coverage_steps.py b/features/steps/tui_slash_command_overlay_coverage_steps.py index 61ed8fe58..631688cd9 100644 --- a/features/steps/tui_slash_command_overlay_coverage_steps.py +++ b/features/steps/tui_slash_command_overlay_coverage_steps.py @@ -3,11 +3,9 @@ These steps target uncovered lines in slash_command_overlay.py: - Line 16: _FallbackStatic.__init__ sets self._text = "" - Line 19: _FallbackStatic.update sets self._text = text -- Lines 31-32: set_commands filtering logic (query vs empty query) -- Line 34: lines = [f"/{query}"] -- Lines 35-36: loop appending filtered commands with descriptions -- Lines 37-38: "(no commands)" fallback when nothing matches -- Line 39: self.update("\n".join(lines)) +- Lines 53-56: set_commands hide path for empty queries +- Lines 57-65: command filtering, truncation, and no-match messaging +- Lines 66-69: visible flag updates and update() call """ import importlib @@ -57,6 +55,7 @@ def step_module_imported(context): def step_create_overlay(context): """Instantiate SlashCommandOverlay, hitting _FallbackStatic.__init__.""" context.overlay = context._sco_overlay_cls() + context._overlay = context.overlay @then("the overlay internal text should be empty") @@ -72,6 +71,7 @@ def step_verify_empty_text(context): def step_have_overlay(context): """Create and store an overlay instance for subsequent steps.""" context.overlay = context._sco_overlay_cls() + context._overlay = context.overlay @when('I call update with "{text}"') @@ -87,7 +87,6 @@ def step_verify_text(context, expected): f"Expected '{expected}', got '{context.overlay._text}'" ) - # --------------------------------------------------------------------------- # set_commands with query (lines 31-36, 39) # --------------------------------------------------------------------------- @@ -137,15 +136,15 @@ def step_text_not_contains(context, substring): # set_commands truncation to 12 entries (line 35 [:12]) # --------------------------------------------------------------------------- @when( - 'I call set_commands with query "" and {count:d} commands prefixed with "{prefix}"' + 'I call set_commands with query "{query}" and {count:d} commands prefixed with "{prefix}"' ) -def step_call_set_commands_many(context, count, prefix): +def step_call_set_commands_many(context, query, count, prefix): """Call set_commands with a large number of commands (as SlashCommandSpec).""" specs = [ SlashCommandSpec(command=f"{prefix}{i}", group="Test", description=f"Desc {i}") for i in range(count) ] - context.overlay.set_commands("", specs) + context.overlay.set_commands(query, specs) @then("the overlay text should have exactly {expected:d} lines") diff --git a/features/tui_app_coverage.feature b/features/tui_app_coverage.feature index a610e42ea..fffb55161 100644 --- a/features/tui_app_coverage.feature +++ b/features/tui_app_coverage.feature @@ -47,7 +47,7 @@ Feature: TUI App Coverage Given a mock command router and persona state When I instantiate the Textual TUI app Then the app class should have CSS_PATH set to "cleveragents.tcss" - And the app class should have 3 key bindings + And the app class should have 4 key bindings # --- compose method (lines 102-112) --- @@ -66,7 +66,7 @@ Feature: TUI App Coverage Then the persona bar should have content set And the help panel should be hidden on mount And the reference picker should have suggestions initialised - And the slash overlay should have commands initialised + And the slash overlay should be hidden on mount # --- action_help method (lines 123-125) --- @@ -204,3 +204,27 @@ Feature: TUI App Coverage Given a mock command router and persona state When I instantiate the Textual TUI app Then the app class should have THEME set to "dracula" + + # --- action_escape cascade (issue #6450) --- + + Scenario: action_escape cascades overlays toward the prompt + 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 set the prompt text to "draft @README.md" + And I show the actor selection overlay + And I show the help panel overlay for context "Main Screen" + And I show the slash overlay for query "help" + And I show the reference picker overlay for query "README.md" + And I call action_escape on the app + Then the actor selection overlay should be hidden + And the help panel should remain visible + When I call action_escape on the app + Then the help panel should be hidden on mount + And the slash overlay should remain visible + When I call action_escape on the app + Then the slash overlay should be hidden + And the reference picker overlay should remain visible + When I call action_escape on the app + Then the reference picker overlay should be hidden + And the prompt text should be "draft" diff --git a/features/tui_slash_command_overlay_coverage.feature b/features/tui_slash_command_overlay_coverage.feature index 7d245cc88..16340070e 100644 --- a/features/tui_slash_command_overlay_coverage.feature +++ b/features/tui_slash_command_overlay_coverage.feature @@ -22,26 +22,27 @@ Feature: TUI Slash Command Overlay Coverage And the overlay text should contain " /hello" And the overlay text should not contain " /history" And the overlay text should not contain " /quit" + And the overlay should be visible - Scenario: set_commands returns all commands when query is empty + Scenario: set_commands hides overlay when query is empty Given I have a SlashCommandOverlay instance When I call set_commands with empty query and commands "help,history,quit" - Then the overlay text should contain "/" - And the overlay text should contain " /help" - And the overlay text should contain " /history" - And the overlay text should contain " /quit" + Then the overlay should not be visible + And the overlay internal text should be empty Scenario: set_commands shows no-commands message when nothing matches Given I have a SlashCommandOverlay instance When I call set_commands with query "zzz" and commands "help,history,quit" Then the overlay text should contain "/zzz" And the overlay text should contain " (no commands)" + And the overlay should be visible Scenario: set_commands truncates to at most twelve command entries Given I have a SlashCommandOverlay instance - When I call set_commands with query "" and 15 commands prefixed with "cmd" + When I call set_commands with query "cmd" and 15 commands prefixed with "cmd" Then the overlay text should have exactly 13 lines And the overlay text should not contain " /cmd13" + And the overlay should be visible Scenario: FallbackStatic class is returned when textual import fails When I force the fallback static base to load diff --git a/src/cleveragents/tui/app.py b/src/cleveragents/tui/app.py index 4734ff2cb..4de84e2e1 100644 --- a/src/cleveragents/tui/app.py +++ b/src/cleveragents/tui/app.py @@ -228,6 +228,7 @@ if _TEXTUAL_AVAILABLE: ("ctrl+q", "quit", "Quit"), ("f1", "help", "Help"), ("ctrl+t", "cycle_preset", "Cycle Preset"), + ("escape", "escape", "Close Overlay"), ] def __init__( @@ -308,6 +309,62 @@ if _TEXTUAL_AVAILABLE: self._persona_state.cycle_preset(self._session.session_id) self._refresh_persona_bar() + def action_escape(self) -> None: + prompt = self.query_one("#prompt", PromptInput) + + actor_overlay = self.query_one("#actor-selection", ActorSelectionOverlay) + if actor_overlay.visible: + actor_overlay.hide() + return + + help_panel = self.query_one("#help-panel", HelpPanelOverlay) + if help_panel.visible: + help_panel.hide() + return + + slash_overlay = self.query_one("#slash-overlay", SlashCommandOverlay) + if getattr(slash_overlay, "visible", False): + slash_overlay.hide() + return + + ref_picker = self.query_one("#reference-picker", ReferencePickerOverlay) + if getattr(ref_picker, "visible", False): + self._remove_reference_trigger(prompt) + ref_picker.hide() + return + + focus_method = getattr(prompt, "focus", None) + if callable(focus_method): + focus_method() + + @staticmethod + def _remove_reference_trigger(prompt: PromptInput) -> None: + value = getattr(prompt, "value", "") + if not value: + return + + trigger_index = value.rfind("@") + if trigger_index == -1: + return + + prefix = value[:trigger_index] + suffix = value[trigger_index + 1 :] + + token_end = 0 + while token_end < len(suffix) and not suffix[token_end].isspace(): + token_end += 1 + + suffix_after = suffix[token_end:] + prefix_clean = prefix.rstrip() + suffix_clean = suffix_after.lstrip() + + if prefix_clean and suffix_clean: + prompt.value = f"{prefix_clean} {suffix_clean}" + elif prefix_clean: + prompt.value = prefix_clean + else: + prompt.value = suffix_clean + def _refresh_persona_bar(self) -> None: persona = self._persona_state.active_persona(self._session.session_id) preset = self._persona_state.current_preset(self._session.session_id) diff --git a/src/cleveragents/tui/widgets/reference_picker.py b/src/cleveragents/tui/widgets/reference_picker.py index 9d777db2c..530f5ae2c 100644 --- a/src/cleveragents/tui/widgets/reference_picker.py +++ b/src/cleveragents/tui/widgets/reference_picker.py @@ -27,11 +27,36 @@ _StaticBase = _load_static_base() class ReferencePickerOverlay(_StaticBase): """Simple overlay displaying reference suggestions.""" + def __init__(self, *args: object, **kwargs: object) -> None: + super().__init__(*args, **kwargs) + self._visible = False + self._text = "" + + @property + def visible(self) -> bool: + """Return whether the overlay is currently visible.""" + + return self._visible + + def hide(self) -> None: + """Hide the overlay and clear its content.""" + + self._visible = False + self._text = "" + self.update("") + def set_suggestions(self, query: str, suggestions: list[str]) -> None: - if not suggestions: - self.update(f"@{query}\n(no matches)") + if not query: + self.hide() return + lines = [f"@{query}"] - for item in suggestions[:10]: - lines.append(f" {item}") - self.update("\n".join(lines)) + if not suggestions: + lines.append(" (no matches)") + else: + for item in suggestions[:10]: + lines.append(f" {item}") + + self._visible = True + self._text = "\n".join(lines) + self.update(self._text) diff --git a/src/cleveragents/tui/widgets/slash_command_overlay.py b/src/cleveragents/tui/widgets/slash_command_overlay.py index 33ee67e6d..e3ce42d58 100644 --- a/src/cleveragents/tui/widgets/slash_command_overlay.py +++ b/src/cleveragents/tui/widgets/slash_command_overlay.py @@ -36,13 +36,30 @@ class SlashCommandOverlay(_StaticBase): super().__init__(*args, **kwargs) self.selected_index: int = 0 self._commands: list[SlashCommandSpec] = [] + self._visible = False + self._text = "" + + @property + def visible(self) -> bool: + """Return whether the overlay is currently visible.""" + + return self._visible + + def hide(self) -> None: + """Hide the overlay and clear its content.""" + + self._visible = False + self._text = "" + self._commands = [] + self.selected_index = 0 + self.update("") def set_commands(self, query: str, commands: list[SlashCommandSpec]) -> None: - filtered = ( - [item for item in commands if item.command.startswith(query)] - if query - else list(commands) - ) + if not query: + self.hide() + return + + filtered = [item for item in commands if item.command.startswith(query)] self._commands = filtered self.selected_index = 0 lines = [f"/{query}"] @@ -52,7 +69,10 @@ class SlashCommandOverlay(_StaticBase): lines.append(f"{name_col}{' ' * padding}{spec.description}") if len(lines) == 1: lines.append(" (no commands)") - self.update("\n".join(lines)) + + self._visible = True + self._text = "\n".join(lines) + self.update(self._text) def navigate_up(self) -> None: """Move selection up by one, clamped to zero.""" @@ -73,6 +93,4 @@ class SlashCommandOverlay(_StaticBase): def dismiss(self) -> None: """Dismiss/hide the overlay (Escape key action).""" - self.update("") - self._commands = [] - self.selected_index = 0 + self.hide()