diff --git a/.vscode/settings.json b/.vscode/settings.json index d54bafe..659b424 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -26,7 +26,15 @@ "ruff.lint.run": "always" } }, - "[json,yaml,markdown]": { + "[json]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true + }, + "[markdown]": { + "editor.defaultFormatter": "esbenp.prettier-vscode", + "editor.formatOnSave": true + }, + "[yaml]": { "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.formatOnSave": true }, diff --git a/CLAUDE.md b/CLAUDE.md index bcf48d2..39e0ffb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,7 @@ ## Rules +- Read `docs/architecture.md` for an architectural overview of the project. Refactors should always start here first. - The project uses `uv`, `ruff` and `mypy` - Run commands should be prefixed with `uv`: `uv run ...` - Use `asyncio` features, if such is needed diff --git a/src/agent_chat_cli/app.py b/src/agent_chat_cli/app.py index 99eeacb..aafefb5 100644 --- a/src/agent_chat_cli/app.py +++ b/src/agent_chat_cli/app.py @@ -11,6 +11,7 @@ from agent_chat_cli.utils import AgentLoop from agent_chat_cli.utils.message_bus import MessageBus from agent_chat_cli.utils.logger import setup_logging +from agent_chat_cli.utils.actions import Actions from dotenv import load_dotenv @@ -21,7 +22,11 @@ class AgentChatCLIApp(App): CSS_PATH = "utils/styles.tcss" - BINDINGS = [Binding("ctrl+c", "quit", "Quit", show=False, priority=True)] + BINDINGS = [ + Binding("ctrl+c", "quit", "Quit", show=False, priority=True), + Binding("escape", "interrupt", "Interrupt", show=True), + Binding("ctrl+n", "new", "New", show=True), + ] def __init__(self) -> None: super().__init__() @@ -32,12 +37,14 @@ def __init__(self) -> None: on_message=self.message_bus.handle_agent_message, ) + self.actions = Actions(self) + def compose(self) -> ComposeResult: with VerticalScroll(): yield Header() yield ChatHistory() yield ThinkingIndicator() - yield UserInput(query=self.agent_loop.query) + yield UserInput(actions=self.actions) async def on_mount(self) -> None: asyncio.create_task(self.agent_loop.start()) @@ -45,6 +52,12 @@ async def on_mount(self) -> None: async def on_message_posted(self, event: MessagePosted) -> None: await self.message_bus.on_message_posted(event) + async def action_interrupt(self) -> None: + await self.actions.interrupt() + + async def action_new(self) -> None: + await self.actions.new() + def main(): app = AgentChatCLIApp() diff --git a/src/agent_chat_cli/components/user_input.py b/src/agent_chat_cli/components/user_input.py index 6c25c99..49deb15 100644 --- a/src/agent_chat_cli/components/user_input.py +++ b/src/agent_chat_cli/components/user_input.py @@ -1,5 +1,4 @@ import asyncio -from typing import Callable, Awaitable from textual.widget import Widget from textual.app import ComposeResult @@ -10,14 +9,16 @@ from agent_chat_cli.components.chat_history import MessagePosted from agent_chat_cli.components.thinking_indicator import ThinkingIndicator from agent_chat_cli.components.messages import Message +from agent_chat_cli.utils.actions import Actions +from agent_chat_cli.utils.enums import ControlCommand class UserInput(Widget): first_boot = True - def __init__(self, query: Callable[[str], Awaitable[None]]) -> None: + def __init__(self, actions: Actions) -> None: super().__init__() - self.agent_query = query + self.actions = actions def compose(self) -> ComposeResult: with Flex(): @@ -36,9 +37,18 @@ async def on_input_submitted(self, event: Input.Submitted) -> None: if not user_message: return + if user_message.lower() == ControlCommand.EXIT.value: + self.actions.quit() + return + input_widget = self.query_one(Input) input_widget.value = "" + if user_message.lower() == ControlCommand.CLEAR.value: + await self.actions.interrupt() + await self.actions.new() + return + # Post to chat history self.post_message(MessagePosted(Message.user(user_message))) @@ -52,4 +62,4 @@ async def query_agent(self, user_input: str) -> None: input_widget = self.query_one(Input) input_widget.cursor_blink = False - await self.agent_query(user_input) + await self.actions.query(user_input) diff --git a/src/agent_chat_cli/docs/architecture.md b/src/agent_chat_cli/docs/architecture.md index 31f89d9..60bc234 100644 --- a/src/agent_chat_cli/docs/architecture.md +++ b/src/agent_chat_cli/docs/architecture.md @@ -24,6 +24,7 @@ Manages the conversation loop with Claude SDK: - Handles streaming responses - Parses SDK messages into structured AgentMessage objects - Emits AgentMessageType events (STREAM_EVENT, ASSISTANT, RESULT) +- Manages session persistence via session_id #### Message Bus (`message_bus.py`) Routes agent messages to appropriate UI components: @@ -32,6 +33,19 @@ Routes agent messages to appropriate UI components: - Controls thinking indicator state - Manages scroll-to-bottom behavior +#### Actions (`actions.py`) +Centralizes all user-initiated actions and controls: +- **quit()**: Exits the application +- **query(user_input)**: Sends user query to agent loop queue +- **interrupt()**: Stops streaming mid-execution by setting interrupt flag and calling SDK interrupt +- **new()**: Starts new conversation by sending NEW_CONVERSATION control command +- Manages UI state (thinking indicator, chat history clearing) +- Directly accesses agent_loop internals (query_queue, client, interrupting flag) + +Actions are triggered via: +- Keybindings in app.py (ESC → action_interrupt, Ctrl+N → action_new) +- Text commands in user_input.py ("exit", "clear") + #### Config (`config.py`) Loads and validates YAML configuration: - Filters disabled MCP servers @@ -48,7 +62,7 @@ UserInput.on_input_submitted ↓ MessagePosted event → ChatHistory (immediate UI update) ↓ -AgentLoop.query (added to queue) +Actions.query(user_input) → AgentLoop.query_queue.put() ↓ Claude SDK (streaming response) ↓ @@ -62,6 +76,20 @@ Match on AgentMessageType: - RESULT → Reset thinking indicator ``` +### Control Commands Flow +``` +User Action (ESC, Ctrl+N, "clear", "exit") + ↓ +App.action_* (keybinding) OR UserInput (text command) + ↓ +Actions.interrupt() OR Actions.new() OR Actions.quit() + ↓ +AgentLoop internals: + - interrupt: Set interrupting flag + SDK interrupt + - new: Put ControlCommand.NEW_CONVERSATION on queue + - quit: App.exit() +``` + ## Key Types ### Enums (`utils/enums.py`) @@ -78,6 +106,11 @@ Match on AgentMessageType: - CONTENT_BLOCK_DELTA: SDK streaming event type - TEXT_DELTA: SDK text delta type +**ControlCommand**: Control commands for agent loop +- NEW_CONVERSATION: Disconnect and reconnect SDK to start fresh session +- EXIT: User command to quit application +- CLEAR: User command to start new conversation + **MessageType** (`components/messages.py`): UI message types - SYSTEM, USER, AGENT, TOOL @@ -113,6 +146,43 @@ Configuration is loaded from `agent-chat-cli.config.yaml`: MCP server prompts are automatically appended to the system prompt. +## User Commands + +### Text Commands +- **exit**: Quits the application +- **clear**: Starts a new conversation (clears history and reconnects) + +### Keybindings +- **Ctrl+C**: Quit application +- **ESC**: Interrupt streaming response +- **Ctrl+N**: Start new conversation + +## Session Management + +The agent loop supports session persistence and resumption via `session_id`: + +### Initialization +- `AgentLoop.__init__` accepts an optional `session_id` parameter +- If provided, the session_id is passed to Claude SDK via the `resume` config option +- This allows resuming a previous conversation with full context + +### Session Capture +- During SDK initialization, a SystemMessage with subtype "init" is received +- The message contains a `session_id` in its data payload +- AgentLoop extracts and stores this session_id: `agent_loop.py:65` +- The session_id can be persisted and used to resume the session later + +### Resume Flow +``` +AgentLoop(session_id="abc123") + ↓ +config_dict["resume"] = session_id + ↓ +ClaudeSDKClient initialized with resume option + ↓ +SDK reconnects to previous session with full history +``` + ## Event Flow ### User Message Flow @@ -135,3 +205,7 @@ MCP server prompts are automatically appended to the system prompt. - Message bus manages stateful streaming (tracks current_agent_message) - Config loading combines multiple prompts into final system_prompt - Tool names follow format: `mcp__servername__toolname` +- Actions class provides single interface for all user-initiated operations +- Control commands are queued alongside user queries to ensure proper task ordering +- Agent loop processes both strings (user queries) and ControlCommands from the same queue +- Interrupt flag is checked on each streaming message to enable immediate stop diff --git a/src/agent_chat_cli/utils/actions.py b/src/agent_chat_cli/utils/actions.py new file mode 100644 index 0000000..322b8c2 --- /dev/null +++ b/src/agent_chat_cli/utils/actions.py @@ -0,0 +1,32 @@ +from agent_chat_cli.utils.agent_loop import AgentLoop +from agent_chat_cli.utils.enums import ControlCommand +from agent_chat_cli.components.chat_history import ChatHistory +from agent_chat_cli.components.thinking_indicator import ThinkingIndicator + + +class Actions: + def __init__(self, app) -> None: + self.app = app + self.agent_loop: AgentLoop = app.agent_loop + + def quit(self) -> None: + self.app.exit() + + async def query(self, user_input: str) -> None: + await self.agent_loop.query_queue.put(user_input) + + async def interrupt(self) -> None: + self.agent_loop.interrupting = True + await self.agent_loop.client.interrupt() + + thinking_indicator = self.app.query_one(ThinkingIndicator) + thinking_indicator.is_thinking = False + + async def new(self) -> None: + await self.agent_loop.query_queue.put(ControlCommand.NEW_CONVERSATION) + + chat_history = self.app.query_one(ChatHistory) + await chat_history.remove_children() + + thinking_indicator = self.app.query_one(ThinkingIndicator) + thinking_indicator.is_thinking = False diff --git a/src/agent_chat_cli/utils/agent_loop.py b/src/agent_chat_cli/utils/agent_loop.py index 58dcb0a..e4e947e 100644 --- a/src/agent_chat_cli/utils/agent_loop.py +++ b/src/agent_chat_cli/utils/agent_loop.py @@ -14,7 +14,7 @@ ) from agent_chat_cli.utils.config import load_config -from agent_chat_cli.utils.enums import AgentMessageType, ContentType +from agent_chat_cli.utils.enums import AgentMessageType, ContentType, ControlCommand @dataclass @@ -39,9 +39,10 @@ def __init__( self.client = ClaudeSDKClient(options=ClaudeAgentOptions(**config_dict)) self.on_message = on_message - self.query_queue: asyncio.Queue[str] = asyncio.Queue() + self.query_queue: asyncio.Queue[str | ControlCommand] = asyncio.Queue() self._running = False + self.interrupting = False async def start(self) -> None: await self.client.connect() @@ -50,9 +51,20 @@ async def start(self) -> None: while self._running: user_input = await self.query_queue.get() + + if isinstance(user_input, ControlCommand): + if user_input == ControlCommand.NEW_CONVERSATION: + await self.client.disconnect() + await self.client.connect() + continue + + self.interrupting = False await self.client.query(user_input) async for message in self.client.receive_response(): + if self.interrupting: + continue + await self._handle_message(message) await self.on_message(AgentMessage(type=AgentMessageType.RESULT, data=None)) @@ -105,10 +117,3 @@ async def _handle_message(self, message: Any) -> None: data={"content": content}, ) ) - - async def query(self, user_input: str) -> None: - await self.query_queue.put(user_input) - - async def stop(self) -> None: - self._running = False - await self.client.disconnect() diff --git a/src/agent_chat_cli/utils/enums.py b/src/agent_chat_cli/utils/enums.py index 9a285bc..89e4959 100644 --- a/src/agent_chat_cli/utils/enums.py +++ b/src/agent_chat_cli/utils/enums.py @@ -14,3 +14,9 @@ class ContentType(Enum): TOOL_USE = "tool_use" CONTENT_BLOCK_DELTA = "content_block_delta" TEXT_DELTA = "text_delta" + + +class ControlCommand(Enum): + NEW_CONVERSATION = "new_conversation" + EXIT = "exit" + CLEAR = "clear" diff --git a/src/agent_chat_cli/utils/message_bus.py b/src/agent_chat_cli/utils/message_bus.py index d32b168..3e059b0 100644 --- a/src/agent_chat_cli/utils/message_bus.py +++ b/src/agent_chat_cli/utils/message_bus.py @@ -28,8 +28,10 @@ async def handle_agent_message(self, message: AgentMessage) -> None: match message.type: case AgentMessageType.STREAM_EVENT: await self._handle_stream_event(message) + case AgentMessageType.ASSISTANT: await self._handle_assistant(message) + case AgentMessageType.RESULT: await self._handle_result()