From 649f2ca12103ea7a0e303fc80302d31e45df2e01 Mon Sep 17 00:00:00 2001 From: Icy Fish Date: Fri, 20 Mar 2026 00:28:34 +0100 Subject: [PATCH] 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 Co-authored-by: Claude Opus 4.6 (1M context) --- EvoScientist/cli/tui_interactive.py | 30 ++- tests/test_tui_widgets.py | 322 ++++++++++++++++++++++++++++ 2 files changed, 341 insertions(+), 11 deletions(-) diff --git a/EvoScientist/cli/tui_interactive.py b/EvoScientist/cli/tui_interactive.py index 2d15f60..ba5010d 100644 --- a/EvoScientist/cli/tui_interactive.py +++ b/EvoScientist/cli/tui_interactive.py @@ -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() diff --git a/tests/test_tui_widgets.py b/tests/test_tui_widgets.py index eb8caf0..f11592d 100644 --- a/tests/test_tui_widgets.py +++ b/tests/test_tui_widgets.py @@ -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()