feat(tui): implement escape cascade key behavior (#6450)

ISSUES CLOSED: #6450
This commit is contained in:
2026-04-09 23:22:45 +00:00
committed by drew
parent 422fa4d587
commit bf9a390d87
7 changed files with 265 additions and 35 deletions
+110 -4
View File
@@ -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)
# ---------------------------------------------------------------------------
@@ -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")
+26 -2
View File
@@ -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"
@@ -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
+57
View File
@@ -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)
@@ -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)
@@ -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()