diff --git a/gcode/ui.py b/gcode/ui.py index 916716c..0c03d0e 100644 --- a/gcode/ui.py +++ b/gcode/ui.py @@ -96,7 +96,10 @@ def goodbye(self, session: str) -> None: def _show_slash_menu(self) -> str: """Show an interactive slash command menu with arrow key navigation. - Returns the selected command string (e.g. '/help'). + Typing filters the list as you type (case-insensitive substring match, + e.g. "oll" narrows to /ollama); Backspace edits the filter, Enter runs + the highlighted command, and Esc cancels. Returns the selected command + string (e.g. '/help'), or "" when the user cancels or nothing matches. """ choices = [f"{cmd} — {desc}" for cmd, desc in _SLASH_COMMANDS] @@ -105,7 +108,9 @@ def _show_slash_menu(self) -> str: "Select a command:", choices=choices, use_shortcuts=False, - instruction="(↑↓ navigate, Enter select, Esc cancel)", + use_search_filter=True, + use_jk_keys=False, # j/k are consumed by the search filter + instruction="(↑↓ navigate, type to filter, Enter select, Esc cancel)", ).ask() except KeyboardInterrupt: return "" diff --git a/tests/test_ui.py b/tests/test_ui.py index 44ccb9c..40ed9b9 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -1,7 +1,16 @@ -from gcode.ui import RichUI +from unittest.mock import Mock, patch + +from gcode.ui import _SLASH_COMMANDS, RichUI from rich.console import Console +def _select_mock(return_value): + """Return a questionary.select() mock whose .ask() yields return_value.""" + fake_q = Mock() + fake_q.ask.return_value = return_value + return patch("gcode.ui.questionary.select", return_value=fake_q), fake_q + + def test_goodbye_renders_logo_and_session(): ui = RichUI() ui.console = Console(record=True, width=120) @@ -13,3 +22,53 @@ def test_goodbye_renders_logo_and_session(): assert "Thanks for coding with GCode." in output assert "session" in output.lower() assert "demo-session" in output + + +def test_slash_menu_enables_type_to_filter(): + """The slash menu turns on questionary's as-you-type search filter. + + Typing narrows the choices (e.g. "oll" filters to /ollama), while arrow + keys, Enter, and Esc keep working. j/k must be disabled because questionary + rejects combining them with the search filter. + """ + ui = RichUI() + patcher, _ = _select_mock("/ollama — List/select local Ollama models") + with patcher as mock_select: + selected = ui._show_slash_menu() + + assert selected == "/ollama" + kwargs = mock_select.call_args.kwargs + assert kwargs["use_search_filter"] is True + assert kwargs["use_jk_keys"] is False + assert kwargs["use_shortcuts"] is False + + +def test_slash_menu_cancel_returns_empty(): + """Esc (None result) or Ctrl+C returns "" so the prompt loop continues.""" + ui = RichUI() + + patcher, _ = _select_mock(None) + with patcher: + assert ui._show_slash_menu() == "" + + with patch("gcode.ui.questionary.select", side_effect=KeyboardInterrupt): + assert ui._show_slash_menu() == "" + + +def test_slash_menu_choice_labels_are_searchable(): + """Choice labels include the command name so a substring match filters. + + The full label is "{cmd} — {desc}"; filtering on "oll" must keep the + /ollama entry (and anything else containing the substring). + """ + labels = [f"{cmd} — {desc}" for cmd, desc in _SLASH_COMMANDS] + filtered = [label for label in labels if "oll" in label.lower()] + assert any("/ollama" in label for label in filtered) + assert len(filtered) >= 1 + + +def test_slash_menu_all_commands_parse_to_first_token(): + """Every selected label maps back to exactly its command name.""" + for cmd, desc in _SLASH_COMMANDS: + label = f"{cmd} — {desc}" + assert label.split()[0] == cmd