Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
},
Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 15 additions & 2 deletions src/agent_chat_cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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__()
Expand All @@ -32,19 +37,27 @@ 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())

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()
Expand Down
18 changes: 14 additions & 4 deletions src/agent_chat_cli/components/user_input.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import asyncio
from typing import Callable, Awaitable

from textual.widget import Widget
from textual.app import ComposeResult
Expand All @@ -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():
Expand All @@ -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)))

Expand All @@ -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)
76 changes: 75 additions & 1 deletion src/agent_chat_cli/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand All @@ -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)
Expand All @@ -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`)
Expand All @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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
32 changes: 32 additions & 0 deletions src/agent_chat_cli/utils/actions.py
Original file line number Diff line number Diff line change
@@ -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
23 changes: 14 additions & 9 deletions src/agent_chat_cli/utils/agent_loop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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))
Expand Down Expand Up @@ -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()
6 changes: 6 additions & 0 deletions src/agent_chat_cli/utils/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
2 changes: 2 additions & 0 deletions src/agent_chat_cli/utils/message_bus.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down