Skip to content

Commit f24a467

Browse files
committed
refactor: restructure and cleanup
1 parent cbc0941 commit f24a467

File tree

9 files changed

+168
-17
lines changed

9 files changed

+168
-17
lines changed

.vscode/settings.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,15 @@
2626
"ruff.lint.run": "always"
2727
}
2828
},
29-
"[json,yaml,markdown]": {
29+
"[json]": {
30+
"editor.defaultFormatter": "esbenp.prettier-vscode",
31+
"editor.formatOnSave": true
32+
},
33+
"[markdown]": {
34+
"editor.defaultFormatter": "esbenp.prettier-vscode",
35+
"editor.formatOnSave": true
36+
},
37+
"[yaml]": {
3038
"editor.defaultFormatter": "esbenp.prettier-vscode",
3139
"editor.formatOnSave": true
3240
},

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## Rules
44

5+
- Read `docs/architecture.md` for an architectural overview of the project. Refactors should always start here first.
56
- The project uses `uv`, `ruff` and `mypy`
67
- Run commands should be prefixed with `uv`: `uv run ...`
78
- Use `asyncio` features, if such is needed

src/agent_chat_cli/app.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from agent_chat_cli.utils import AgentLoop
1212
from agent_chat_cli.utils.message_bus import MessageBus
1313
from agent_chat_cli.utils.logger import setup_logging
14+
from agent_chat_cli.utils.actions import Actions
1415

1516
from dotenv import load_dotenv
1617

@@ -21,7 +22,11 @@
2122
class AgentChatCLIApp(App):
2223
CSS_PATH = "utils/styles.tcss"
2324

24-
BINDINGS = [Binding("ctrl+c", "quit", "Quit", show=False, priority=True)]
25+
BINDINGS = [
26+
Binding("ctrl+c", "quit", "Quit", show=False, priority=True),
27+
Binding("escape", "interrupt", "Interrupt", show=True),
28+
Binding("ctrl+n", "new", "New", show=True),
29+
]
2530

2631
def __init__(self) -> None:
2732
super().__init__()
@@ -32,19 +37,27 @@ def __init__(self) -> None:
3237
on_message=self.message_bus.handle_agent_message,
3338
)
3439

40+
self.actions = Actions(self)
41+
3542
def compose(self) -> ComposeResult:
3643
with VerticalScroll():
3744
yield Header()
3845
yield ChatHistory()
3946
yield ThinkingIndicator()
40-
yield UserInput(query=self.agent_loop.query)
47+
yield UserInput(actions=self.actions)
4148

4249
async def on_mount(self) -> None:
4350
asyncio.create_task(self.agent_loop.start())
4451

4552
async def on_message_posted(self, event: MessagePosted) -> None:
4653
await self.message_bus.on_message_posted(event)
4754

55+
async def action_interrupt(self) -> None:
56+
await self.actions.interrupt()
57+
58+
async def action_new(self) -> None:
59+
await self.actions.new()
60+
4861

4962
def main():
5063
app = AgentChatCLIApp()

src/agent_chat_cli/components/user_input.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import asyncio
2-
from typing import Callable, Awaitable
32

43
from textual.widget import Widget
54
from textual.app import ComposeResult
@@ -10,14 +9,16 @@
109
from agent_chat_cli.components.chat_history import MessagePosted
1110
from agent_chat_cli.components.thinking_indicator import ThinkingIndicator
1211
from agent_chat_cli.components.messages import Message
12+
from agent_chat_cli.utils.actions import Actions
13+
from agent_chat_cli.utils.enums import ControlCommand
1314

1415

1516
class UserInput(Widget):
1617
first_boot = True
1718

18-
def __init__(self, query: Callable[[str], Awaitable[None]]) -> None:
19+
def __init__(self, actions: Actions) -> None:
1920
super().__init__()
20-
self.agent_query = query
21+
self.actions = actions
2122

2223
def compose(self) -> ComposeResult:
2324
with Flex():
@@ -36,9 +37,18 @@ async def on_input_submitted(self, event: Input.Submitted) -> None:
3637
if not user_message:
3738
return
3839

40+
if user_message.lower() == ControlCommand.EXIT.value:
41+
self.actions.quit()
42+
return
43+
3944
input_widget = self.query_one(Input)
4045
input_widget.value = ""
4146

47+
if user_message.lower() == ControlCommand.CLEAR.value:
48+
await self.actions.interrupt()
49+
await self.actions.new()
50+
return
51+
4252
# Post to chat history
4353
self.post_message(MessagePosted(Message.user(user_message)))
4454

@@ -52,4 +62,4 @@ async def query_agent(self, user_input: str) -> None:
5262
input_widget = self.query_one(Input)
5363
input_widget.cursor_blink = False
5464

55-
await self.agent_query(user_input)
65+
await self.actions.query(user_input)

src/agent_chat_cli/docs/architecture.md

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ Manages the conversation loop with Claude SDK:
2424
- Handles streaming responses
2525
- Parses SDK messages into structured AgentMessage objects
2626
- Emits AgentMessageType events (STREAM_EVENT, ASSISTANT, RESULT)
27+
- Manages session persistence via session_id
2728

2829
#### Message Bus (`message_bus.py`)
2930
Routes agent messages to appropriate UI components:
@@ -32,6 +33,19 @@ Routes agent messages to appropriate UI components:
3233
- Controls thinking indicator state
3334
- Manages scroll-to-bottom behavior
3435

36+
#### Actions (`actions.py`)
37+
Centralizes all user-initiated actions and controls:
38+
- **quit()**: Exits the application
39+
- **query(user_input)**: Sends user query to agent loop queue
40+
- **interrupt()**: Stops streaming mid-execution by setting interrupt flag and calling SDK interrupt
41+
- **new()**: Starts new conversation by sending NEW_CONVERSATION control command
42+
- Manages UI state (thinking indicator, chat history clearing)
43+
- Directly accesses agent_loop internals (query_queue, client, interrupting flag)
44+
45+
Actions are triggered via:
46+
- Keybindings in app.py (ESC → action_interrupt, Ctrl+N → action_new)
47+
- Text commands in user_input.py ("exit", "clear")
48+
3549
#### Config (`config.py`)
3650
Loads and validates YAML configuration:
3751
- Filters disabled MCP servers
@@ -48,7 +62,7 @@ UserInput.on_input_submitted
4862
4963
MessagePosted event → ChatHistory (immediate UI update)
5064
51-
AgentLoop.query (added to queue)
65+
Actions.query(user_input) → AgentLoop.query_queue.put()
5266
5367
Claude SDK (streaming response)
5468
@@ -62,6 +76,20 @@ Match on AgentMessageType:
6276
- RESULT → Reset thinking indicator
6377
```
6478

79+
### Control Commands Flow
80+
```
81+
User Action (ESC, Ctrl+N, "clear", "exit")
82+
83+
App.action_* (keybinding) OR UserInput (text command)
84+
85+
Actions.interrupt() OR Actions.new() OR Actions.quit()
86+
87+
AgentLoop internals:
88+
- interrupt: Set interrupting flag + SDK interrupt
89+
- new: Put ControlCommand.NEW_CONVERSATION on queue
90+
- quit: App.exit()
91+
```
92+
6593
## Key Types
6694

6795
### Enums (`utils/enums.py`)
@@ -78,6 +106,11 @@ Match on AgentMessageType:
78106
- CONTENT_BLOCK_DELTA: SDK streaming event type
79107
- TEXT_DELTA: SDK text delta type
80108

109+
**ControlCommand**: Control commands for agent loop
110+
- NEW_CONVERSATION: Disconnect and reconnect SDK to start fresh session
111+
- EXIT: User command to quit application
112+
- CLEAR: User command to start new conversation
113+
81114
**MessageType** (`components/messages.py`): UI message types
82115
- SYSTEM, USER, AGENT, TOOL
83116

@@ -113,6 +146,43 @@ Configuration is loaded from `agent-chat-cli.config.yaml`:
113146

114147
MCP server prompts are automatically appended to the system prompt.
115148

149+
## User Commands
150+
151+
### Text Commands
152+
- **exit**: Quits the application
153+
- **clear**: Starts a new conversation (clears history and reconnects)
154+
155+
### Keybindings
156+
- **Ctrl+C**: Quit application
157+
- **ESC**: Interrupt streaming response
158+
- **Ctrl+N**: Start new conversation
159+
160+
## Session Management
161+
162+
The agent loop supports session persistence and resumption via `session_id`:
163+
164+
### Initialization
165+
- `AgentLoop.__init__` accepts an optional `session_id` parameter
166+
- If provided, the session_id is passed to Claude SDK via the `resume` config option
167+
- This allows resuming a previous conversation with full context
168+
169+
### Session Capture
170+
- During SDK initialization, a SystemMessage with subtype "init" is received
171+
- The message contains a `session_id` in its data payload
172+
- AgentLoop extracts and stores this session_id: `agent_loop.py:65`
173+
- The session_id can be persisted and used to resume the session later
174+
175+
### Resume Flow
176+
```
177+
AgentLoop(session_id="abc123")
178+
179+
config_dict["resume"] = session_id
180+
181+
ClaudeSDKClient initialized with resume option
182+
183+
SDK reconnects to previous session with full history
184+
```
185+
116186
## Event Flow
117187

118188
### User Message Flow
@@ -135,3 +205,7 @@ MCP server prompts are automatically appended to the system prompt.
135205
- Message bus manages stateful streaming (tracks current_agent_message)
136206
- Config loading combines multiple prompts into final system_prompt
137207
- Tool names follow format: `mcp__servername__toolname`
208+
- Actions class provides single interface for all user-initiated operations
209+
- Control commands are queued alongside user queries to ensure proper task ordering
210+
- Agent loop processes both strings (user queries) and ControlCommands from the same queue
211+
- Interrupt flag is checked on each streaming message to enable immediate stop
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from agent_chat_cli.utils.agent_loop import AgentLoop
2+
from agent_chat_cli.utils.enums import ControlCommand
3+
from agent_chat_cli.components.chat_history import ChatHistory
4+
from agent_chat_cli.components.thinking_indicator import ThinkingIndicator
5+
6+
7+
class Actions:
8+
def __init__(self, app) -> None:
9+
self.app = app
10+
self.agent_loop: AgentLoop = app.agent_loop
11+
12+
def quit(self) -> None:
13+
self.app.exit()
14+
15+
async def query(self, user_input: str) -> None:
16+
await self.agent_loop.query_queue.put(user_input)
17+
18+
async def interrupt(self) -> None:
19+
self.agent_loop.interrupting = True
20+
await self.agent_loop.client.interrupt()
21+
22+
thinking_indicator = self.app.query_one(ThinkingIndicator)
23+
thinking_indicator.is_thinking = False
24+
25+
async def new(self) -> None:
26+
await self.agent_loop.query_queue.put(ControlCommand.NEW_CONVERSATION)
27+
28+
chat_history = self.app.query_one(ChatHistory)
29+
await chat_history.remove_children()
30+
31+
thinking_indicator = self.app.query_one(ThinkingIndicator)
32+
thinking_indicator.is_thinking = False

src/agent_chat_cli/utils/agent_loop.py

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
)
1515

1616
from agent_chat_cli.utils.config import load_config
17-
from agent_chat_cli.utils.enums import AgentMessageType, ContentType
17+
from agent_chat_cli.utils.enums import AgentMessageType, ContentType, ControlCommand
1818

1919

2020
@dataclass
@@ -39,9 +39,10 @@ def __init__(
3939
self.client = ClaudeSDKClient(options=ClaudeAgentOptions(**config_dict))
4040

4141
self.on_message = on_message
42-
self.query_queue: asyncio.Queue[str] = asyncio.Queue()
42+
self.query_queue: asyncio.Queue[str | ControlCommand] = asyncio.Queue()
4343

4444
self._running = False
45+
self.interrupting = False
4546

4647
async def start(self) -> None:
4748
await self.client.connect()
@@ -50,9 +51,20 @@ async def start(self) -> None:
5051

5152
while self._running:
5253
user_input = await self.query_queue.get()
54+
55+
if isinstance(user_input, ControlCommand):
56+
if user_input == ControlCommand.NEW_CONVERSATION:
57+
await self.client.disconnect()
58+
await self.client.connect()
59+
continue
60+
61+
self.interrupting = False
5362
await self.client.query(user_input)
5463

5564
async for message in self.client.receive_response():
65+
if self.interrupting:
66+
continue
67+
5668
await self._handle_message(message)
5769

5870
await self.on_message(AgentMessage(type=AgentMessageType.RESULT, data=None))
@@ -105,10 +117,3 @@ async def _handle_message(self, message: Any) -> None:
105117
data={"content": content},
106118
)
107119
)
108-
109-
async def query(self, user_input: str) -> None:
110-
await self.query_queue.put(user_input)
111-
112-
async def stop(self) -> None:
113-
self._running = False
114-
await self.client.disconnect()

src/agent_chat_cli/utils/enums.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,9 @@ class ContentType(Enum):
1414
TOOL_USE = "tool_use"
1515
CONTENT_BLOCK_DELTA = "content_block_delta"
1616
TEXT_DELTA = "text_delta"
17+
18+
19+
class ControlCommand(Enum):
20+
NEW_CONVERSATION = "new_conversation"
21+
EXIT = "exit"
22+
CLEAR = "clear"

src/agent_chat_cli/utils/message_bus.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,10 @@ async def handle_agent_message(self, message: AgentMessage) -> None:
2828
match message.type:
2929
case AgentMessageType.STREAM_EVENT:
3030
await self._handle_stream_event(message)
31+
3132
case AgentMessageType.ASSISTANT:
3233
await self._handle_assistant(message)
34+
3335
case AgentMessageType.RESULT:
3436
await self._handle_result()
3537

0 commit comments

Comments
 (0)