Skip to content

Commit 75e3e86

Browse files
authored
Merge pull request #2 from damassi/damassi/session-id
feat: Add support for chat resume via session_id
2 parents d34bbd5 + ed587c2 commit 75e3e86

File tree

8 files changed

+217
-13
lines changed

8 files changed

+217
-13
lines changed

Makefile

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
.PHONY: chat dev console
2+
3+
install:
4+
uv sync && uv run pre-commit install
5+
6+
chat:
7+
uv run chat
8+
9+
console:
10+
uv run textual console -x SYSTEM -x EVENT -x DEBUG -x INFO
11+
12+
dev:
13+
uv run textual run --dev -c chat

README.md

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ This tool is for those who wish for slightly more control over their MCP servers
1515
This app uses [uv](https://github.com/astral-sh/uv) for package management so first install that. Then:
1616

1717
- `git clone https://github.com/damassi/agent-chat-cli-python.git`
18-
- `uv sync`
19-
- `uv run chat`
18+
- `make install`
19+
- `make chat`
2020

2121
Additional MCP servers are configured in `agent-chat-cli.config.yaml` and prompts added within the `prompts` folder.
2222

@@ -27,3 +27,19 @@ Additional MCP servers are configured in `agent-chat-cli.config.yaml` and prompt
2727
- Typechecking is via [MyPy](https://github.com/python/mypy):
2828
- `uv run mypy src`
2929
- Linting and formatting is via [Ruff](https://docs.astral.sh/ruff/)
30+
31+
Textual has an integrated logging console which one can boot separately from the app to receive logs.
32+
33+
In one terminal pane boot the console:
34+
35+
```bash
36+
make console
37+
```
38+
39+
> Note: this command intentionally filters out more verbose notifications. See the Makefile to configure.
40+
41+
And then in a second, start the textual dev server:
42+
43+
```bash
44+
make dev
45+
```

agent-chat-cli.config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
system_prompt: system.md
55

66
# Model to use (e.g., sonnet, opus, haiku)
7-
model: sonnet
7+
model: haiku
88

99
# Enable streaming responses
1010
include_partial_messages: true

src/agent_chat_cli/app.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
23
from textual.app import App, ComposeResult
34
from textual.containers import VerticalScroll
45
from textual.binding import Binding
@@ -9,10 +10,12 @@
910
from agent_chat_cli.components.user_input import UserInput
1011
from agent_chat_cli.utils import AgentLoop
1112
from agent_chat_cli.utils.message_bus import MessageBus
13+
from agent_chat_cli.utils.logger import setup_logging
1214

1315
from dotenv import load_dotenv
1416

1517
load_dotenv()
18+
setup_logging()
1619

1720

1821
class AgentChatCLIApp(App):
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
# Architecture
2+
3+
## Overview
4+
5+
Agent Chat CLI is a Python TUI application built with Textual that provides an interactive chat interface for Claude AI with MCP (Model Context Protocol) server support.
6+
7+
## Core Components
8+
9+
### App Layer (`app.py`)
10+
Main Textual application that initializes and coordinates all components.
11+
12+
### Components Layer
13+
Textual widgets responsible for UI rendering:
14+
- **ChatHistory**: Container that displays message widgets
15+
- **Message widgets**: SystemMessage, UserMessage, AgentMessage, ToolMessage
16+
- **UserInput**: Handles user text input and submission
17+
- **ThinkingIndicator**: Shows when agent is processing
18+
19+
### Utils Layer
20+
21+
#### Agent Loop (`agent_loop.py`)
22+
Manages the conversation loop with Claude SDK:
23+
- Maintains async queue for user queries
24+
- Handles streaming responses
25+
- Parses SDK messages into structured AgentMessage objects
26+
- Emits AgentMessageType events (STREAM_EVENT, ASSISTANT, RESULT)
27+
28+
#### Message Bus (`message_bus.py`)
29+
Routes agent messages to appropriate UI components:
30+
- Handles streaming text updates
31+
- Mounts tool use messages
32+
- Controls thinking indicator state
33+
- Manages scroll-to-bottom behavior
34+
35+
#### Config (`config.py`)
36+
Loads and validates YAML configuration:
37+
- Filters disabled MCP servers
38+
- Loads prompts from files
39+
- Expands environment variables
40+
- Combines system prompt with MCP server prompts
41+
42+
## Data Flow
43+
44+
```
45+
User Input
46+
47+
UserInput.on_input_submitted
48+
49+
MessagePosted event → ChatHistory (immediate UI update)
50+
51+
AgentLoop.query (added to queue)
52+
53+
Claude SDK (streaming response)
54+
55+
AgentLoop._handle_message
56+
57+
AgentMessage (typed message) → MessageBus.handle_agent_message
58+
59+
Match on AgentMessageType:
60+
- STREAM_EVENT → Update streaming message widget
61+
- ASSISTANT → Mount tool use widgets
62+
- RESULT → Reset thinking indicator
63+
```
64+
65+
## Key Types
66+
67+
### Enums (`utils/enums.py`)
68+
69+
**AgentMessageType**: Agent communication events
70+
- ASSISTANT: Assistant message with content blocks
71+
- STREAM_EVENT: Streaming text chunk
72+
- RESULT: Response complete
73+
- INIT, SYSTEM: Initialization and system events
74+
75+
**ContentType**: Content block types
76+
- TEXT: Text content
77+
- TOOL_USE: Tool call
78+
- CONTENT_BLOCK_DELTA: SDK streaming event type
79+
- TEXT_DELTA: SDK text delta type
80+
81+
**MessageType** (`components/messages.py`): UI message types
82+
- SYSTEM, USER, AGENT, TOOL
83+
84+
### Data Classes
85+
86+
**AgentMessage** (`utils/agent_loop.py`): Structured message from agent loop
87+
```python
88+
@dataclass
89+
class AgentMessage:
90+
type: AgentMessageType
91+
data: Any
92+
```
93+
94+
**Message** (`components/messages.py`): UI message data
95+
```python
96+
@dataclass
97+
class Message:
98+
type: MessageType
99+
content: str
100+
metadata: dict[str, Any] | None = None
101+
```
102+
103+
## Configuration System
104+
105+
Configuration is loaded from `agent-chat-cli.config.yaml`:
106+
- **system_prompt**: Base system prompt (supports file paths)
107+
- **model**: Claude model to use
108+
- **include_partial_messages**: Enable streaming
109+
- **mcp_servers**: MCP server configurations (filtered by enabled flag)
110+
- **agents**: Named agent configurations
111+
- **disallowed_tools**: Tool filtering
112+
- **permission_mode**: Permission handling mode
113+
114+
MCP server prompts are automatically appended to the system prompt.
115+
116+
## Event Flow
117+
118+
### User Message Flow
119+
1. User submits text → UserInput
120+
2. MessagePosted event → App
121+
3. App → MessageBus.on_message_posted
122+
4. MessageBus → ChatHistory.add_message
123+
5. MessageBus → Scroll to bottom
124+
125+
### Agent Response Flow
126+
1. AgentLoop receives SDK message
127+
2. Parse into AgentMessage with AgentMessageType
128+
3. MessageBus.handle_agent_message (match/case on type)
129+
4. Update UI components based on type
130+
5. Scroll to bottom
131+
132+
## Notes
133+
134+
- Two distinct MessageType enums exist for different purposes (UI vs Agent events)
135+
- Message bus manages stateful streaming (tracks current_agent_message)
136+
- Config loading combines multiple prompts into final system_prompt
137+
- Tool names follow format: `mcp__servername__toolname`

src/agent_chat_cli/utils/agent_loop.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,12 @@
66
ClaudeAgentOptions,
77
ClaudeSDKClient,
88
)
9-
from claude_agent_sdk.types import AssistantMessage, TextBlock, ToolUseBlock
9+
from claude_agent_sdk.types import (
10+
AssistantMessage,
11+
SystemMessage,
12+
TextBlock,
13+
ToolUseBlock,
14+
)
1015

1116
from agent_chat_cli.utils.config import load_config
1217
from agent_chat_cli.utils.enums import AgentMessageType, ContentType
@@ -22,12 +27,16 @@ class AgentLoop:
2227
def __init__(
2328
self,
2429
on_message: Callable[[AgentMessage], Awaitable[None]],
30+
session_id: str | None = None,
2531
) -> None:
2632
self.config = load_config()
33+
self.session_id = session_id
34+
35+
config_dict = self.config.model_dump()
36+
if session_id:
37+
config_dict["resume"] = session_id
2738

28-
self.client = ClaudeSDKClient(
29-
options=ClaudeAgentOptions(**self.config.model_dump())
30-
)
39+
self.client = ClaudeSDKClient(options=ClaudeAgentOptions(**config_dict))
3140

3241
self.on_message = on_message
3342
self.query_queue: asyncio.Queue[str] = asyncio.Queue()
@@ -49,6 +58,12 @@ async def start(self) -> None:
4958
await self.on_message(AgentMessage(type=AgentMessageType.RESULT, data=None))
5059

5160
async def _handle_message(self, message: Any) -> None:
61+
if isinstance(message, SystemMessage):
62+
if message.subtype == AgentMessageType.INIT.value and message.data.get(
63+
"session_id"
64+
):
65+
self.session_id = message.data["session_id"]
66+
5267
if hasattr(message, "event"):
5368
event = message.event # type: ignore[attr-defined]
5469

src/agent_chat_cli/utils/logger.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import json
2+
import logging
3+
from typing import Any
4+
5+
from textual.logging import TextualHandler
6+
7+
8+
def setup_logging():
9+
logging.basicConfig(
10+
level="NOTSET",
11+
handlers=[TextualHandler()],
12+
)
13+
14+
15+
def log(message: str):
16+
logging.info(message)
17+
18+
19+
def log_json(message: Any):
20+
logging.info(json.dumps(message, indent=2))

src/agent_chat_cli/utils/message_bus.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,12 +23,6 @@ def __init__(self, app: "App") -> None:
2323
self.current_agent_message: AgentMessageWidget | None = None
2424
self.current_response_text = ""
2525

26-
async def _scroll_to_bottom(self) -> None:
27-
"""Scroll the container to the bottom after a slight pause."""
28-
await asyncio.sleep(0.1)
29-
container = self.app.query_one("#container")
30-
container.scroll_end(animate=False, immediate=True)
31-
3226
async def handle_agent_message(self, message: AgentMessage) -> None:
3327
match message.type:
3428
case AgentMessageType.STREAM_EVENT:
@@ -38,6 +32,12 @@ async def handle_agent_message(self, message: AgentMessage) -> None:
3832
case AgentMessageType.RESULT:
3933
await self._handle_result()
4034

35+
async def _scroll_to_bottom(self) -> None:
36+
"""Scroll the container to the bottom after a slight pause."""
37+
await asyncio.sleep(0.1)
38+
container = self.app.query_one("#container")
39+
container.scroll_end(animate=False, immediate=True)
40+
4141
async def _handle_stream_event(self, message: AgentMessage) -> None:
4242
text_chunk = message.data.get("text", "")
4343

0 commit comments

Comments
 (0)