Skip to content

Commit 0edbd09

Browse files
authored
Merge pull request #13 from damassi/feat/slash-commands
feat: Add '/' slash commands
2 parents 0815f1b + aa7010c commit 0edbd09

File tree

16 files changed

+615
-53
lines changed

16 files changed

+615
-53
lines changed

.github/workflows/ci.yml

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,11 @@ on:
77
branches: [main]
88

99
jobs:
10-
test:
10+
lint:
1111
runs-on: ubuntu-latest
1212
steps:
1313
- uses: actions/checkout@v4
1414

15-
- name: Set up Python
16-
uses: actions/setup-python@v5
17-
with:
18-
python-version: "3.12"
19-
2015
- name: Install uv
2116
uses: astral-sh/setup-uv@v4
2217

@@ -26,8 +21,30 @@ jobs:
2621
- name: Run linter
2722
run: uv run ruff check src tests
2823

24+
typecheck:
25+
runs-on: ubuntu-latest
26+
steps:
27+
- uses: actions/checkout@v4
28+
29+
- name: Install uv
30+
uses: astral-sh/setup-uv@v4
31+
32+
- name: Install dependencies
33+
run: uv sync --all-groups
34+
2935
- name: Run type checker
3036
run: uv run mypy src
3137

38+
test:
39+
runs-on: ubuntu-latest
40+
steps:
41+
- uses: actions/checkout@v4
42+
43+
- name: Install uv
44+
uses: astral-sh/setup-uv@v4
45+
46+
- name: Install dependencies
47+
run: uv sync --all-groups
48+
3249
- name: Run tests
3350
run: uv run pytest

Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ console:
77
uv run textual console -x SYSTEM -x EVENT -x DEBUG -x INFO
88

99
dev:
10-
uv run textual run --dev -c chat
10+
LOG_LEVEL=NOTSET uv run textual run --dev -c chat
1111

1212
lint:
1313
uv run ruff check --fix src

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: 56 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,19 @@
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),
1617
]
1718

1819
def __init__(self, actions: Actions) -> None:
@@ -27,38 +28,75 @@ def compose(self) -> ComposeResult:
2728
show_line_numbers=False,
2829
soft_wrap=True,
2930
)
31+
yield SlashCommandMenu(actions=self.actions)
3032

3133
def on_mount(self) -> None:
3234
input_widget = self.query_one(TextArea)
3335
input_widget.focus()
3436

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

3954
async def on_key(self, event) -> None:
40-
if event.key == "ctrl+j":
41-
event.stop()
42-
event.prevent_default()
43-
input_widget = self.query_one(TextArea)
44-
input_widget.insert("\n")
55+
if event.key == Key.CTRL_J.value:
56+
self._insert_newline(event)
57+
return
4558

46-
async def action_submit(self) -> None:
59+
menu = self.query_one(SlashCommandMenu)
60+
61+
if menu.is_visible:
62+
self._close_menu(event)
63+
64+
def _insert_newline(self, event) -> None:
65+
event.stop()
66+
event.prevent_default()
4767
input_widget = self.query_one(TextArea)
48-
user_message = input_widget.text.strip()
68+
input_widget.insert("\n")
4969

50-
if not user_message:
70+
def _close_menu(self, event) -> None:
71+
if event.key not in (Key.ESCAPE.value, Key.BACKSPACE.value, Key.DELETE.value):
5172
return
5273

53-
if user_message.lower() == ControlCommand.EXIT.value:
54-
self.actions.quit()
74+
event.stop()
75+
event.prevent_default()
76+
77+
menu = self.query_one(SlashCommandMenu)
78+
menu.hide()
79+
80+
input_widget = self.query_one(TextArea)
81+
input_widget.focus()
82+
83+
if event.key == Key.ESCAPE.value:
84+
input_widget.clear()
85+
86+
async def action_submit(self) -> None:
87+
menu = self.query_one(SlashCommandMenu)
88+
89+
if menu.is_visible:
90+
option_list = menu.query_one(OptionList)
91+
option_list.action_select()
92+
self.query_one(TextArea).focus()
5593
return
5694

57-
input_widget.clear()
95+
input_widget = self.query_one(TextArea)
96+
user_message = input_widget.text.strip()
5897

59-
if user_message.lower() == ControlCommand.CLEAR.value:
60-
await self.actions.interrupt()
61-
await self.actions.new()
98+
if not user_message:
6299
return
63100

101+
input_widget.clear()
64102
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 = "/"

0 commit comments

Comments
 (0)