diff --git a/clients/terminal/tui/commands/registry.py b/clients/terminal/tui/commands/registry.py index 9357d2e..ae3c6fb 100644 --- a/clients/terminal/tui/commands/registry.py +++ b/clients/terminal/tui/commands/registry.py @@ -33,6 +33,30 @@ out.append(cmd) return out + def match(self, prefix: str) -> list["BaseCommand"]: + """Return commands whose name or alias matches ``prefix``. + + Case-insensitive prefix match against the canonical name and aliases. + An exact name/alias match sorts first, then remaining prefix matches in + registration order. An empty prefix returns every command (used when the + user has typed only ``/``). + """ + prefix = prefix.strip().lower() + if not prefix: + return self.all() + + def rank(cmd: "BaseCommand") -> int: + names = [cmd.meta.name.lower(), *(a.lower() for a in cmd.meta.aliases)] + if prefix in names: + return 0 + if any(n.startswith(prefix) for n in names): + return 1 + return 2 + + matched = [cmd for cmd in self.all() if rank(cmd) < 2] + matched.sort(key=rank) + return matched + _GLOBAL_REGISTRY: CommandRegistry | None = None diff --git a/clients/terminal/tui/widgets/__init__.py b/clients/terminal/tui/widgets/__init__.py index ee0a295..2ec28d3 100644 --- a/clients/terminal/tui/widgets/__init__.py +++ b/clients/terminal/tui/widgets/__init__.py @@ -3,8 +3,9 @@ from __future__ import annotations from .chat_panel import ChatPanel +from .command_hints import CommandHints from .input_box import InputBox from .status_bar import StatusBar from .status_panel import StatusPanel -__all__ = ["ChatPanel", "InputBox", "StatusBar", "StatusPanel"] +__all__ = ["ChatPanel", "CommandHints", "InputBox", "StatusBar", "StatusPanel"] diff --git a/clients/terminal/tui/widgets/command_hints.py b/clients/terminal/tui/widgets/command_hints.py new file mode 100644 index 0000000..de638f7 --- /dev/null +++ b/clients/terminal/tui/widgets/command_hints.py @@ -0,0 +1,82 @@ +"""Inline slash-command hints shown above the input box. + +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``. + +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 +moment a space appears the command name is complete and the hints hide. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from rich.text import Text +from textual.widgets import Static + +if TYPE_CHECKING: + from clients.terminal.tui.commands.base import BaseCommand + +# Cap the list so a one-character prefix does not push the whole input frame up +# by half a screen. +_MAX_HINTS = 8 + + +class CommandHints(Static): + """Render matching slash commands for the current input.""" + + DEFAULT_CSS = """ + CommandHints { + display: none; + height: auto; + max-height: 10; + width: 100%; + padding: 0 1; + background: $tui-surface; + color: $tui-text; + border-top: solid $tui-border; + } + """ + + def __init__(self) -> None: + super().__init__("") + self._matches: list[BaseCommand] = [] + + 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.display = False + return + + from clients.terminal.tui.commands.registry import get_registry + + self._matches = get_registry().match(text[1:]) + if not self._matches: + self.display = False + return + 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 _build_content(self) -> Text: + lines: list[Text] = [] + for cmd in 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"), + ) + ) + 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 c21218a..8c90427 100644 --- a/clients/terminal/tui/widgets/input_box.py +++ b/clients/terminal/tui/widgets/input_box.py @@ -19,10 +19,17 @@ from textual.widgets import TextArea from clients.terminal.tui.events import UserSubmitted +from clients.terminal.tui.widgets.command_hints import CommandHints class _PromptInput(TextArea): - """Multi-line text area that submits on Enter.""" + """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. + """ async def _on_key(self, event: events.Key) -> None: # Intercept before TextArea's own _on_key, which maps "enter" -> "\n". @@ -31,8 +38,26 @@ event.prevent_default() self.action_submit() return + if event.key == "tab" and self._complete_command(): + event.stop() + event.prevent_default() + return await super()._on_key(event) + def _complete_command(self) -> bool: + """If typing a slash command, complete to the top match. Returns True if handled.""" + 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 + + matches = get_registry().match(text[1:]) + if not matches: + return False + self.text = f"/{matches[0].meta.name} " + self.move_cursor((0, len(self.text))) + return True + def action_submit(self) -> None: text = self.text if text.strip(): @@ -70,6 +95,7 @@ def __init__(self) -> None: super().__init__() + self._hints = CommandHints() self._input = _PromptInput( text="", placeholder="Ask anything... (Enter to send)", @@ -79,11 +105,17 @@ ) def compose(self) -> ComposeResult: + yield self._hints yield self._input def on_mount(self) -> None: self._input.focus() + def on_text_area_changed(self, event: TextArea.Changed) -> None: + # Only the prompt's own textarea drives the hints. + if event.text_area is self._input: + self._hints.update_for(self._input.text) + def focus_input(self) -> None: self._input.focus() diff --git a/tests/clients/test_command_registry.py b/tests/clients/test_command_registry.py new file mode 100644 index 0000000..53fa594 --- /dev/null +++ b/tests/clients/test_command_registry.py @@ -0,0 +1,68 @@ +"""Unit tests for the command registry ``match`` helper.""" + +from __future__ import annotations + +from clients.terminal.tui.commands.base import BaseCommand, CommandMeta +from clients.terminal.tui.commands.registry import CommandRegistry + + +class _Cmd(BaseCommand): + def __init__(self, meta: CommandMeta) -> None: + self.meta = meta + + async def execute(self, ctx, args: str) -> None: # pragma: no cover - unused here + raise NotImplementedError + + +def _registry() -> CommandRegistry: + reg = CommandRegistry() + reg.register(_Cmd(CommandMeta("new", ("clear",), "Start new session"))) + reg.register(_Cmd(CommandMeta("sessions", ("resume", "continue"), "List sessions"))) + reg.register(_Cmd(CommandMeta("switch", (), "Switch session"))) + return reg + + +def test_match_empty_prefix_returns_all() -> None: + reg = _registry() + assert {c.meta.name for c in reg.match("")} == {"new", "sessions", "switch"} + + +def test_match_prefix_matches_name_and_alias() -> None: + reg = _registry() + names = {c.meta.name for c in reg.match("s")} + assert names == {"sessions", "switch"} + + +def test_match_exact_name_sorts_first() -> None: + reg = _registry() + matched = reg.match("switch") + assert [c.meta.name for c in matched] == ["switch"] + + +def test_match_exact_alias_sorts_first() -> None: + reg = _registry() + # "s" prefix-matches sessions + switch; "sessions" is an exact name but also + # "resume"/"continue" are aliases of sessions. Exact name match should win. + matched = reg.match("se") + assert matched[0].meta.name == "sessions" + + +def test_match_resolves_alias_exact() -> None: + reg = _registry() + matched = reg.match("resume") + assert [c.meta.name for c in matched] == ["sessions"] + + +def test_match_no_matches_returns_empty() -> None: + reg = _registry() + assert reg.match("zzz") == [] + + +def test_match_is_case_insensitive() -> None: + reg = _registry() + assert {c.meta.name for c in reg.match("SW")} == {"switch"} + + +def test_match_strips_whitespace() -> None: + reg = _registry() + assert {c.meta.name for c in reg.match(" new ")} == {"new"} \ No newline at end of file diff --git a/tests/clients/test_input_box.py b/tests/clients/test_input_box.py new file mode 100644 index 0000000..740a220 --- /dev/null +++ b/tests/clients/test_input_box.py @@ -0,0 +1,145 @@ +"""Tests for slash-command hints and Tab completion in the input box.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from clients.terminal.tui.tui_app import NaviCodeTui + + +@pytest.fixture(autouse=True) +def tmp_state_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Override the state dir so tests never touch ~/.navi_code.""" + from clients.terminal import config + + original = config.settings.state_dir + config.settings.state_dir = tmp_path + import clients.terminal.tui.settings as settings_module + + settings_module._tui_settings = None + yield tmp_path + config.settings.state_dir = original + settings_module._tui_settings = None + + +@pytest.fixture(autouse=True) +def mock_tui_api(monkeypatch: pytest.MonkeyPatch) -> None: + import clients.terminal.api as api_module + + monkeypatch.setattr( + api_module, + "create_session", + lambda profile_id=None: {"session_id": "test-session", "profile_id": "navi_code"}, + ) + monkeypatch.setattr( + api_module, "get_session", lambda sid: {"session_id": sid, "profile_id": "navi_code"} + ) + monkeypatch.setattr(api_module, "list_sessions", lambda: []) + monkeypatch.setattr(api_module, "get_profile_model", lambda pid: "configured-model") + + +async def _set_text(pilot, text: str) -> None: + """Set the prompt text and let the TUI react to the change message.""" + input_box = pilot.app.query_one("InputBox") + input_box._input.text = text + await pilot.pause() + + +@pytest.mark.anyio +async def test_hints_hidden_without_slash() -> None: + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + await _set_text(pilot, "hello there") + hints = pilot.app.query_one("CommandHints") + assert hints.display is False + + +@pytest.mark.anyio +async def test_hints_shown_for_slash_prefix() -> None: + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + await _set_text(pilot, "/s") + hints = pilot.app.query_one("CommandHints") + assert hints.display is True + names = {c.meta.name for c in hints._matches} + assert "sessions" in names and "switch" in names + + +@pytest.mark.anyio +async def test_hints_show_all_for_bare_slash() -> None: + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + await _set_text(pilot, "/") + hints = pilot.app.query_one("CommandHints") + assert hints.display is True + assert len(hints._matches) >= 1 + + +@pytest.mark.anyio +async def test_hints_hidden_once_whitespace_typed() -> None: + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + await _set_text(pilot, "/switch abc") + hints = pilot.app.query_one("CommandHints") + assert hints.display is False + + +@pytest.mark.anyio +async def test_hints_hidden_when_no_match() -> None: + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + await _set_text(pilot, "/zzzzzz") + hints = pilot.app.query_one("CommandHints") + assert hints.display is False + + +@pytest.mark.anyio +async def test_tab_completes_command_name() -> 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, "/sw") + await pilot.press("tab") + await pilot.pause() + assert input_box._input.text == "/switch " + + +@pytest.mark.anyio +async def test_tab_does_not_complete_without_slash() -> 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, "sw") + await pilot.press("tab") + await pilot.pause() + # No slash -> completion not applied; text unchanged (or default tab behavior). + assert input_box._input.text == "sw" + + +@pytest.mark.anyio +async def test_tab_does_not_complete_when_args_present() -> 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, "/switch abc") + await pilot.press("tab") + await pilot.pause() + assert input_box._input.text == "/switch abc" + + +@pytest.mark.anyio +async def test_enter_runs_slash_command_not_sent_to_agent() -> None: + """A slash command on Enter is dispatched as a command, not a user message.""" + async with NaviCodeTui(new_session=True).run_test() as pilot: + await pilot.pause() + chat = pilot.app.query_one("ChatPanel") + before = len(chat._model.items) + await _set_text(pilot, "/help") + await pilot.press("enter") + await pilot.pause() + # /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