fix: resolve TAB cursor disappearance and up/down double-handling (#58)

- Add priority binding for TAB to intercept before Textual's focus_next
- Remove duplicate up/down handling in on_key (now handled by priority
  bindings from PR #76)
- Update tests to use cmd_manager.list_commands() instead of removed
  _TUI_SLASH_COMMANDS

Closes #57

Co-authored-by: X-iZhang <zacharyzhang2022@gmail.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Icy Fish
2026-03-20 00:28:34 +01:00
committed by GitHub
parent c627aadd39
commit 649f2ca121
2 changed files with 341 additions and 11 deletions
+19 -11
View File
@@ -270,6 +270,7 @@ def run_textual_interactive(
BINDINGS: ClassVar[list[Binding]] = [
Binding("ctrl+c", "request_quit", "Quit", show=False),
Binding("ctrl+v", "paste_clipboard", "Paste", show=False),
Binding("tab", "tab_complete", show=False, priority=True),
Binding("up", "edit_queued", show=False, priority=True),
Binding("down", "down_delegate", show=False, priority=True),
Binding("escape", "cancel_queued", show=False, priority=True),
@@ -1710,22 +1711,29 @@ def run_textual_interactive(
prompt.value = new_value
prompt.cursor_position = pos + len(text)
def action_tab_complete(self) -> None:
"""Handle TAB: cycle completions when visible, otherwise no-op.
Registered as a priority binding so it intercepts before Textual's
default focus-next behaviour, which would steal focus from the input
and lose the cursor.
"""
comp_widget = self.query_one("#completions", Static)
if not (comp_widget.display and self._comp_items):
# No completions active — keep focus on the prompt.
self.query_one("#prompt", Input).focus()
return
self._comp_index = (self._comp_index + 1) % len(self._comp_items)
self._apply_selected_completion()
def on_key(self, event: Any) -> None:
comp_widget = self.query_one("#completions", Static)
if not (comp_widget.display and self._comp_items):
return
if event.key in ("tab", "down"):
event.prevent_default()
event.stop()
self._comp_index = (self._comp_index + 1) % len(self._comp_items)
self._render_completions()
elif event.key == "up":
event.prevent_default()
event.stop()
self._comp_index = (self._comp_index - 1) % len(self._comp_items)
self._render_completions()
elif event.key == "enter" and self._comp_index >= 0:
# Up/down are handled by priority bindings (action_edit_queued /
# action_down_delegate) — only enter needs on_key handling.
if event.key == "enter" and self._comp_index >= 0:
event.prevent_default()
event.stop()
self._apply_selected_completion()
+322
View File
@@ -510,5 +510,327 @@ class TestClipboardPaste(unittest.TestCase):
assert result is None or isinstance(result, str)
@unittest.skipUnless(_has_textual, "textual not installed")
class TestCompletionLogic(unittest.TestCase):
"""Unit tests for slash-command completion and TAB-key handling.
The completion methods live on EvoTextualInteractiveApp but require a
running Textual pilot to instantiate normally. We use a lightweight
stub that reimplements only the state fields and wires query_one() to
return fake widget objects, letting us exercise the pure logic without
starting the full TUI.
"""
# ------------------------------------------------------------------
# Stub infrastructure
# ------------------------------------------------------------------
def _make_app(self, comp_items=None, comp_index=-1):
"""Return a stub app-like object with completion state."""
from rich.text import Text
# Fake Input widget -------------------------------------------------
class _FakeInput:
def __init__(self):
self.value = ""
self.cursor_position = 0
def focus(self):
self._focused = True
# Fake Static widget ------------------------------------------------
class _FakeStatic:
def __init__(self):
self.display = False
self._content = None
def update(self, content):
self._content = content
fake_input = _FakeInput()
fake_completions = _FakeStatic()
# Widget registry -----------------------------------------------
_widgets = {
("#prompt", None): fake_input,
("#completions", None): fake_completions,
}
from EvoScientist.commands import manager as cmd_manager
_slash_commands = cmd_manager.list_commands()
# Build stub --------------------------------------------------------
class _StubApp:
"""Minimal stub that shares the real completion method bodies."""
def __init__(self):
self._comp_items: list = list(comp_items or [])
self._comp_index: int = comp_index
# Expose fakes for assertions
self._fake_input = fake_input
self._fake_completions = fake_completions
self._SLASH_COMMANDS = _slash_commands
def query_one(self, selector, widget_type=None):
# Match by selector string; widget_type is ignored in stub
if "prompt" in selector:
return fake_input
if "completions" in selector:
return fake_completions
raise KeyError(f"Unknown selector: {selector!r}")
# ---- copy real method bodies verbatim ----
def action_tab_complete(self):
comp_widget = self.query_one("#completions")
if not (comp_widget.display and self._comp_items):
self.query_one("#prompt").focus()
return
self._comp_index = (self._comp_index + 1) % len(self._comp_items)
self._apply_selected_completion()
def _apply_selected_completion(self):
selected_cmd = self._comp_items[self._comp_index][0]
prompt = self.query_one("#prompt")
prompt.value = selected_cmd + " "
prompt.cursor_position = len(prompt.value)
self._render_completions()
def _hide_completions(self):
self._comp_items = []
self._comp_index = -1
comp_widget = self.query_one("#completions")
comp_widget.display = False
def _render_completions(self):
comp_widget = self.query_one("#completions")
comp_text = Text()
for i, (cmd, desc) in enumerate(self._comp_items):
if i == self._comp_index:
comp_text.append("\u25b8 ", style="bold")
comp_text.append(f"{cmd:<22}", style="bold")
comp_text.append(desc, style="bold")
else:
comp_text.append(" ", style="#888888")
comp_text.append(f"{cmd:<22}", style="#888888")
comp_text.append(desc, style="#888888")
if i < len(self._comp_items) - 1:
comp_text.append("\n")
comp_widget.update(comp_text)
def on_input_changed(self, text: str):
"""Simplified version matching the real on_input_changed logic."""
comp_widget = self.query_one("#completions")
if text.startswith("/"):
prefix = text.lower()
matches = [
(cmd, desc)
for cmd, desc in self._SLASH_COMMANDS
if cmd.startswith(prefix)
]
if len(matches) == 1 and matches[0][0] == prefix:
self._hide_completions()
return
if matches:
self._comp_items = matches
self._comp_index = -1
self._render_completions()
comp_widget.display = True
return
self._hide_completions()
def on_key(self, key: str):
"""Simplified version matching the real on_key logic.
Up/down are handled by priority bindings, not on_key.
Only enter needs on_key handling.
"""
comp_widget = self.query_one("#completions")
if not (comp_widget.display and self._comp_items):
return False # did nothing
if key == "enter" and self._comp_index >= 0:
self._hide_completions()
return True
return False
return _StubApp()
# ------------------------------------------------------------------
# action_tab_complete
# ------------------------------------------------------------------
def test_tab_complete_no_completions_refocuses_prompt(self):
"""TAB with no active completions should refocus the prompt."""
app = self._make_app(comp_items=[])
app._fake_completions.display = False
app.action_tab_complete()
assert getattr(app._fake_input, "_focused", False) is True
def test_tab_complete_cycles_forward(self):
"""TAB with completions visible should advance the selection index."""
items = [("/resume", "desc1"), ("/run", "desc2"), ("/reset", "desc3")]
app = self._make_app(comp_items=items, comp_index=-1)
app._fake_completions.display = True
app.action_tab_complete()
assert app._comp_index == 0
assert app._fake_input.value == "/resume "
def test_tab_complete_wraps_around(self):
"""TAB past the last item should wrap back to index 0."""
items = [("/resume", "d1"), ("/run", "d2")]
app = self._make_app(comp_items=items, comp_index=1) # last item
app._fake_completions.display = True
app.action_tab_complete()
assert app._comp_index == 0
assert app._fake_input.value == "/resume "
def test_tab_complete_updates_cursor_position(self):
"""TAB should position the cursor at the end of the completed text."""
items = [("/skills", "List installed skills")]
app = self._make_app(comp_items=items, comp_index=-1)
app._fake_completions.display = True
app.action_tab_complete()
expected = "/skills "
assert app._fake_input.value == expected
assert app._fake_input.cursor_position == len(expected)
# ------------------------------------------------------------------
# _apply_selected_completion
# ------------------------------------------------------------------
def test_apply_selected_completion(self):
items = [("/new", "Start new"), ("/next", "Next")]
app = self._make_app(comp_items=items, comp_index=1)
app._apply_selected_completion()
assert app._fake_input.value == "/next "
assert app._fake_input.cursor_position == len("/next ")
# ------------------------------------------------------------------
# _hide_completions
# ------------------------------------------------------------------
def test_hide_completions_clears_state(self):
items = [("/resume", "d")]
app = self._make_app(comp_items=items, comp_index=0)
app._fake_completions.display = True
app._hide_completions()
assert app._comp_items == []
assert app._comp_index == -1
assert app._fake_completions.display is False
# ------------------------------------------------------------------
# _render_completions
# ------------------------------------------------------------------
def test_render_completions_produces_text(self):
"""_render_completions should call update() with a rich Text object."""
from rich.text import Text
items = [("/resume", "Resume session"), ("/run", "Run")]
app = self._make_app(comp_items=items, comp_index=0)
app._render_completions()
result = app._fake_completions._content
assert isinstance(result, Text)
plain = result.plain
assert "/resume" in plain
assert "/run" in plain
def test_render_completions_highlights_selected(self):
"""The selected item should have a bold arrow marker."""
from rich.text import Text
items = [("/help", "Help"), ("/hitl", "HITL")]
app = self._make_app(comp_items=items, comp_index=1)
app._render_completions()
result: Text = app._fake_completions._content
# Collect spans for bold segments
bold_spans = [
result.plain[s.start : s.end]
for s in result._spans
if "bold" in str(s.style)
]
# The arrow marker "▸" should appear in a bold span
arrow_in_bold = any("▸" in seg for seg in bold_spans)
assert arrow_in_bold, f"No bold arrow found. Spans: {bold_spans}"
# ------------------------------------------------------------------
# on_input_changed
# ------------------------------------------------------------------
def test_input_changed_slash_shows_completions(self):
"""/re prefix should show matching commands."""
app = self._make_app()
app.on_input_changed("/re")
assert app._fake_completions.display is True
assert len(app._comp_items) > 0
assert all(cmd.startswith("/re") for cmd, _ in app._comp_items)
def test_input_changed_exact_match_hides_completions(self):
"""An exact match for a command should hide completions."""
app = self._make_app()
# /help is the only command starting with /help
app.on_input_changed("/help")
assert app._fake_completions.display is False
def test_input_changed_non_slash_hides_completions(self):
"""Regular text (no leading slash) should hide completions."""
items = [("/resume", "d")]
app = self._make_app(comp_items=items)
app._fake_completions.display = True
app.on_input_changed("hello world")
assert app._fake_completions.display is False
def test_input_changed_no_match_hides_completions(self):
"""A /prefix that matches nothing should hide completions."""
app = self._make_app()
app._fake_completions.display = True
app.on_input_changed("/zzznomatch")
assert app._fake_completions.display is False
# ------------------------------------------------------------------
# on_key (enter only — up/down handled by priority bindings)
# ------------------------------------------------------------------
def test_on_key_enter_hides_completions_when_selected(self):
items = [("/resume", "d1")]
app = self._make_app(comp_items=items, comp_index=0)
app._fake_completions.display = True
handled = app.on_key("enter")
assert handled is True
assert app._fake_completions.display is False
assert app._comp_items == []
def test_on_key_enter_ignored_when_nothing_selected(self):
"""Enter with comp_index == -1 should not hide completions."""
items = [("/resume", "d1")]
app = self._make_app(comp_items=items, comp_index=-1)
app._fake_completions.display = True
handled = app.on_key("enter")
assert handled is False
assert app._fake_completions.display is True # unchanged
def test_on_key_noop_when_completions_hidden(self):
"""Key events should be ignored when completions are not visible."""
items = [("/resume", "d1")]
app = self._make_app(comp_items=items, comp_index=0)
app._fake_completions.display = False # hidden
handled = app.on_key("down")
assert handled is False
assert app._comp_index == 0 # unchanged
if __name__ == "__main__":
unittest.main()