feat(tui): implement escape cascade key behavior (#6450)
ISSUES CLOSED: #6450
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user