Skip to content

Commit b6152d1

Browse files
committed
feat: add slash fuzzymatch
1 parent 0edbd09 commit b6152d1

File tree

3 files changed

+148
-20
lines changed

3 files changed

+148
-20
lines changed

src/agent_chat_cli/components/slash_command_menu.py

Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,76 @@
1+
from typing import Callable
2+
13
from textual.widget import Widget
24
from textual.app import ComposeResult
5+
from textual.containers import VerticalScroll
36
from textual.widgets import OptionList
47
from textual.widgets.option_list import Option
58

69
from agent_chat_cli.core.actions import Actions
710

11+
COMMANDS = [
12+
{"id": "new", "label": "/new - Start new conversation"},
13+
{"id": "clear", "label": "/clear - Clear chat history"},
14+
{"id": "exit", "label": "/exit - Exit"},
15+
]
16+
817

918
class SlashCommandMenu(Widget):
10-
def __init__(self, actions: Actions) -> None:
19+
def __init__(
20+
self, actions: Actions, on_filter_change: Callable[[str], None] | None = None
21+
) -> None:
1122
super().__init__()
1223
self.actions = actions
24+
self.filter_text = ""
25+
self.on_filter_change = on_filter_change
1326

1427
def compose(self) -> ComposeResult:
15-
yield OptionList(
16-
Option("/new - Start new conversation", id="new"),
17-
Option("/clear - Clear chat history", id="clear"),
18-
Option("/exit - Exit", id="exit"),
19-
)
28+
yield OptionList(*[Option(cmd["label"], id=cmd["id"]) for cmd in COMMANDS])
2029

2130
def show(self) -> None:
31+
self.filter_text = ""
2232
self.add_class("visible")
23-
option_list = self.query_one(OptionList)
24-
option_list.highlighted = 0
25-
option_list.focus()
33+
self._refresh_options()
34+
35+
scroll_containers = self.app.query(VerticalScroll)
36+
if scroll_containers:
37+
scroll_containers.first().scroll_end(animate=False)
2638

2739
def hide(self) -> None:
2840
self.remove_class("visible")
41+
self.filter_text = ""
2942

3043
@property
3144
def is_visible(self) -> bool:
3245
return self.has_class("visible")
3346

47+
def _refresh_options(self) -> None:
48+
option_list = self.query_one(OptionList)
49+
option_list.clear_options()
50+
51+
filtered = [
52+
cmd for cmd in COMMANDS if self.filter_text.lower() in cmd["id"].lower()
53+
]
54+
55+
for cmd in filtered:
56+
option_list.add_option(Option(cmd["label"], id=cmd["id"]))
57+
58+
if filtered:
59+
option_list.highlighted = 0
60+
61+
option_list.focus()
62+
63+
def on_key(self, event) -> None:
64+
if not self.is_visible:
65+
return
66+
67+
if event.is_printable and event.character:
68+
self.filter_text += event.character
69+
self._refresh_options()
70+
71+
if self.on_filter_change:
72+
self.on_filter_change(event.character)
73+
3474
async def on_option_list_option_selected(
3575
self, event: OptionList.OptionSelected
3676
) -> None:

src/agent_chat_cli/components/user_input.py

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -28,13 +28,25 @@ def compose(self) -> ComposeResult:
2828
show_line_numbers=False,
2929
soft_wrap=True,
3030
)
31-
yield SlashCommandMenu(actions=self.actions)
31+
yield SlashCommandMenu(
32+
actions=self.actions, on_filter_change=self._on_filter_change
33+
)
34+
35+
def _on_filter_change(self, char: str) -> None:
36+
text_area = self.query_one(TextArea)
37+
if char == Key.BACKSPACE.value:
38+
text_area.action_delete_left()
39+
else:
40+
text_area.insert(char)
3241

3342
def on_mount(self) -> None:
3443
input_widget = self.query_one(TextArea)
3544
input_widget.focus()
3645

3746
def on_descendant_blur(self, event: DescendantBlur) -> None:
47+
if not self.display:
48+
return
49+
3850
menu = self.query_one(SlashCommandMenu)
3951

4052
if isinstance(event.widget, TextArea) and not menu.is_visible:
@@ -68,20 +80,29 @@ def _insert_newline(self, event) -> None:
6880
input_widget.insert("\n")
6981

7082
def _close_menu(self, event) -> None:
71-
if event.key not in (Key.ESCAPE.value, Key.BACKSPACE.value, Key.DELETE.value):
72-
return
73-
74-
event.stop()
75-
event.prevent_default()
76-
7783
menu = self.query_one(SlashCommandMenu)
78-
menu.hide()
79-
80-
input_widget = self.query_one(TextArea)
81-
input_widget.focus()
8284

8385
if event.key == Key.ESCAPE.value:
86+
event.stop()
87+
event.prevent_default()
88+
menu.hide()
89+
input_widget = self.query_one(TextArea)
8490
input_widget.clear()
91+
input_widget.focus()
92+
return
93+
94+
if event.key in (Key.BACKSPACE.value, Key.DELETE.value):
95+
if menu.filter_text:
96+
menu.filter_text = menu.filter_text[:-1]
97+
menu._refresh_options()
98+
self.query_one(TextArea).action_delete_left()
99+
else:
100+
event.stop()
101+
event.prevent_default()
102+
menu.hide()
103+
input_widget = self.query_one(TextArea)
104+
input_widget.clear()
105+
input_widget.focus()
85106

86107
async def action_submit(self) -> None:
87108
menu = self.query_one(SlashCommandMenu)

tests/test_app.py

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from textual.widgets import TextArea
66

77
from agent_chat_cli.app import AgentChatCLIApp
8+
from agent_chat_cli.components.slash_command_menu import SlashCommandMenu
89
from agent_chat_cli.components.thinking_indicator import ThinkingIndicator
910
from agent_chat_cli.components.tool_permission_prompt import ToolPermissionPrompt
1011
from agent_chat_cli.components.user_input import UserInput
@@ -161,3 +162,69 @@ async def test_escape_triggers_interrupt_when_menu_not_visible(
161162
await pilot.press("escape")
162163

163164
assert app.ui_state.interrupting is True
165+
166+
167+
class TestSlashCommandMenuBehavior:
168+
async def test_slash_opens_menu(self, mock_agent_loop, mock_config):
169+
app = AgentChatCLIApp()
170+
async with app.run_test() as pilot:
171+
await pilot.press("/")
172+
173+
menu = app.query_one(SlashCommandMenu)
174+
assert menu.is_visible is True
175+
176+
async def test_escape_closes_menu_and_clears_input(
177+
self, mock_agent_loop, mock_config
178+
):
179+
app = AgentChatCLIApp()
180+
async with app.run_test() as pilot:
181+
await pilot.press("/")
182+
await pilot.press("c")
183+
await pilot.press("escape")
184+
185+
menu = app.query_one(SlashCommandMenu)
186+
text_area = app.query_one(UserInput).query_one(TextArea)
187+
188+
assert menu.is_visible is False
189+
assert text_area.text == ""
190+
191+
async def test_typing_filters_menu_and_shows_in_textarea(
192+
self, mock_agent_loop, mock_config
193+
):
194+
app = AgentChatCLIApp()
195+
async with app.run_test() as pilot:
196+
await pilot.press("/")
197+
await pilot.press("c", "l")
198+
199+
menu = app.query_one(SlashCommandMenu)
200+
text_area = app.query_one(UserInput).query_one(TextArea)
201+
202+
assert menu.filter_text == "cl"
203+
assert text_area.text == "cl"
204+
205+
async def test_backspace_removes_filter_character(
206+
self, mock_agent_loop, mock_config
207+
):
208+
app = AgentChatCLIApp()
209+
async with app.run_test() as pilot:
210+
await pilot.press("/")
211+
await pilot.press("c", "l")
212+
await pilot.press("backspace")
213+
214+
menu = app.query_one(SlashCommandMenu)
215+
text_area = app.query_one(UserInput).query_one(TextArea)
216+
217+
assert menu.filter_text == "c"
218+
assert text_area.text == "c"
219+
assert menu.is_visible is True
220+
221+
async def test_backspace_on_empty_filter_closes_menu(
222+
self, mock_agent_loop, mock_config
223+
):
224+
app = AgentChatCLIApp()
225+
async with app.run_test() as pilot:
226+
await pilot.press("/")
227+
await pilot.press("backspace")
228+
229+
menu = app.query_one(SlashCommandMenu)
230+
assert menu.is_visible is False

0 commit comments

Comments
 (0)