From b6152d145a34ca11f700be16b470bede9b106d86 Mon Sep 17 00:00:00 2001 From: Christopher Pappas Date: Sun, 14 Dec 2025 11:47:09 -0800 Subject: [PATCH] feat: add slash fuzzymatch --- .../components/slash_command_menu.py | 58 +++++++++++++--- src/agent_chat_cli/components/user_input.py | 43 +++++++++--- tests/test_app.py | 67 +++++++++++++++++++ 3 files changed, 148 insertions(+), 20 deletions(-) diff --git a/src/agent_chat_cli/components/slash_command_menu.py b/src/agent_chat_cli/components/slash_command_menu.py index 92d824c..5a3a074 100644 --- a/src/agent_chat_cli/components/slash_command_menu.py +++ b/src/agent_chat_cli/components/slash_command_menu.py @@ -1,36 +1,76 @@ +from typing import Callable + from textual.widget import Widget from textual.app import ComposeResult +from textual.containers import VerticalScroll from textual.widgets import OptionList from textual.widgets.option_list import Option from agent_chat_cli.core.actions import Actions +COMMANDS = [ + {"id": "new", "label": "/new - Start new conversation"}, + {"id": "clear", "label": "/clear - Clear chat history"}, + {"id": "exit", "label": "/exit - Exit"}, +] + class SlashCommandMenu(Widget): - def __init__(self, actions: Actions) -> None: + def __init__( + self, actions: Actions, on_filter_change: Callable[[str], None] | None = None + ) -> None: super().__init__() self.actions = actions + self.filter_text = "" + self.on_filter_change = on_filter_change def compose(self) -> ComposeResult: - yield OptionList( - Option("/new - Start new conversation", id="new"), - Option("/clear - Clear chat history", id="clear"), - Option("/exit - Exit", id="exit"), - ) + yield OptionList(*[Option(cmd["label"], id=cmd["id"]) for cmd in COMMANDS]) def show(self) -> None: + self.filter_text = "" self.add_class("visible") - option_list = self.query_one(OptionList) - option_list.highlighted = 0 - option_list.focus() + self._refresh_options() + + scroll_containers = self.app.query(VerticalScroll) + if scroll_containers: + scroll_containers.first().scroll_end(animate=False) def hide(self) -> None: self.remove_class("visible") + self.filter_text = "" @property def is_visible(self) -> bool: return self.has_class("visible") + def _refresh_options(self) -> None: + option_list = self.query_one(OptionList) + option_list.clear_options() + + filtered = [ + cmd for cmd in COMMANDS if self.filter_text.lower() in cmd["id"].lower() + ] + + for cmd in filtered: + option_list.add_option(Option(cmd["label"], id=cmd["id"])) + + if filtered: + option_list.highlighted = 0 + + option_list.focus() + + def on_key(self, event) -> None: + if not self.is_visible: + return + + if event.is_printable and event.character: + self.filter_text += event.character + self._refresh_options() + + if self.on_filter_change: + self.on_filter_change(event.character) + async def on_option_list_option_selected( self, event: OptionList.OptionSelected ) -> None: diff --git a/src/agent_chat_cli/components/user_input.py b/src/agent_chat_cli/components/user_input.py index 9ec87d2..c6206ba 100644 --- a/src/agent_chat_cli/components/user_input.py +++ b/src/agent_chat_cli/components/user_input.py @@ -28,13 +28,25 @@ def compose(self) -> ComposeResult: show_line_numbers=False, soft_wrap=True, ) - yield SlashCommandMenu(actions=self.actions) + yield SlashCommandMenu( + actions=self.actions, on_filter_change=self._on_filter_change + ) + + def _on_filter_change(self, char: str) -> None: + text_area = self.query_one(TextArea) + if char == Key.BACKSPACE.value: + text_area.action_delete_left() + else: + text_area.insert(char) def on_mount(self) -> None: input_widget = self.query_one(TextArea) input_widget.focus() def on_descendant_blur(self, event: DescendantBlur) -> None: + if not self.display: + return + menu = self.query_one(SlashCommandMenu) if isinstance(event.widget, TextArea) and not menu.is_visible: @@ -68,20 +80,29 @@ def _insert_newline(self, event) -> None: input_widget.insert("\n") def _close_menu(self, event) -> None: - if event.key not in (Key.ESCAPE.value, Key.BACKSPACE.value, Key.DELETE.value): - return - - event.stop() - event.prevent_default() - menu = self.query_one(SlashCommandMenu) - menu.hide() - - input_widget = self.query_one(TextArea) - input_widget.focus() if event.key == Key.ESCAPE.value: + event.stop() + event.prevent_default() + menu.hide() + input_widget = self.query_one(TextArea) input_widget.clear() + input_widget.focus() + return + + if event.key in (Key.BACKSPACE.value, Key.DELETE.value): + if menu.filter_text: + menu.filter_text = menu.filter_text[:-1] + menu._refresh_options() + self.query_one(TextArea).action_delete_left() + else: + event.stop() + event.prevent_default() + menu.hide() + input_widget = self.query_one(TextArea) + input_widget.clear() + input_widget.focus() async def action_submit(self) -> None: menu = self.query_one(SlashCommandMenu) diff --git a/tests/test_app.py b/tests/test_app.py index 1399f40..973b76d 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -5,6 +5,7 @@ from textual.widgets import TextArea from agent_chat_cli.app import AgentChatCLIApp +from agent_chat_cli.components.slash_command_menu import SlashCommandMenu from agent_chat_cli.components.thinking_indicator import ThinkingIndicator from agent_chat_cli.components.tool_permission_prompt import ToolPermissionPrompt from agent_chat_cli.components.user_input import UserInput @@ -161,3 +162,69 @@ async def test_escape_triggers_interrupt_when_menu_not_visible( await pilot.press("escape") assert app.ui_state.interrupting is True + + +class TestSlashCommandMenuBehavior: + async def test_slash_opens_menu(self, mock_agent_loop, mock_config): + app = AgentChatCLIApp() + async with app.run_test() as pilot: + await pilot.press("/") + + menu = app.query_one(SlashCommandMenu) + assert menu.is_visible is True + + async def test_escape_closes_menu_and_clears_input( + self, mock_agent_loop, mock_config + ): + app = AgentChatCLIApp() + async with app.run_test() as pilot: + await pilot.press("/") + await pilot.press("c") + await pilot.press("escape") + + menu = app.query_one(SlashCommandMenu) + text_area = app.query_one(UserInput).query_one(TextArea) + + assert menu.is_visible is False + assert text_area.text == "" + + async def test_typing_filters_menu_and_shows_in_textarea( + self, mock_agent_loop, mock_config + ): + app = AgentChatCLIApp() + async with app.run_test() as pilot: + await pilot.press("/") + await pilot.press("c", "l") + + menu = app.query_one(SlashCommandMenu) + text_area = app.query_one(UserInput).query_one(TextArea) + + assert menu.filter_text == "cl" + assert text_area.text == "cl" + + async def test_backspace_removes_filter_character( + self, mock_agent_loop, mock_config + ): + app = AgentChatCLIApp() + async with app.run_test() as pilot: + await pilot.press("/") + await pilot.press("c", "l") + await pilot.press("backspace") + + menu = app.query_one(SlashCommandMenu) + text_area = app.query_one(UserInput).query_one(TextArea) + + assert menu.filter_text == "c" + assert text_area.text == "c" + assert menu.is_visible is True + + async def test_backspace_on_empty_filter_closes_menu( + self, mock_agent_loop, mock_config + ): + app = AgentChatCLIApp() + async with app.run_test() as pilot: + await pilot.press("/") + await pilot.press("backspace") + + menu = app.query_one(SlashCommandMenu) + assert menu.is_visible is False