diff --git a/clients/terminal/tui/widgets/command_hints.py b/clients/terminal/tui/widgets/command_hints.py index de638f7..19ea0e2 100644 --- a/clients/terminal/tui/widgets/command_hints.py +++ b/clients/terminal/tui/widgets/command_hints.py @@ -2,8 +2,9 @@ A non-interactive ``Static`` that renders the commands matching the current input while the user is typing a ``/``-command. It is purely visual — it never -takes focus and does not intercept keys. ``Tab`` completion and ``Enter`` -execution are handled by :class:`InputBox` / ``_PromptInput``. +takes focus. Keyboard navigation (``Up``/``Down`` to move the highlight, +``Enter`` to run the highlighted command, ``Tab`` to complete its name) is +handled by ``_PromptInput`` via a reference to this widget. Visibility rule: hints appear only while the input starts with ``/`` and contains no whitespace yet (i.e. the command name is still being typed). The @@ -26,7 +27,7 @@ class CommandHints(Static): - """Render matching slash commands for the current input.""" + """Render matching slash commands for the current input, with a highlight.""" DEFAULT_CSS = """ CommandHints { @@ -44,12 +45,14 @@ def __init__(self) -> None: super().__init__("") self._matches: list[BaseCommand] = [] + self._highlighted = 0 def update_for(self, text: str) -> None: """Recompute hints for ``text`` and show/hide the widget.""" # Show only while typing the command name: starts with '/' and no space. if not text.startswith("/") or any(ch.isspace() for ch in text): self._matches = [] + self._highlighted = 0 self.display = False return @@ -57,26 +60,46 @@ self._matches = get_registry().match(text[1:]) if not self._matches: + self._highlighted = 0 self.display = False return + # Keep the highlight valid when the match set shrinks; reset to top when + # the command name changed (highlight 0 is the exact-name-first match). + if self._highlighted >= len(self._matches): + self._highlighted = 0 self.update(self._build_content()) self.display = True - def first_match(self) -> BaseCommand | None: - """Top match (exact-name-first), used for ``Tab`` completion.""" - return self._matches[0] if self._matches else None + def visible(self) -> bool: + """True when the hint list is currently shown with at least one match.""" + return bool(self.display) and bool(self._matches) + + def current_match(self) -> BaseCommand | None: + """The highlighted match — what ``Enter``/``Tab`` act on.""" + if not self._matches: + return None + return self._matches[self._highlighted] + + def move_highlight(self, delta: int) -> bool: + """Move the highlight by ``delta`` (wraps around). Returns True if moved.""" + if len(self._matches) <= 1: + return False + self._highlighted = (self._highlighted + delta) % len(self._matches) + self.update(self._build_content()) + return True def _build_content(self) -> Text: lines: list[Text] = [] - for cmd in self._matches[:_MAX_HINTS]: + for index, cmd in enumerate(self._matches[:_MAX_HINTS]): meta = cmd.meta aliases = f" ({', '.join(meta.aliases)})" if meta.aliases else "" keybind = f" [{meta.keybind}]" if meta.keybind else "" - lines.append( - Text.assemble( - Text(f"/{meta.name}{aliases}", style="bold"), - Text(keybind, style="dim"), - Text(f" — {meta.description}", style="dim"), - ) + line = Text.assemble( + Text(f"/{meta.name}{aliases}", style="bold"), + Text(keybind, style="dim"), + Text(f" — {meta.description}", style="dim"), ) + if index == self._highlighted: + line.stylize("reverse") + lines.append(line) return Text("\n").join(lines) \ No newline at end of file diff --git a/clients/terminal/tui/widgets/input_box.py b/clients/terminal/tui/widgets/input_box.py index 8c90427..8eb42af 100644 --- a/clients/terminal/tui/widgets/input_box.py +++ b/clients/terminal/tui/widgets/input_box.py @@ -25,36 +25,73 @@ class _PromptInput(TextArea): """Multi-line text area that submits on Enter. - ``Tab`` completes an in-progress slash command: if the input starts with - ``/`` and has no whitespace yet, Tab replaces the text with the canonical - name of the top matching command (plus a trailing space) and moves the - cursor to the end. ``Enter`` submits as before. + Slash-command interaction while the hints list is open (input starts with + ``/`` and has no whitespace yet): + + - ``Up``/``Down`` move the highlight through the matching commands. + - ``Enter`` runs the highlighted command (it is dispatched via + :class:`UserSubmitted` as ``/ ``, which the app routes to + ``_run_command`` — not sent to the agent). + - ``Tab`` completes the input to the highlighted command's canonical name + (plus a trailing space) and keeps focus in the field for typing args. + + When the hints are not open, ``Enter`` submits the text as before (a + ``/cmd args`` line is still routed to ``_run_command`` by the app). """ + def __init__(self, *args, hints: CommandHints | None = None, **kwargs) -> None: + super().__init__(*args, **kwargs) + # Sibling hints widget. Named with a suffix to avoid shadowing any + # Textual Widget internals (see the _context/_render pitfall). + self._hints_ref: CommandHints | None = hints + async def _on_key(self, event: events.Key) -> None: + hints = self._hints_ref # Intercept before TextArea's own _on_key, which maps "enter" -> "\n". if event.key == "enter": event.stop() event.prevent_default() - self.action_submit() + self._submit() return if event.key == "tab" and self._complete_command(): event.stop() event.prevent_default() return + if event.key in ("up", "down") and hints is not None and hints.visible(): + delta = -1 if event.key == "up" else 1 + if hints.move_highlight(delta): + event.stop() + event.prevent_default() + return await super()._on_key(event) + def _submit(self) -> None: + """Submit, but if a command hint is open, run the highlighted command.""" + hints = self._hints_ref + if hints is not None and hints.visible(): + cmd = hints.current_match() + if cmd is not None: + # Route through action_submit so the app's _run_command handles it. + self.text = f"/{cmd.meta.name} " + self.action_submit() + def _complete_command(self) -> bool: - """If typing a slash command, complete to the top match. Returns True if handled.""" + """If typing a slash command, complete to the highlighted match.""" text = self.text if not text.startswith("/") or any(ch.isspace() for ch in text): return False - from clients.terminal.tui.commands.registry import get_registry + cmd = None + hints = self._hints_ref + if hints is not None and hints.visible(): + cmd = hints.current_match() + if cmd is None: + from clients.terminal.tui.commands.registry import get_registry - matches = get_registry().match(text[1:]) - if not matches: + matches = get_registry().match(text[1:]) + cmd = matches[0] if matches else None + if cmd is None: return False - self.text = f"/{matches[0].meta.name} " + self.text = f"/{cmd.meta.name} " self.move_cursor((0, len(self.text))) return True @@ -102,6 +139,7 @@ classes="input-field", show_line_numbers=False, soft_wrap=True, + hints=self._hints, ) def compose(self) -> ComposeResult: diff --git a/tests/clients/test_input_box.py b/tests/clients/test_input_box.py index 740a220..0cb97e0 100644 --- a/tests/clients/test_input_box.py +++ b/tests/clients/test_input_box.py @@ -142,4 +142,79 @@ # /help emits a status entry into the chat model — no user_message added. kinds = [i.kind for i in chat._model.items[before:]] assert "user_message" not in kinds - assert any(k in ("status", "error") for k in kinds) \ No newline at end of file + assert any(k in ("status", "error") for k in kinds) + + +@pytest.mark.anyio +async def test_down_arrow_moves_highlight() -> None: + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + await _set_text(pilot, "/t") # matches: thinking, themes + hints = pilot.app.query_one("CommandHints") + assert hints.current_match().meta.name == "thinking" + await pilot.press("down") + await pilot.pause() + assert hints.current_match().meta.name == "themes" + await pilot.press("up") + await pilot.pause() + assert hints.current_match().meta.name == "thinking" + + +@pytest.mark.anyio +async def test_arrow_keys_wrap_around() -> None: + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + await _set_text(pilot, "/t") + hints = pilot.app.query_one("CommandHints") + # Up from the top wraps to the bottom. + await pilot.press("up") + await pilot.pause() + assert hints.current_match().meta.name == "themes" + + +@pytest.mark.anyio +async def test_arrows_no_op_when_hints_hidden() -> None: + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + input_box = pilot.app.query_one("InputBox") + await _set_text(pilot, "hello") + await pilot.press("down") + await pilot.press("up") + await pilot.pause() + assert input_box._input.text == "hello" + + +@pytest.mark.anyio +async def test_enter_runs_highlighted_command(monkeypatch: pytest.MonkeyPatch) -> None: + """Enter while hints are open runs the highlighted command, not the typed prefix.""" + invoked: list[tuple[str, str]] = [] + + async def spy(self, cmd, args: str) -> None: + invoked.append((cmd.meta.name, args)) + + monkeypatch.setattr(NaviCodeTui, "_command_worker", spy) + + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + await _set_text(pilot, "/t") # matches: thinking (top), themes + await pilot.press("down") # highlight themes + await pilot.press("enter") + await pilot.pause() + assert invoked == [("themes", "")] + + +@pytest.mark.anyio +async def test_enter_without_highlight_runs_top_match(monkeypatch: pytest.MonkeyPatch) -> None: + invoked: list[tuple[str, str]] = [] + + async def spy(self, cmd, args: str) -> None: + invoked.append((cmd.meta.name, args)) + + monkeypatch.setattr(NaviCodeTui, "_command_worker", spy) + + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + await _set_text(pilot, "/h") # only "help" matches + await pilot.press("enter") + await pilot.pause() + assert invoked == [("help", "")] \ No newline at end of file