Skip to content

Commit 5007a5a

Browse files
committed
feat: first pass slash commands
1 parent 0815f1b commit 5007a5a

File tree

12 files changed

+567
-42
lines changed

12 files changed

+567
-42
lines changed

src/agent_chat_cli/components/header.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ def compose(self) -> ComposeResult:
3333
yield Spacer()
3434

3535
yield Label(
36-
"[dim]Type your message and press Enter. Type 'exit' to quit.[/dim]",
36+
"[dim]Type your message and press Enter. Press / for commands.[/dim]",
3737
id="header-instructions",
3838
classes="header-instructions",
3939
)
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
from textual.widget import Widget
2+
from textual.app import ComposeResult
3+
from textual.widgets import OptionList
4+
from textual.widgets.option_list import Option
5+
6+
from agent_chat_cli.core.actions import Actions
7+
8+
9+
class SlashCommandMenu(Widget):
10+
def __init__(self, actions: Actions) -> None:
11+
super().__init__()
12+
self.actions = actions
13+
14+
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+
)
20+
21+
def show(self) -> None:
22+
self.add_class("visible")
23+
option_list = self.query_one(OptionList)
24+
option_list.highlighted = 0
25+
option_list.focus()
26+
27+
def hide(self) -> None:
28+
self.remove_class("visible")
29+
30+
@property
31+
def is_visible(self) -> bool:
32+
return self.has_class("visible")
33+
34+
async def on_option_list_option_selected(
35+
self, event: OptionList.OptionSelected
36+
) -> None:
37+
self.hide()
38+
39+
match event.option_id:
40+
case "exit":
41+
self.actions.quit()
42+
case "clear":
43+
await self.actions.clear()
44+
case "new":
45+
await self.actions.new()

src/agent_chat_cli/components/tool_permission_prompt.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
from agent_chat_cli.components.flex import Flex
1111
from agent_chat_cli.components.spacer import Spacer
1212
from agent_chat_cli.utils import get_tool_info
13+
from agent_chat_cli.utils.enums import Key
1314
from agent_chat_cli.utils.logger import log_json
1415

1516
if TYPE_CHECKING:
@@ -22,7 +23,7 @@ class ToolPermissionPrompt(Widget):
2223
tool_input: dict[str, Any] = reactive({}, init=False) # type: ignore[assignment]
2324

2425
BINDINGS = [
25-
Binding("enter", "submit", "Submit", priority=True),
26+
Binding(Key.ENTER.value, "submit", "Submit", priority=True),
2627
]
2728

2829
def __init__(self, actions: "Actions") -> None:
@@ -92,7 +93,7 @@ def on_descendant_blur(self) -> None:
9293
input_widget.focus()
9394

9495
async def on_key(self, event) -> None:
95-
if event.key == "escape":
96+
if event.key == Key.ESCAPE.value:
9697
log_json({"event": "permission_escape_pressed"})
9798

9899
event.stop()
Lines changed: 47 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
from textual.widget import Widget
22
from textual.app import ComposeResult
3-
from textual.widgets import TextArea
3+
from textual.widgets import TextArea, OptionList
44
from textual.binding import Binding
55
from textual.events import DescendantBlur
66

77
from agent_chat_cli.components.caret import Caret
88
from agent_chat_cli.components.flex import Flex
9+
from agent_chat_cli.components.slash_command_menu import SlashCommandMenu
910
from agent_chat_cli.core.actions import Actions
10-
from agent_chat_cli.utils.enums import ControlCommand
11+
from agent_chat_cli.utils.enums import Key
1112

1213

1314
class UserInput(Widget):
1415
BINDINGS = [
15-
Binding("enter", "submit", "Submit", priority=True),
16+
Binding(Key.ENTER.value, "submit", "Submit", priority=True),
17+
Binding(Key.ESCAPE.value, "hide_menu", "Hide Menu", priority=True),
1618
]
1719

1820
def __init__(self, actions: Actions) -> None:
@@ -27,38 +29,68 @@ def compose(self) -> ComposeResult:
2729
show_line_numbers=False,
2830
soft_wrap=True,
2931
)
32+
yield SlashCommandMenu(actions=self.actions)
3033

3134
def on_mount(self) -> None:
3235
input_widget = self.query_one(TextArea)
3336
input_widget.focus()
3437

3538
def on_descendant_blur(self, event: DescendantBlur) -> None:
36-
if isinstance(event.widget, TextArea):
39+
menu = self.query_one(SlashCommandMenu)
40+
41+
if isinstance(event.widget, TextArea) and not menu.is_visible:
3742
event.widget.focus(scroll_visible=False)
43+
elif isinstance(event.widget, OptionList) and menu.is_visible:
44+
menu.hide()
45+
self.query_one(TextArea).focus(scroll_visible=False)
46+
47+
def on_text_area_changed(self, event: TextArea.Changed) -> None:
48+
menu = self.query_one(SlashCommandMenu)
49+
text = event.text_area.text
50+
51+
if text == Key.SLASH.value:
52+
event.text_area.clear()
53+
menu.show()
3854

3955
async def on_key(self, event) -> None:
40-
if event.key == "ctrl+j":
56+
if event.key == Key.CTRL_J.value:
4157
event.stop()
4258
event.prevent_default()
4359
input_widget = self.query_one(TextArea)
4460
input_widget.insert("\n")
61+
return
62+
63+
menu = self.query_one(SlashCommandMenu)
64+
65+
if menu.is_visible and event.key in (Key.BACKSPACE.value, Key.DELETE.value):
66+
event.stop()
67+
event.prevent_default()
68+
menu.hide()
69+
self.query_one(TextArea).focus()
70+
71+
def action_hide_menu(self) -> None:
72+
menu = self.query_one(SlashCommandMenu)
73+
74+
if menu.is_visible:
75+
menu.hide()
76+
input_widget = self.query_one(TextArea)
77+
input_widget.clear()
78+
input_widget.focus()
4579

4680
async def action_submit(self) -> None:
81+
menu = self.query_one(SlashCommandMenu)
82+
83+
if menu.is_visible:
84+
option_list = menu.query_one(OptionList)
85+
option_list.action_select()
86+
self.query_one(TextArea).focus()
87+
return
88+
4789
input_widget = self.query_one(TextArea)
4890
user_message = input_widget.text.strip()
4991

5092
if not user_message:
5193
return
5294

53-
if user_message.lower() == ControlCommand.EXIT.value:
54-
self.actions.quit()
55-
return
56-
5795
input_widget.clear()
58-
59-
if user_message.lower() == ControlCommand.CLEAR.value:
60-
await self.actions.interrupt()
61-
await self.actions.new()
62-
return
63-
6496
await self.actions.submit_user_message(user_message)

src/agent_chat_cli/core/actions.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -37,14 +37,16 @@ async def interrupt(self) -> None:
3737
await self.app.agent_loop.client.interrupt()
3838
self.app.ui_state.stop_thinking()
3939

40-
async def new(self) -> None:
41-
await self.app.agent_loop.query_queue.put(ControlCommand.NEW_CONVERSATION)
42-
40+
async def clear(self) -> None:
4341
chat_history = self.app.query_one(ChatHistory)
4442
await chat_history.remove_children()
4543

4644
self.app.ui_state.stop_thinking()
4745

46+
async def new(self) -> None:
47+
await self.app.agent_loop.query_queue.put(ControlCommand.NEW_CONVERSATION)
48+
await self.clear()
49+
4850
async def respond_to_tool_permission(self, response: str) -> None:
4951
log_json(
5052
{

src/agent_chat_cli/core/agent_loop.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,8 @@ async def start(self) -> None:
7272
if user_input == ControlCommand.NEW_CONVERSATION:
7373
await self.client.disconnect()
7474

75+
self.session_id = None
76+
7577
mcp_servers = {
7678
name: config.model_dump()
7779
for name, config in self.available_servers.items()

src/agent_chat_cli/core/styles.tcss

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,3 +94,20 @@ TextArea {
9494
.tool-message {
9595
padding-left: 2;
9696
}
97+
98+
SlashCommandMenu {
99+
height: auto;
100+
max-height: 10;
101+
display: none;
102+
}
103+
104+
SlashCommandMenu.visible {
105+
display: block;
106+
}
107+
108+
SlashCommandMenu OptionList {
109+
height: auto;
110+
max-height: 10;
111+
border: solid $primary;
112+
background: $surface;
113+
}

src/agent_chat_cli/utils/enums.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,12 @@ class ControlCommand(Enum):
2222
NEW_CONVERSATION = "new_conversation"
2323
EXIT = "exit"
2424
CLEAR = "clear"
25+
26+
27+
class Key(Enum):
28+
ENTER = "enter"
29+
ESCAPE = "escape"
30+
BACKSPACE = "backspace"
31+
DELETE = "delete"
32+
CTRL_J = "ctrl+j"
33+
SLASH = "/"
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import pytest
2+
from unittest.mock import AsyncMock, MagicMock
3+
4+
from textual.app import App, ComposeResult
5+
from textual.widgets import OptionList
6+
7+
from agent_chat_cli.components.slash_command_menu import SlashCommandMenu
8+
9+
10+
class SlashCommandMenuApp(App):
11+
def __init__(self):
12+
super().__init__()
13+
self.mock_actions = MagicMock()
14+
self.mock_actions.quit = MagicMock()
15+
self.mock_actions.clear = AsyncMock()
16+
self.mock_actions.new = AsyncMock()
17+
18+
def compose(self) -> ComposeResult:
19+
yield SlashCommandMenu(actions=self.mock_actions)
20+
21+
22+
class TestSlashCommandMenuVisibility:
23+
@pytest.fixture
24+
def app(self):
25+
return SlashCommandMenuApp()
26+
27+
async def test_hidden_by_default(self, app):
28+
async with app.run_test():
29+
menu = app.query_one(SlashCommandMenu)
30+
31+
assert menu.is_visible is False
32+
33+
async def test_show_makes_visible(self, app):
34+
async with app.run_test():
35+
menu = app.query_one(SlashCommandMenu)
36+
menu.show()
37+
38+
assert menu.is_visible is True
39+
40+
async def test_hide_makes_invisible(self, app):
41+
async with app.run_test():
42+
menu = app.query_one(SlashCommandMenu)
43+
menu.show()
44+
menu.hide()
45+
46+
assert menu.is_visible is False
47+
48+
async def test_show_highlights_first_option(self, app):
49+
async with app.run_test():
50+
menu = app.query_one(SlashCommandMenu)
51+
menu.show()
52+
53+
option_list = menu.query_one(OptionList)
54+
assert option_list.highlighted == 0
55+
56+
57+
class TestSlashCommandMenuSelection:
58+
@pytest.fixture
59+
def app(self):
60+
return SlashCommandMenuApp()
61+
62+
async def test_new_command_calls_new(self, app):
63+
async with app.run_test() as pilot:
64+
menu = app.query_one(SlashCommandMenu)
65+
menu.show()
66+
67+
await pilot.press("enter")
68+
69+
app.mock_actions.new.assert_called_once()
70+
71+
async def test_clear_command_calls_clear(self, app):
72+
async with app.run_test() as pilot:
73+
menu = app.query_one(SlashCommandMenu)
74+
menu.show()
75+
76+
await pilot.press("down")
77+
await pilot.press("enter")
78+
79+
app.mock_actions.clear.assert_called_once()
80+
81+
async def test_exit_command_calls_quit(self, app):
82+
async with app.run_test() as pilot:
83+
menu = app.query_one(SlashCommandMenu)
84+
menu.show()
85+
86+
await pilot.press("down")
87+
await pilot.press("down")
88+
await pilot.press("enter")
89+
90+
app.mock_actions.quit.assert_called_once()
91+
92+
async def test_selection_hides_menu(self, app):
93+
async with app.run_test() as pilot:
94+
menu = app.query_one(SlashCommandMenu)
95+
menu.show()
96+
97+
await pilot.press("enter")
98+
99+
assert menu.is_visible is False

0 commit comments

Comments
 (0)