From 5007a5aa8df0558eba0785391d0602320c66d511 Mon Sep 17 00:00:00 2001 From: Christopher Pappas Date: Sun, 14 Dec 2025 10:27:29 -0800 Subject: [PATCH 1/3] feat: first pass slash commands --- src/agent_chat_cli/components/header.py | 2 +- .../components/slash_command_menu.py | 45 +++ .../components/tool_permission_prompt.py | 5 +- src/agent_chat_cli/components/user_input.py | 62 +++- src/agent_chat_cli/core/actions.py | 8 +- src/agent_chat_cli/core/agent_loop.py | 2 + src/agent_chat_cli/core/styles.tcss | 17 ++ src/agent_chat_cli/utils/enums.py | 9 + tests/components/test_slash_command_menu.py | 99 ++++++ tests/components/test_user_input.py | 59 ++-- tests/core/test_agent_loop.py | 284 ++++++++++++++++++ tests/utils/test_enums.py | 17 +- 12 files changed, 567 insertions(+), 42 deletions(-) create mode 100644 src/agent_chat_cli/components/slash_command_menu.py create mode 100644 tests/components/test_slash_command_menu.py create mode 100644 tests/core/test_agent_loop.py diff --git a/src/agent_chat_cli/components/header.py b/src/agent_chat_cli/components/header.py index 8395397..44bf62b 100644 --- a/src/agent_chat_cli/components/header.py +++ b/src/agent_chat_cli/components/header.py @@ -33,7 +33,7 @@ def compose(self) -> ComposeResult: yield Spacer() yield Label( - "[dim]Type your message and press Enter. Type 'exit' to quit.[/dim]", + "[dim]Type your message and press Enter. Press / for commands.[/dim]", id="header-instructions", classes="header-instructions", ) diff --git a/src/agent_chat_cli/components/slash_command_menu.py b/src/agent_chat_cli/components/slash_command_menu.py new file mode 100644 index 0000000..92d824c --- /dev/null +++ b/src/agent_chat_cli/components/slash_command_menu.py @@ -0,0 +1,45 @@ +from textual.widget import Widget +from textual.app import ComposeResult +from textual.widgets import OptionList +from textual.widgets.option_list import Option + +from agent_chat_cli.core.actions import Actions + + +class SlashCommandMenu(Widget): + def __init__(self, actions: Actions) -> None: + super().__init__() + self.actions = actions + + 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"), + ) + + def show(self) -> None: + self.add_class("visible") + option_list = self.query_one(OptionList) + option_list.highlighted = 0 + option_list.focus() + + def hide(self) -> None: + self.remove_class("visible") + + @property + def is_visible(self) -> bool: + return self.has_class("visible") + + async def on_option_list_option_selected( + self, event: OptionList.OptionSelected + ) -> None: + self.hide() + + match event.option_id: + case "exit": + self.actions.quit() + case "clear": + await self.actions.clear() + case "new": + await self.actions.new() diff --git a/src/agent_chat_cli/components/tool_permission_prompt.py b/src/agent_chat_cli/components/tool_permission_prompt.py index b2e407e..05677de 100644 --- a/src/agent_chat_cli/components/tool_permission_prompt.py +++ b/src/agent_chat_cli/components/tool_permission_prompt.py @@ -10,6 +10,7 @@ from agent_chat_cli.components.flex import Flex from agent_chat_cli.components.spacer import Spacer from agent_chat_cli.utils import get_tool_info +from agent_chat_cli.utils.enums import Key from agent_chat_cli.utils.logger import log_json if TYPE_CHECKING: @@ -22,7 +23,7 @@ class ToolPermissionPrompt(Widget): tool_input: dict[str, Any] = reactive({}, init=False) # type: ignore[assignment] BINDINGS = [ - Binding("enter", "submit", "Submit", priority=True), + Binding(Key.ENTER.value, "submit", "Submit", priority=True), ] def __init__(self, actions: "Actions") -> None: @@ -92,7 +93,7 @@ def on_descendant_blur(self) -> None: input_widget.focus() async def on_key(self, event) -> None: - if event.key == "escape": + if event.key == Key.ESCAPE.value: log_json({"event": "permission_escape_pressed"}) event.stop() diff --git a/src/agent_chat_cli/components/user_input.py b/src/agent_chat_cli/components/user_input.py index caf6860..9b45db9 100644 --- a/src/agent_chat_cli/components/user_input.py +++ b/src/agent_chat_cli/components/user_input.py @@ -1,18 +1,20 @@ from textual.widget import Widget from textual.app import ComposeResult -from textual.widgets import TextArea +from textual.widgets import TextArea, OptionList from textual.binding import Binding from textual.events import DescendantBlur from agent_chat_cli.components.caret import Caret from agent_chat_cli.components.flex import Flex +from agent_chat_cli.components.slash_command_menu import SlashCommandMenu from agent_chat_cli.core.actions import Actions -from agent_chat_cli.utils.enums import ControlCommand +from agent_chat_cli.utils.enums import Key class UserInput(Widget): BINDINGS = [ - Binding("enter", "submit", "Submit", priority=True), + Binding(Key.ENTER.value, "submit", "Submit", priority=True), + Binding(Key.ESCAPE.value, "hide_menu", "Hide Menu", priority=True), ] def __init__(self, actions: Actions) -> None: @@ -27,38 +29,68 @@ def compose(self) -> ComposeResult: show_line_numbers=False, soft_wrap=True, ) + yield SlashCommandMenu(actions=self.actions) def on_mount(self) -> None: input_widget = self.query_one(TextArea) input_widget.focus() def on_descendant_blur(self, event: DescendantBlur) -> None: - if isinstance(event.widget, TextArea): + menu = self.query_one(SlashCommandMenu) + + if isinstance(event.widget, TextArea) and not menu.is_visible: event.widget.focus(scroll_visible=False) + elif isinstance(event.widget, OptionList) and menu.is_visible: + menu.hide() + self.query_one(TextArea).focus(scroll_visible=False) + + def on_text_area_changed(self, event: TextArea.Changed) -> None: + menu = self.query_one(SlashCommandMenu) + text = event.text_area.text + + if text == Key.SLASH.value: + event.text_area.clear() + menu.show() async def on_key(self, event) -> None: - if event.key == "ctrl+j": + if event.key == Key.CTRL_J.value: event.stop() event.prevent_default() input_widget = self.query_one(TextArea) input_widget.insert("\n") + return + + menu = self.query_one(SlashCommandMenu) + + if menu.is_visible and event.key in (Key.BACKSPACE.value, Key.DELETE.value): + event.stop() + event.prevent_default() + menu.hide() + self.query_one(TextArea).focus() + + def action_hide_menu(self) -> None: + menu = self.query_one(SlashCommandMenu) + + if menu.is_visible: + 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) + + if menu.is_visible: + option_list = menu.query_one(OptionList) + option_list.action_select() + self.query_one(TextArea).focus() + return + input_widget = self.query_one(TextArea) user_message = input_widget.text.strip() if not user_message: return - if user_message.lower() == ControlCommand.EXIT.value: - self.actions.quit() - return - input_widget.clear() - - if user_message.lower() == ControlCommand.CLEAR.value: - await self.actions.interrupt() - await self.actions.new() - return - await self.actions.submit_user_message(user_message) diff --git a/src/agent_chat_cli/core/actions.py b/src/agent_chat_cli/core/actions.py index 9b1964f..27d361c 100644 --- a/src/agent_chat_cli/core/actions.py +++ b/src/agent_chat_cli/core/actions.py @@ -37,14 +37,16 @@ async def interrupt(self) -> None: await self.app.agent_loop.client.interrupt() self.app.ui_state.stop_thinking() - async def new(self) -> None: - await self.app.agent_loop.query_queue.put(ControlCommand.NEW_CONVERSATION) - + async def clear(self) -> None: chat_history = self.app.query_one(ChatHistory) await chat_history.remove_children() self.app.ui_state.stop_thinking() + async def new(self) -> None: + await self.app.agent_loop.query_queue.put(ControlCommand.NEW_CONVERSATION) + await self.clear() + async def respond_to_tool_permission(self, response: str) -> None: log_json( { diff --git a/src/agent_chat_cli/core/agent_loop.py b/src/agent_chat_cli/core/agent_loop.py index f59685c..fe8cab0 100644 --- a/src/agent_chat_cli/core/agent_loop.py +++ b/src/agent_chat_cli/core/agent_loop.py @@ -72,6 +72,8 @@ async def start(self) -> None: if user_input == ControlCommand.NEW_CONVERSATION: await self.client.disconnect() + self.session_id = None + mcp_servers = { name: config.model_dump() for name, config in self.available_servers.items() diff --git a/src/agent_chat_cli/core/styles.tcss b/src/agent_chat_cli/core/styles.tcss index ed63d0e..1912250 100644 --- a/src/agent_chat_cli/core/styles.tcss +++ b/src/agent_chat_cli/core/styles.tcss @@ -94,3 +94,20 @@ TextArea { .tool-message { padding-left: 2; } + +SlashCommandMenu { + height: auto; + max-height: 10; + display: none; +} + +SlashCommandMenu.visible { + display: block; +} + +SlashCommandMenu OptionList { + height: auto; + max-height: 10; + border: solid $primary; + background: $surface; +} diff --git a/src/agent_chat_cli/utils/enums.py b/src/agent_chat_cli/utils/enums.py index 293adfb..b638e20 100644 --- a/src/agent_chat_cli/utils/enums.py +++ b/src/agent_chat_cli/utils/enums.py @@ -22,3 +22,12 @@ class ControlCommand(Enum): NEW_CONVERSATION = "new_conversation" EXIT = "exit" CLEAR = "clear" + + +class Key(Enum): + ENTER = "enter" + ESCAPE = "escape" + BACKSPACE = "backspace" + DELETE = "delete" + CTRL_J = "ctrl+j" + SLASH = "/" diff --git a/tests/components/test_slash_command_menu.py b/tests/components/test_slash_command_menu.py new file mode 100644 index 0000000..e63cd96 --- /dev/null +++ b/tests/components/test_slash_command_menu.py @@ -0,0 +1,99 @@ +import pytest +from unittest.mock import AsyncMock, MagicMock + +from textual.app import App, ComposeResult +from textual.widgets import OptionList + +from agent_chat_cli.components.slash_command_menu import SlashCommandMenu + + +class SlashCommandMenuApp(App): + def __init__(self): + super().__init__() + self.mock_actions = MagicMock() + self.mock_actions.quit = MagicMock() + self.mock_actions.clear = AsyncMock() + self.mock_actions.new = AsyncMock() + + def compose(self) -> ComposeResult: + yield SlashCommandMenu(actions=self.mock_actions) + + +class TestSlashCommandMenuVisibility: + @pytest.fixture + def app(self): + return SlashCommandMenuApp() + + async def test_hidden_by_default(self, app): + async with app.run_test(): + menu = app.query_one(SlashCommandMenu) + + assert menu.is_visible is False + + async def test_show_makes_visible(self, app): + async with app.run_test(): + menu = app.query_one(SlashCommandMenu) + menu.show() + + assert menu.is_visible is True + + async def test_hide_makes_invisible(self, app): + async with app.run_test(): + menu = app.query_one(SlashCommandMenu) + menu.show() + menu.hide() + + assert menu.is_visible is False + + async def test_show_highlights_first_option(self, app): + async with app.run_test(): + menu = app.query_one(SlashCommandMenu) + menu.show() + + option_list = menu.query_one(OptionList) + assert option_list.highlighted == 0 + + +class TestSlashCommandMenuSelection: + @pytest.fixture + def app(self): + return SlashCommandMenuApp() + + async def test_new_command_calls_new(self, app): + async with app.run_test() as pilot: + menu = app.query_one(SlashCommandMenu) + menu.show() + + await pilot.press("enter") + + app.mock_actions.new.assert_called_once() + + async def test_clear_command_calls_clear(self, app): + async with app.run_test() as pilot: + menu = app.query_one(SlashCommandMenu) + menu.show() + + await pilot.press("down") + await pilot.press("enter") + + app.mock_actions.clear.assert_called_once() + + async def test_exit_command_calls_quit(self, app): + async with app.run_test() as pilot: + menu = app.query_one(SlashCommandMenu) + menu.show() + + await pilot.press("down") + await pilot.press("down") + await pilot.press("enter") + + app.mock_actions.quit.assert_called_once() + + async def test_selection_hides_menu(self, app): + async with app.run_test() as pilot: + menu = app.query_one(SlashCommandMenu) + menu.show() + + await pilot.press("enter") + + assert menu.is_visible is False diff --git a/tests/components/test_user_input.py b/tests/components/test_user_input.py index 102f6b7..05280e4 100644 --- a/tests/components/test_user_input.py +++ b/tests/components/test_user_input.py @@ -4,6 +4,7 @@ from textual.app import App, ComposeResult from textual.widgets import TextArea +from agent_chat_cli.components.slash_command_menu import SlashCommandMenu from agent_chat_cli.components.user_input import UserInput @@ -14,6 +15,7 @@ def __init__(self): self.mock_actions.quit = MagicMock() self.mock_actions.interrupt = AsyncMock() self.mock_actions.new = AsyncMock() + self.mock_actions.clear = AsyncMock() self.mock_actions.submit_user_message = AsyncMock() def compose(self) -> ComposeResult: @@ -52,45 +54,62 @@ async def test_clears_input_after_submit(self, app): assert text_area.text == "" -class TestUserInputControlCommands: +class TestUserInputNewlines: @pytest.fixture def app(self): return UserInputApp() - async def test_exit_command_quits(self, app): + async def test_ctrl_j_inserts_newline(self, app): async with app.run_test() as pilot: user_input = app.query_one(UserInput) text_area = user_input.query_one(TextArea) - text_area.insert("exit") + text_area.insert("line1") - await pilot.press("enter") + await pilot.press("ctrl+j") + text_area.insert("line2") + + assert "line1\nline2" in text_area.text - app.mock_actions.quit.assert_called_once() - async def test_clear_command_resets_conversation(self, app): +class TestUserInputSlashMenu: + @pytest.fixture + def app(self): + return UserInputApp() + + async def test_slash_shows_menu(self, app): async with app.run_test() as pilot: user_input = app.query_one(UserInput) text_area = user_input.query_one(TextArea) - text_area.insert("clear") + menu = user_input.query_one(SlashCommandMenu) - await pilot.press("enter") + await pilot.press("/") - app.mock_actions.interrupt.assert_called_once() - app.mock_actions.new.assert_called_once() + assert menu.is_visible is True + assert text_area.text == "" + async def test_escape_hides_menu(self, app): + async with app.run_test() as pilot: + user_input = app.query_one(UserInput) + menu = user_input.query_one(SlashCommandMenu) -class TestUserInputNewlines: - @pytest.fixture - def app(self): - return UserInputApp() + await pilot.press("/") + await pilot.press("escape") - async def test_ctrl_j_inserts_newline(self, app): + assert menu.is_visible is False + + async def test_backspace_hides_menu(self, app): async with app.run_test() as pilot: user_input = app.query_one(UserInput) - text_area = user_input.query_one(TextArea) - text_area.insert("line1") + menu = user_input.query_one(SlashCommandMenu) - await pilot.press("ctrl+j") - text_area.insert("line2") + await pilot.press("/") + await pilot.press("backspace") - assert "line1\nline2" in text_area.text + assert menu.is_visible is False + + async def test_enter_selects_menu_item(self, app): + async with app.run_test() as pilot: + await pilot.press("/") + await pilot.press("enter") + + app.mock_actions.new.assert_called_once() diff --git a/tests/core/test_agent_loop.py b/tests/core/test_agent_loop.py new file mode 100644 index 0000000..a4d6bb8 --- /dev/null +++ b/tests/core/test_agent_loop.py @@ -0,0 +1,284 @@ +import asyncio + +import pytest +from unittest.mock import AsyncMock, MagicMock, patch + +from claude_agent_sdk.types import ( + AssistantMessage, + SystemMessage, + TextBlock, + ToolUseBlock, +) + +from agent_chat_cli.core.agent_loop import AgentLoop +from agent_chat_cli.utils.enums import AgentMessageType, ContentType, ControlCommand +from agent_chat_cli.utils.mcp_server_status import MCPServerStatus + + +@pytest.fixture +def mock_app(): + app = MagicMock() + app.ui_state = MagicMock() + app.actions = MagicMock() + app.actions.handle_agent_message = AsyncMock() + app.actions.post_system_message = MagicMock() + return app + + +@pytest.fixture +def mock_sdk_client(): + with patch("agent_chat_cli.core.agent_loop.ClaudeSDKClient") as mock: + instance = MagicMock() + instance.connect = AsyncMock() + instance.disconnect = AsyncMock() + instance.query = AsyncMock() + instance.receive_response = MagicMock(return_value=AsyncIterator([])) + mock.return_value = instance + yield mock + + +class AsyncIterator: + def __init__(self, items): + self.items = iter(items) + + def __aiter__(self): + return self + + async def __anext__(self): + try: + return next(self.items) + except StopIteration: + raise StopAsyncIteration + + +@pytest.fixture +def mock_config(): + with patch("agent_chat_cli.core.agent_loop.load_config") as load_mock: + load_mock.return_value = MagicMock( + system_prompt="test", + model="test-model", + ) + with patch( + "agent_chat_cli.core.agent_loop.get_available_servers" + ) as servers_mock: + servers_mock.return_value = {} + with patch("agent_chat_cli.core.agent_loop.get_sdk_config") as sdk_mock: + sdk_mock.return_value = {"model": "test-model", "system_prompt": "test"} + yield + + +@pytest.fixture(autouse=True) +def reset_mcp_status(): + MCPServerStatus._mcp_servers = [] + MCPServerStatus._callbacks = [] + yield + MCPServerStatus._mcp_servers = [] + MCPServerStatus._callbacks = [] + + +class TestAgentLoopNewConversation: + async def test_new_conversation_clears_session_id( + self, mock_app, mock_sdk_client, mock_config + ): + agent_loop = AgentLoop(app=mock_app) + agent_loop.session_id = "existing-session-123" + + await agent_loop.query_queue.put(ControlCommand.NEW_CONVERSATION) + + async def run_loop(): + await agent_loop.start() + + loop_task = asyncio.create_task(run_loop()) + + await asyncio.sleep(0.1) + + assert agent_loop.session_id is None + mock_sdk_client.return_value.disconnect.assert_called_once() + + agent_loop._running = False + await agent_loop.query_queue.put("stop") + loop_task.cancel() + try: + await loop_task + except asyncio.CancelledError: + pass + + +class TestHandleMessageSystemMessage: + async def test_stores_session_id_from_init_message(self, mock_app, mock_config): + agent_loop = AgentLoop(app=mock_app) + agent_loop.session_id = None + + message = MagicMock(spec=SystemMessage) + message.subtype = AgentMessageType.INIT.value + message.data = { + "session_id": "new-session-456", + "mcp_servers": [], + } + + await agent_loop._handle_message(message) + + assert agent_loop.session_id == "new-session-456" + + async def test_updates_mcp_server_status_from_init_message( + self, mock_app, mock_config + ): + agent_loop = AgentLoop(app=mock_app) + + message = MagicMock(spec=SystemMessage) + message.subtype = AgentMessageType.INIT.value + message.data = { + "session_id": "session-123", + "mcp_servers": [{"name": "filesystem", "status": "connected"}], + } + + await agent_loop._handle_message(message) + + assert MCPServerStatus.is_connected("filesystem") is True + + +class TestHandleMessageStreamEvent: + async def test_handles_text_delta_stream_event(self, mock_app, mock_config): + agent_loop = AgentLoop(app=mock_app) + + message = MagicMock() + message.event = { + "type": ContentType.CONTENT_BLOCK_DELTA.value, + "delta": { + "type": ContentType.TEXT_DELTA.value, + "text": "Hello world", + }, + } + + await agent_loop._handle_message(message) + + mock_app.actions.handle_agent_message.assert_called_once() + call_arg = mock_app.actions.handle_agent_message.call_args[0][0] + assert call_arg.type == AgentMessageType.STREAM_EVENT + assert call_arg.data == {"text": "Hello world"} + + async def test_ignores_empty_text_delta(self, mock_app, mock_config): + agent_loop = AgentLoop(app=mock_app) + + message = MagicMock() + message.event = { + "type": ContentType.CONTENT_BLOCK_DELTA.value, + "delta": { + "type": ContentType.TEXT_DELTA.value, + "text": "", + }, + } + + await agent_loop._handle_message(message) + + mock_app.actions.handle_agent_message.assert_not_called() + + +class TestHandleMessageAssistantMessage: + async def test_handles_text_block(self, mock_app, mock_config): + agent_loop = AgentLoop(app=mock_app) + + text_block = MagicMock(spec=TextBlock) + text_block.text = "Assistant response" + + message = MagicMock(spec=AssistantMessage) + message.content = [text_block] + + await agent_loop._handle_message(message) + + mock_app.actions.handle_agent_message.assert_called_once() + call_arg = mock_app.actions.handle_agent_message.call_args[0][0] + assert call_arg.type == AgentMessageType.ASSISTANT + assert call_arg.data["content"][0]["type"] == ContentType.TEXT.value + assert call_arg.data["content"][0]["text"] == "Assistant response" + + async def test_handles_tool_use_block(self, mock_app, mock_config): + agent_loop = AgentLoop(app=mock_app) + + tool_block = MagicMock(spec=ToolUseBlock) + tool_block.id = "tool-123" + tool_block.name = "read_file" + tool_block.input = {"path": "/tmp/test.txt"} + + message = MagicMock(spec=AssistantMessage) + message.content = [tool_block] + + await agent_loop._handle_message(message) + + mock_app.actions.handle_agent_message.assert_called_once() + call_arg = mock_app.actions.handle_agent_message.call_args[0][0] + assert call_arg.type == AgentMessageType.ASSISTANT + assert call_arg.data["content"][0]["type"] == ContentType.TOOL_USE.value + assert call_arg.data["content"][0]["name"] == "read_file" + + +class TestCanUseTool: + async def test_allows_tool_on_yes_response(self, mock_app, mock_config): + agent_loop = AgentLoop(app=mock_app) + + await agent_loop.permission_response_queue.put("yes") + + result = await agent_loop._can_use_tool( + tool_name="read_file", + tool_input={"path": "/tmp/test.txt"}, + _context=MagicMock(), + ) + + assert result.behavior == "allow" + + async def test_allows_tool_on_empty_response(self, mock_app, mock_config): + agent_loop = AgentLoop(app=mock_app) + + await agent_loop.permission_response_queue.put("") + + result = await agent_loop._can_use_tool( + tool_name="read_file", + tool_input={"path": "/tmp/test.txt"}, + _context=MagicMock(), + ) + + assert result.behavior == "allow" + + async def test_denies_tool_on_no_response(self, mock_app, mock_config): + agent_loop = AgentLoop(app=mock_app) + + await agent_loop.permission_response_queue.put("no") + + result = await agent_loop._can_use_tool( + tool_name="read_file", + tool_input={"path": "/tmp/test.txt"}, + _context=MagicMock(), + ) + + assert result.behavior == "deny" + mock_app.actions.post_system_message.assert_called_once() + + async def test_denies_tool_on_custom_response(self, mock_app, mock_config): + agent_loop = AgentLoop(app=mock_app) + + await agent_loop.permission_response_queue.put("do something else instead") + + result = await agent_loop._can_use_tool( + tool_name="read_file", + tool_input={"path": "/tmp/test.txt"}, + _context=MagicMock(), + ) + + assert result.behavior == "deny" + assert result.message == "do something else instead" + + async def test_posts_permission_request_message(self, mock_app, mock_config): + agent_loop = AgentLoop(app=mock_app) + + await agent_loop.permission_response_queue.put("yes") + + await agent_loop._can_use_tool( + tool_name="write_file", + tool_input={"path": "/tmp/out.txt", "content": "data"}, + _context=MagicMock(), + ) + + mock_app.actions.handle_agent_message.assert_called_once() + call_arg = mock_app.actions.handle_agent_message.call_args[0][0] + assert call_arg.type == AgentMessageType.TOOL_PERMISSION_REQUEST + assert call_arg.data["tool_name"] == "write_file" diff --git a/tests/utils/test_enums.py b/tests/utils/test_enums.py index 47412a4..85791fc 100644 --- a/tests/utils/test_enums.py +++ b/tests/utils/test_enums.py @@ -1,4 +1,9 @@ -from agent_chat_cli.utils.enums import AgentMessageType, ContentType, ControlCommand +from agent_chat_cli.utils.enums import ( + AgentMessageType, + ContentType, + ControlCommand, + Key, +) class TestAgentMessageType: @@ -27,3 +32,13 @@ def test_all_control_commands_have_values(self): assert ControlCommand.NEW_CONVERSATION.value == "new_conversation" assert ControlCommand.EXIT.value == "exit" assert ControlCommand.CLEAR.value == "clear" + + +class TestKey: + def test_all_keys_have_values(self): + assert Key.ENTER.value == "enter" + assert Key.ESCAPE.value == "escape" + assert Key.BACKSPACE.value == "backspace" + assert Key.DELETE.value == "delete" + assert Key.CTRL_J.value == "ctrl+j" + assert Key.SLASH.value == "/" From 995265fba24d9035f8e52b6c2d9effcd51187edd Mon Sep 17 00:00:00 2001 From: Christopher Pappas Date: Sun, 14 Dec 2025 11:03:34 -0800 Subject: [PATCH 2/3] fix: default log level --- Makefile | 2 +- src/agent_chat_cli/components/user_input.py | 36 ++++++++++++--------- src/agent_chat_cli/utils/logger.py | 5 ++- tests/test_app.py | 11 +++++++ 4 files changed, 37 insertions(+), 17 deletions(-) diff --git a/Makefile b/Makefile index 4d3da68..e209d2e 100644 --- a/Makefile +++ b/Makefile @@ -7,7 +7,7 @@ console: uv run textual console -x SYSTEM -x EVENT -x DEBUG -x INFO dev: - uv run textual run --dev -c chat + LOG_LEVEL=NOTSET uv run textual run --dev -c chat lint: uv run ruff check --fix src diff --git a/src/agent_chat_cli/components/user_input.py b/src/agent_chat_cli/components/user_input.py index 9b45db9..9ec87d2 100644 --- a/src/agent_chat_cli/components/user_input.py +++ b/src/agent_chat_cli/components/user_input.py @@ -14,7 +14,6 @@ class UserInput(Widget): BINDINGS = [ Binding(Key.ENTER.value, "submit", "Submit", priority=True), - Binding(Key.ESCAPE.value, "hide_menu", "Hide Menu", priority=True), ] def __init__(self, actions: Actions) -> None: @@ -54,28 +53,35 @@ def on_text_area_changed(self, event: TextArea.Changed) -> None: async def on_key(self, event) -> None: if event.key == Key.CTRL_J.value: - event.stop() - event.prevent_default() - input_widget = self.query_one(TextArea) - input_widget.insert("\n") + self._insert_newline(event) return menu = self.query_one(SlashCommandMenu) - if menu.is_visible and event.key in (Key.BACKSPACE.value, Key.DELETE.value): - event.stop() - event.prevent_default() - menu.hide() - self.query_one(TextArea).focus() + if menu.is_visible: + self._close_menu(event) + + def _insert_newline(self, event) -> None: + event.stop() + event.prevent_default() + input_widget = self.query_one(TextArea) + 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() - def action_hide_menu(self) -> None: menu = self.query_one(SlashCommandMenu) + menu.hide() - if menu.is_visible: - menu.hide() - input_widget = self.query_one(TextArea) + input_widget = self.query_one(TextArea) + input_widget.focus() + + if event.key == Key.ESCAPE.value: input_widget.clear() - input_widget.focus() async def action_submit(self) -> None: menu = self.query_one(SlashCommandMenu) diff --git a/src/agent_chat_cli/utils/logger.py b/src/agent_chat_cli/utils/logger.py index 2a3acdd..08a870a 100644 --- a/src/agent_chat_cli/utils/logger.py +++ b/src/agent_chat_cli/utils/logger.py @@ -1,13 +1,16 @@ import json import logging +import os from typing import Any from textual.logging import TextualHandler def setup_logging(): + level = os.getenv("LOG_LEVEL", "INFO").upper() + logging.basicConfig( - level="NOTSET", + level=level, handlers=[TextualHandler()], ) diff --git a/tests/test_app.py b/tests/test_app.py index f44c2c1..1399f40 100644 --- a/tests/test_app.py +++ b/tests/test_app.py @@ -150,3 +150,14 @@ async def test_interrupt_blocked_during_permission_prompt( await app.actions.interrupt() assert app.ui_state.interrupting is False + + async def test_escape_triggers_interrupt_when_menu_not_visible( + self, mock_agent_loop, mock_config + ): + app = AgentChatCLIApp() + async with app.run_test() as pilot: + app.ui_state.start_thinking() + + await pilot.press("escape") + + assert app.ui_state.interrupting is True From aa7010cf67eae0c8d311452f101f80ef6c02a222 Mon Sep 17 00:00:00 2001 From: Christopher Pappas Date: Sun, 14 Dec 2025 11:18:48 -0800 Subject: [PATCH 3/3] feat: parallelize ci --- .github/workflows/ci.yml | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f28e726..79ef848 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -7,16 +7,11 @@ on: branches: [main] jobs: - test: + lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: "3.12" - - name: Install uv uses: astral-sh/setup-uv@v4 @@ -26,8 +21,30 @@ jobs: - name: Run linter run: uv run ruff check src tests + typecheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Install dependencies + run: uv sync --all-groups + - name: Run type checker run: uv run mypy src + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Install dependencies + run: uv sync --all-groups + - name: Run tests run: uv run pytest