From a338dbf73b655b236f693f05ec029659ad78c747 Mon Sep 17 00:00:00 2001 From: Christopher Pappas Date: Sun, 14 Dec 2025 20:38:59 -0800 Subject: [PATCH 1/2] refactor: similify message pipeline --- docs/architecture.md | 2 +- src/agent_chat_cli/app.py | 5 +- src/agent_chat_cli/components/chat_history.py | 7 -- src/agent_chat_cli/core/actions.py | 33 +++++-- src/agent_chat_cli/core/agent_loop.py | 2 +- src/agent_chat_cli/core/message_bus.py | 21 +---- src/agent_chat_cli/docs/architecture.md | 8 +- tests/core/test_actions.py | 90 +++++++++++++++++++ tests/core/test_agent_loop.py | 2 +- 9 files changed, 129 insertions(+), 41 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 31975b8..4411442 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -118,7 +118,7 @@ Modal prompt for tool permission requests: - Manages focus to prevent input elsewhere while visible **ChatHistory** (`components/chat_history.py`) -Container for message widgets, handles `MessagePosted` events. +Container for message widgets. **ThinkingIndicator** (`components/thinking_indicator.py`) Animated indicator shown during agent processing. diff --git a/src/agent_chat_cli/app.py b/src/agent_chat_cli/app.py index d2fddde..1f40226 100644 --- a/src/agent_chat_cli/app.py +++ b/src/agent_chat_cli/app.py @@ -5,7 +5,7 @@ from textual.binding import Binding from agent_chat_cli.components.header import Header -from agent_chat_cli.components.chat_history import ChatHistory, MessagePosted +from agent_chat_cli.components.chat_history import ChatHistory from agent_chat_cli.components.thinking_indicator import ThinkingIndicator from agent_chat_cli.components.tool_permission_prompt import ToolPermissionPrompt from agent_chat_cli.components.user_input import UserInput @@ -49,9 +49,6 @@ def compose(self) -> ComposeResult: 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() diff --git a/src/agent_chat_cli/components/chat_history.py b/src/agent_chat_cli/components/chat_history.py index 357e739..0f0885c 100644 --- a/src/agent_chat_cli/components/chat_history.py +++ b/src/agent_chat_cli/components/chat_history.py @@ -1,6 +1,5 @@ import json from textual.containers import Container -from textual.message import Message as TextualMessage from agent_chat_cli.components.messages import ( AgentMessage, @@ -48,9 +47,3 @@ def _create_message_widget( tool_widget.tool_input = {"raw": message.content} return tool_widget - - -class MessagePosted(TextualMessage): - def __init__(self, message: Message) -> None: - self.message = message - super().__init__() diff --git a/src/agent_chat_cli/core/actions.py b/src/agent_chat_cli/core/actions.py index 27d361c..c122a39 100644 --- a/src/agent_chat_cli/core/actions.py +++ b/src/agent_chat_cli/core/actions.py @@ -1,8 +1,11 @@ +import asyncio from typing import TYPE_CHECKING +from textual.containers import VerticalScroll + from agent_chat_cli.utils.enums import ControlCommand -from agent_chat_cli.components.chat_history import ChatHistory, MessagePosted -from agent_chat_cli.components.messages import Message +from agent_chat_cli.components.chat_history import ChatHistory +from agent_chat_cli.components.messages import Message, MessageType from agent_chat_cli.components.tool_permission_prompt import ToolPermissionPrompt from agent_chat_cli.utils.logger import log_json @@ -17,13 +20,33 @@ def __init__(self, app: "AgentChatCLIApp") -> None: def quit(self) -> None: self.app.exit() + async def add_message_to_chat(self, type: MessageType, content: str) -> None: + match type: + case MessageType.USER: + message = Message.user(content) + case MessageType.SYSTEM: + message = Message.system(content) + case MessageType.AGENT: + message = Message.agent(content) + case _: + raise ValueError(f"Unsupported message type: {type}") + + chat_history = self.app.query_one(ChatHistory) + chat_history.add_message(message) + await self._scroll_to_bottom() + + async def _scroll_to_bottom(self) -> None: + await asyncio.sleep(0.1) + container = self.app.query_one(VerticalScroll) + container.scroll_end(animate=False, immediate=True) + async def submit_user_message(self, message: str) -> None: - self.app.post_message(MessagePosted(Message.user(message))) + await self.add_message_to_chat(MessageType.USER, message) self.app.ui_state.start_thinking() await self._query(message) - def post_system_message(self, message: str) -> None: - self.app.post_message(MessagePosted(Message.system(message))) + async def post_system_message(self, message: str) -> None: + await self.add_message_to_chat(MessageType.SYSTEM, message) async def handle_agent_message(self, message) -> None: await self.app.message_bus.handle_agent_message(message) diff --git a/src/agent_chat_cli/core/agent_loop.py b/src/agent_chat_cli/core/agent_loop.py index fe8cab0..c7a92a3 100644 --- a/src/agent_chat_cli/core/agent_loop.py +++ b/src/agent_chat_cli/core/agent_loop.py @@ -213,7 +213,7 @@ async def _can_use_tool( ) if rejected_tool: - self.app.actions.post_system_message( + await self.app.actions.post_system_message( f"Permission denied for {tool_name}" ) diff --git a/src/agent_chat_cli/core/message_bus.py b/src/agent_chat_cli/core/message_bus.py index 4a6cdec..9ca738c 100644 --- a/src/agent_chat_cli/core/message_bus.py +++ b/src/agent_chat_cli/core/message_bus.py @@ -4,10 +4,10 @@ from textual.widgets import Markdown from textual.containers import VerticalScroll -from agent_chat_cli.components.chat_history import ChatHistory, MessagePosted +from agent_chat_cli.components.chat_history import ChatHistory from agent_chat_cli.components.messages import ( AgentMessage as AgentMessageWidget, - Message, + MessageType, ToolMessage, ) from agent_chat_cli.core.agent_loop import AgentMessage @@ -44,12 +44,6 @@ async def handle_agent_message(self, message: AgentMessage) -> None: case AgentMessageType.RESULT: await self._handle_result() - async def on_message_posted(self, event: MessagePosted) -> None: - chat_history = self.app.query_one(ChatHistory) - chat_history.add_message(event.message) - - await self._scroll_to_bottom() - async def _handle_stream_event(self, message: AgentMessage) -> None: text_chunk = message.data.get("text", "") @@ -103,20 +97,13 @@ async def _handle_system(self, message: AgentMessage) -> None: system_content = ( message.data if isinstance(message.data, str) else str(message.data) ) - - # Dispatch message - self.app.post_message(MessagePosted(Message.system(system_content))) - - await self._scroll_to_bottom() + await self.app.actions.add_message_to_chat(MessageType.SYSTEM, system_content) async def _handle_user(self, message: AgentMessage) -> None: user_content = ( message.data if isinstance(message.data, str) else str(message.data) ) - - self.app.post_message(MessagePosted(Message.user(user_content))) - - await self._scroll_to_bottom() + await self.app.actions.add_message_to_chat(MessageType.USER, user_content) async def _handle_tool_permission_request(self, message: AgentMessage) -> None: log_json( diff --git a/src/agent_chat_cli/docs/architecture.md b/src/agent_chat_cli/docs/architecture.md index 268bfb1..dbdc2da 100644 --- a/src/agent_chat_cli/docs/architecture.md +++ b/src/agent_chat_cli/docs/architecture.md @@ -74,7 +74,7 @@ User Input ↓ UserInput.on_input_submitted ↓ -MessagePosted event → ChatHistory (immediate UI update) +Actions.add_message_to_chat → ChatHistory (immediate UI update) ↓ Actions.query(user_input) → AgentLoop.query_queue.put() ↓ @@ -299,10 +299,8 @@ SDK reconnects to previous session with full history ### User Message Flow 1. User submits text → UserInput -2. MessagePosted event → App -3. App → MessageBus.on_message_posted -4. MessageBus → ChatHistory.add_message -5. MessageBus → Scroll to bottom +2. Actions.add_message_to_chat → ChatHistory.add_message +3. Scroll to bottom ### Agent Response Flow 1. AgentLoop receives SDK message diff --git a/tests/core/test_actions.py b/tests/core/test_actions.py index 209859c..0cb8eb3 100644 --- a/tests/core/test_actions.py +++ b/tests/core/test_actions.py @@ -3,6 +3,12 @@ from agent_chat_cli.app import AgentChatCLIApp from agent_chat_cli.components.chat_history import ChatHistory +from agent_chat_cli.components.messages import ( + MessageType, + SystemMessage, + UserMessage, + AgentMessage, +) from agent_chat_cli.components.tool_permission_prompt import ToolPermissionPrompt from agent_chat_cli.utils.enums import ControlCommand @@ -30,6 +36,90 @@ def mock_config(): yield mock +class TestActionsAddMessageToChat: + async def test_adds_user_message(self, mock_agent_loop, mock_config): + app = AgentChatCLIApp() + async with app.run_test(): + chat_history = app.query_one(ChatHistory) + + await app.actions.add_message_to_chat(MessageType.USER, "Hello") + + widgets = chat_history.query(UserMessage) + assert len(widgets) == 1 + assert widgets.first().message == "Hello" + + async def test_adds_system_message(self, mock_agent_loop, mock_config): + app = AgentChatCLIApp() + async with app.run_test(): + chat_history = app.query_one(ChatHistory) + + await app.actions.add_message_to_chat(MessageType.SYSTEM, "System alert") + + widgets = chat_history.query(SystemMessage) + assert len(widgets) == 1 + assert widgets.first().message == "System alert" + + async def test_adds_agent_message(self, mock_agent_loop, mock_config): + app = AgentChatCLIApp() + async with app.run_test(): + chat_history = app.query_one(ChatHistory) + + await app.actions.add_message_to_chat(MessageType.AGENT, "I can help") + + widgets = chat_history.query(AgentMessage) + assert len(widgets) == 1 + assert widgets.first().message == "I can help" + + async def test_raises_for_unsupported_type(self, mock_agent_loop, mock_config): + app = AgentChatCLIApp() + async with app.run_test(): + with pytest.raises(ValueError, match="Unsupported message type"): + await app.actions.add_message_to_chat(MessageType.TOOL, "tool content") + + +class TestActionsPostSystemMessage: + async def test_adds_system_message_to_chat(self, mock_agent_loop, mock_config): + app = AgentChatCLIApp() + async with app.run_test(): + chat_history = app.query_one(ChatHistory) + + await app.actions.post_system_message("Connection established") + + widgets = chat_history.query(SystemMessage) + assert len(widgets) == 1 + assert widgets.first().message == "Connection established" + + +class TestActionsSubmitUserMessage: + async def test_adds_user_message_to_chat(self, mock_agent_loop, mock_config): + app = AgentChatCLIApp() + async with app.run_test(): + chat_history = app.query_one(ChatHistory) + + await app.actions.submit_user_message("Hello agent") + + widgets = chat_history.query(UserMessage) + assert len(widgets) == 1 + assert widgets.first().message == "Hello agent" + + async def test_starts_thinking_indicator(self, mock_agent_loop, mock_config): + from agent_chat_cli.components.thinking_indicator import ThinkingIndicator + + app = AgentChatCLIApp() + async with app.run_test(): + await app.actions.submit_user_message("Hello agent") + + thinking_indicator = app.query_one(ThinkingIndicator) + assert thinking_indicator.is_thinking is True + + async def test_queues_message_to_agent_loop(self, mock_agent_loop, mock_config): + app = AgentChatCLIApp() + async with app.run_test(): + await app.actions.submit_user_message("Hello agent") + + mock_agent_loop.query_queue.put.assert_called_with("Hello agent") + + class TestActionsInterrupt: async def test_sets_interrupting_flag(self, mock_agent_loop, mock_config): app = AgentChatCLIApp() diff --git a/tests/core/test_agent_loop.py b/tests/core/test_agent_loop.py index a4d6bb8..5335a6f 100644 --- a/tests/core/test_agent_loop.py +++ b/tests/core/test_agent_loop.py @@ -21,7 +21,7 @@ def mock_app(): app.ui_state = MagicMock() app.actions = MagicMock() app.actions.handle_agent_message = AsyncMock() - app.actions.post_system_message = MagicMock() + app.actions.post_system_message = AsyncMock() return app From 24354b04f00136e5b8977d63c4c72bf6e1c7929e Mon Sep 17 00:00:00 2001 From: Christopher Pappas Date: Sun, 14 Dec 2025 21:10:01 -0800 Subject: [PATCH 2/2] chore: more organization --- docs/architecture.md | 10 +-- src/agent_chat_cli/app.py | 4 +- src/agent_chat_cli/core/actions.py | 17 ++-- src/agent_chat_cli/core/agent_loop.py | 8 +- .../core/{message_bus.py => renderer.py} | 88 +++++++++---------- src/agent_chat_cli/core/ui_state.py | 7 ++ src/agent_chat_cli/docs/architecture.md | 10 +-- tests/core/test_agent_loop.py | 20 ++--- .../{test_message_bus.py => test_renderer.py} | 32 +++---- 9 files changed, 96 insertions(+), 100 deletions(-) rename src/agent_chat_cli/core/{message_bus.py => renderer.py} (56%) rename tests/core/{test_message_bus.py => test_renderer.py} (76%) diff --git a/docs/architecture.md b/docs/architecture.md index 4411442..f5351f8 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -10,7 +10,7 @@ src/agent_chat_cli/ ├── core/ │ ├── actions.py # User action handlers │ ├── agent_loop.py # Claude Agent SDK client wrapper -│ ├── message_bus.py # Message routing from agent to UI +│ ├── renderer.py # Message routing from agent to UI │ ├── ui_state.py # Centralized UI state management │ └── styles.tcss # Textual CSS styles ├── components/ @@ -43,7 +43,7 @@ The application follows a loosely coupled architecture with four main orchestrat ┌─────────────────────────────────────────────────────────────┐ │ AgentChatCLIApp │ │ ┌───────────┐ ┌───────────┐ ┌─────────┐ ┌───────────┐ │ -│ │ UIState │ │MessageBus │ │ Actions │ │ AgentLoop │ │ +│ │ UIState │ │ Renderer │ │ Actions │ │ AgentLoop │ │ │ └─────┬─────┘ └─────┬─────┘ └────┬────┘ └─────┬─────┘ │ │ │ │ │ │ │ │ └──────────────┴─────────────┴──────────────┘ │ @@ -63,9 +63,9 @@ Centralized management of UI state behaviors. Handles: - Tool permission prompt display/hide - Interrupt state tracking -This class was introduced in PR #9 to consolidate scattered UI state logic from Actions and MessageBus into a single cohesive module. +This class was introduced in PR #9 to consolidate scattered UI state logic from Actions and Renderer into a single cohesive module. -**MessageBus** (`core/message_bus.py`) +**Renderer** (`core/renderer.py`) Routes messages from the AgentLoop to appropriate UI components: - `STREAM_EVENT`: Streaming text chunks to AgentMessage widgets - `ASSISTANT`: Complete assistant responses with tool use blocks @@ -92,7 +92,7 @@ Manages the Claude Agent SDK client lifecycle: 1. User types in `UserInput` and presses Enter 2. `Actions.submit_user_message()` posts to UI and enqueues to `AgentLoop.query_queue` 3. `AgentLoop` sends query to Claude Agent SDK and streams responses -4. Responses flow through `MessageBus.handle_agent_message()` to update UI +4. Responses flow through `Actions.render_message()` to update UI 5. Tool use triggers permission prompt via `UIState.show_permission_prompt()` 6. User response flows back through `Actions.respond_to_tool_permission()` diff --git a/src/agent_chat_cli/app.py b/src/agent_chat_cli/app.py index 1f40226..f05b7e2 100644 --- a/src/agent_chat_cli/app.py +++ b/src/agent_chat_cli/app.py @@ -10,7 +10,7 @@ from agent_chat_cli.components.tool_permission_prompt import ToolPermissionPrompt from agent_chat_cli.components.user_input import UserInput from agent_chat_cli.core.agent_loop import AgentLoop -from agent_chat_cli.core.message_bus import MessageBus +from agent_chat_cli.core.renderer import Renderer from agent_chat_cli.core.actions import Actions from agent_chat_cli.core.ui_state import UIState from agent_chat_cli.utils.logger import setup_logging @@ -34,7 +34,7 @@ def __init__(self) -> None: super().__init__() self.ui_state = UIState(app=self) - self.message_bus = MessageBus(app=self) + self.renderer = Renderer(app=self) self.actions = Actions(app=self) self.agent_loop = AgentLoop(app=self) diff --git a/src/agent_chat_cli/core/actions.py b/src/agent_chat_cli/core/actions.py index c122a39..1fa0e6a 100644 --- a/src/agent_chat_cli/core/actions.py +++ b/src/agent_chat_cli/core/actions.py @@ -1,8 +1,5 @@ -import asyncio from typing import TYPE_CHECKING -from textual.containers import VerticalScroll - from agent_chat_cli.utils.enums import ControlCommand from agent_chat_cli.components.chat_history import ChatHistory from agent_chat_cli.components.messages import Message, MessageType @@ -33,23 +30,19 @@ async def add_message_to_chat(self, type: MessageType, content: str) -> None: chat_history = self.app.query_one(ChatHistory) chat_history.add_message(message) - await self._scroll_to_bottom() - - async def _scroll_to_bottom(self) -> None: - await asyncio.sleep(0.1) - container = self.app.query_one(VerticalScroll) - container.scroll_end(animate=False, immediate=True) async def submit_user_message(self, message: str) -> None: - await self.add_message_to_chat(MessageType.USER, message) + chat_history = self.app.query_one(ChatHistory) + chat_history.add_message(Message.user(message)) self.app.ui_state.start_thinking() + await self.app.ui_state.scroll_to_bottom() await self._query(message) async def post_system_message(self, message: str) -> None: await self.add_message_to_chat(MessageType.SYSTEM, message) - async def handle_agent_message(self, message) -> None: - await self.app.message_bus.handle_agent_message(message) + async def render_message(self, message) -> None: + await self.app.renderer.render_message(message) async def interrupt(self) -> None: permission_prompt = self.app.query_one(ToolPermissionPrompt) diff --git a/src/agent_chat_cli/core/agent_loop.py b/src/agent_chat_cli/core/agent_loop.py index c7a92a3..c3945fb 100644 --- a/src/agent_chat_cli/core/agent_loop.py +++ b/src/agent_chat_cli/core/agent_loop.py @@ -92,7 +92,7 @@ async def start(self) -> None: await self._handle_message(message) - await self.app.actions.handle_agent_message( + await self.app.actions.render_message( AgentMessage(type=AgentMessageType.RESULT, data=None) ) @@ -135,7 +135,7 @@ async def _handle_message(self, message: Message) -> None: text_chunk = delta.get("text", "") if text_chunk: - await self.app.actions.handle_agent_message( + await self.app.actions.render_message( AgentMessage( type=AgentMessageType.STREAM_EVENT, data={"text": text_chunk}, @@ -163,7 +163,7 @@ async def _handle_message(self, message: Message) -> None: ) # Finally, post the agent assistant response - await self.app.actions.handle_agent_message( + await self.app.actions.render_message( AgentMessage( type=AgentMessageType.ASSISTANT, data={"content": content}, @@ -180,7 +180,7 @@ async def _can_use_tool( # Handle permission request queue sequentially async with self.permission_lock: - await self.app.actions.handle_agent_message( + await self.app.actions.render_message( AgentMessage( type=AgentMessageType.TOOL_PERMISSION_REQUEST, data={ diff --git a/src/agent_chat_cli/core/message_bus.py b/src/agent_chat_cli/core/renderer.py similarity index 56% rename from src/agent_chat_cli/core/message_bus.py rename to src/agent_chat_cli/core/renderer.py index 9ca738c..3ac65e3 100644 --- a/src/agent_chat_cli/core/message_bus.py +++ b/src/agent_chat_cli/core/renderer.py @@ -1,8 +1,7 @@ -import asyncio +from dataclasses import dataclass from typing import TYPE_CHECKING from textual.widgets import Markdown -from textual.containers import VerticalScroll from agent_chat_cli.components.chat_history import ChatHistory from agent_chat_cli.components.messages import ( @@ -18,33 +17,45 @@ from agent_chat_cli.app import AgentChatCLIApp -class MessageBus: +@dataclass +class StreamBuffer: + widget: AgentMessageWidget | None = None + text: str = "" + + def reset(self) -> None: + self.widget = None + self.text = "" + + +class Renderer: def __init__(self, app: "AgentChatCLIApp") -> None: self.app = app - self.current_agent_message: AgentMessageWidget | None = None - self.current_response_text = "" + self._stream = StreamBuffer() - async def handle_agent_message(self, message: AgentMessage) -> None: + async def render_message(self, message: AgentMessage) -> None: match message.type: case AgentMessageType.STREAM_EVENT: - await self._handle_stream_event(message) + await self._render_stream_event(message) case AgentMessageType.ASSISTANT: - await self._handle_assistant(message) + await self._render_assistant_message(message) case AgentMessageType.SYSTEM: - await self._handle_system(message) + await self._render_system_message(message) case AgentMessageType.USER: - await self._handle_user(message) + await self._render_user_message(message) case AgentMessageType.TOOL_PERMISSION_REQUEST: - await self._handle_tool_permission_request(message) + await self._render_tool_permission_request(message) case AgentMessageType.RESULT: - await self._handle_result() + await self._on_complete() - async def _handle_stream_event(self, message: AgentMessage) -> None: + if message.type is not AgentMessageType.RESULT: + await self.app.ui_state.scroll_to_bottom() + + async def _render_stream_event(self, message: AgentMessage) -> None: text_chunk = message.data.get("text", "") if not text_chunk: @@ -52,24 +63,21 @@ async def _handle_stream_event(self, message: AgentMessage) -> None: chat_history = self.app.query_one(ChatHistory) - if self.current_agent_message is None: - self.current_response_text = text_chunk + if self._stream.widget is None: + self._stream.text = text_chunk agent_msg = AgentMessageWidget() agent_msg.message = text_chunk - # Append to chat history - chat_history.mount(agent_msg) - self.current_agent_message = agent_msg + await chat_history.mount(agent_msg) + self._stream.widget = agent_msg else: - self.current_response_text += text_chunk - - markdown = self.current_agent_message.query_one(Markdown) - markdown.update(self.current_response_text) + self._stream.text += text_chunk - await self._scroll_to_bottom() + markdown = self._stream.widget.query_one(Markdown) + markdown.update(self._stream.text) - async def _handle_assistant(self, message: AgentMessage) -> None: + async def _render_assistant_message(self, message: AgentMessage) -> None: content_blocks = message.data.get("content", []) chat_history = self.app.query_one(ChatHistory) @@ -77,9 +85,8 @@ async def _handle_assistant(self, message: AgentMessage) -> None: block_type = block.get("type") if block_type == ContentType.TOOL_USE.value: - if self.current_agent_message is not None: - self.current_agent_message = None - self.current_response_text = "" + if self._stream.widget is not None: + self._stream.reset() tool_name = block.get("name", "unknown") tool_input = block.get("input", {}) @@ -88,24 +95,23 @@ async def _handle_assistant(self, message: AgentMessage) -> None: tool_msg.tool_name = tool_name tool_msg.tool_input = tool_input - # Append to chat history - chat_history.mount(tool_msg) - - await self._scroll_to_bottom() + await chat_history.mount(tool_msg) - async def _handle_system(self, message: AgentMessage) -> None: + async def _render_system_message(self, message: AgentMessage) -> None: system_content = ( message.data if isinstance(message.data, str) else str(message.data) ) + await self.app.actions.add_message_to_chat(MessageType.SYSTEM, system_content) - async def _handle_user(self, message: AgentMessage) -> None: + async def _render_user_message(self, message: AgentMessage) -> None: user_content = ( message.data if isinstance(message.data, str) else str(message.data) ) + await self.app.actions.add_message_to_chat(MessageType.USER, user_content) - async def _handle_tool_permission_request(self, message: AgentMessage) -> None: + async def _render_tool_permission_request(self, message: AgentMessage) -> None: log_json( { "event": "showing_permission_prompt", @@ -118,19 +124,9 @@ async def _handle_tool_permission_request(self, message: AgentMessage) -> None: tool_input=message.data.get("tool_input", {}), ) - await self._scroll_to_bottom() - - async def _handle_result(self) -> None: + async def _on_complete(self) -> None: if not self.app.agent_loop.query_queue.empty(): return self.app.ui_state.stop_thinking() - - self.current_agent_message = None - self.current_response_text = "" - - async def _scroll_to_bottom(self) -> None: - await asyncio.sleep(0.1) - - container = self.app.query_one(VerticalScroll) - container.scroll_end(animate=False, immediate=True) + self._stream.reset() diff --git a/src/agent_chat_cli/core/ui_state.py b/src/agent_chat_cli/core/ui_state.py index 2679836..61b9a33 100644 --- a/src/agent_chat_cli/core/ui_state.py +++ b/src/agent_chat_cli/core/ui_state.py @@ -1,5 +1,7 @@ +import asyncio from typing import TYPE_CHECKING, Any +from textual.containers import VerticalScroll from textual.widgets import TextArea from agent_chat_cli.components.thinking_indicator import ThinkingIndicator @@ -69,3 +71,8 @@ def clear_input(self) -> None: user_input = self.app.query_one(UserInput) input_widget = user_input.query_one(TextArea) input_widget.clear() + + async def scroll_to_bottom(self) -> None: + await asyncio.sleep(0.1) + container = self.app.query_one(VerticalScroll) + container.scroll_end(animate=False, immediate=True) diff --git a/src/agent_chat_cli/docs/architecture.md b/src/agent_chat_cli/docs/architecture.md index dbdc2da..52702fb 100644 --- a/src/agent_chat_cli/docs/architecture.md +++ b/src/agent_chat_cli/docs/architecture.md @@ -30,7 +30,7 @@ Manages the conversation loop with Claude SDK: - Uses `permission_lock` (asyncio.Lock) to serialize parallel permission requests - Manages `permission_response_queue` for user responses to tool permission prompts -#### Message Bus (`system/message_bus.py`) +#### Renderer (`core/renderer.py`) Routes agent messages to appropriate UI components: - Handles streaming text updates - Mounts tool use messages @@ -82,7 +82,7 @@ Claude SDK (all enabled servers pre-connected at startup) ↓ AgentLoop._handle_message ↓ -AgentMessage (typed message) → MessageBus.handle_agent_message +AgentMessage (typed message) → Actions.render_message ↓ Match on AgentMessageType: - STREAM_EVENT → Update streaming message widget @@ -182,7 +182,7 @@ AgentLoop._can_use_tool (callback with permission_lock acquired) ↓ Emit SYSTEM AgentMessage with tool_permission_request data ↓ -MessageBus._handle_system detects permission request +Renderer._handle_tool_permission_request shows permission prompt ↓ Show ToolPermissionPrompt, hide UserInput ↓ @@ -305,14 +305,14 @@ SDK reconnects to previous session with full history ### Agent Response Flow 1. AgentLoop receives SDK message 2. Parse into AgentMessage with AgentMessageType -3. MessageBus.handle_agent_message (match/case on type) +3. Actions.render_message → Renderer (match/case on type) 4. Update UI components based on type 5. Scroll to bottom ## Notes - Two distinct MessageType enums exist for different purposes (UI vs Agent events) -- Message bus manages stateful streaming (tracks current_agent_message) +- Renderer manages stateful streaming via StreamBuffer - 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 diff --git a/tests/core/test_agent_loop.py b/tests/core/test_agent_loop.py index 5335a6f..e061e32 100644 --- a/tests/core/test_agent_loop.py +++ b/tests/core/test_agent_loop.py @@ -20,7 +20,7 @@ def mock_app(): app = MagicMock() app.ui_state = MagicMock() app.actions = MagicMock() - app.actions.handle_agent_message = AsyncMock() + app.actions.render_message = AsyncMock() app.actions.post_system_message = AsyncMock() return app @@ -152,8 +152,8 @@ async def test_handles_text_delta_stream_event(self, mock_app, mock_config): 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] + mock_app.actions.render_message.assert_called_once() + call_arg = mock_app.actions.render_message.call_args[0][0] assert call_arg.type == AgentMessageType.STREAM_EVENT assert call_arg.data == {"text": "Hello world"} @@ -171,7 +171,7 @@ async def test_ignores_empty_text_delta(self, mock_app, mock_config): await agent_loop._handle_message(message) - mock_app.actions.handle_agent_message.assert_not_called() + mock_app.actions.render_message.assert_not_called() class TestHandleMessageAssistantMessage: @@ -186,8 +186,8 @@ async def test_handles_text_block(self, mock_app, mock_config): 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] + mock_app.actions.render_message.assert_called_once() + call_arg = mock_app.actions.render_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" @@ -205,8 +205,8 @@ async def test_handles_tool_use_block(self, mock_app, mock_config): 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] + mock_app.actions.render_message.assert_called_once() + call_arg = mock_app.actions.render_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" @@ -278,7 +278,7 @@ async def test_posts_permission_request_message(self, mock_app, mock_config): _context=MagicMock(), ) - mock_app.actions.handle_agent_message.assert_called_once() - call_arg = mock_app.actions.handle_agent_message.call_args[0][0] + mock_app.actions.render_message.assert_called_once() + call_arg = mock_app.actions.render_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/core/test_message_bus.py b/tests/core/test_renderer.py similarity index 76% rename from tests/core/test_message_bus.py rename to tests/core/test_renderer.py index e8448d2..71556bd 100644 --- a/tests/core/test_message_bus.py +++ b/tests/core/test_renderer.py @@ -24,7 +24,7 @@ def mock_config(): yield mock -class TestMessageBusHandleAgentMessage: +class TestRendererRenderMessage: async def test_handles_stream_event(self, mock_agent_loop, mock_config): app = AgentChatCLIApp() async with app.run_test(): @@ -33,23 +33,23 @@ async def test_handles_stream_event(self, mock_agent_loop, mock_config): data={"text": "Hello"}, ) - await app.message_bus.handle_agent_message(message) + await app.renderer.render_message(message) - assert app.message_bus.current_response_text == "Hello" + assert app.renderer._stream.text == "Hello" async def test_accumulates_stream_chunks(self, mock_agent_loop, mock_config): app = AgentChatCLIApp() async with app.run_test(): - await app.message_bus.handle_agent_message( + await app.renderer.render_message( AgentMessage( type=AgentMessageType.STREAM_EVENT, data={"text": "Hello "} ) ) - await app.message_bus.handle_agent_message( + await app.renderer.render_message( AgentMessage(type=AgentMessageType.STREAM_EVENT, data={"text": "world"}) ) - assert app.message_bus.current_response_text == "Hello world" + assert app.renderer._stream.text == "Hello world" async def test_handles_tool_permission_request(self, mock_agent_loop, mock_config): app = AgentChatCLIApp() @@ -59,7 +59,7 @@ async def test_handles_tool_permission_request(self, mock_agent_loop, mock_confi data={"tool_name": "read_file", "tool_input": {"path": "/tmp"}}, ) - await app.message_bus.handle_agent_message(message) + await app.renderer.render_message(message) from agent_chat_cli.components.tool_permission_prompt import ( ToolPermissionPrompt, @@ -72,16 +72,16 @@ async def test_handles_result_resets_state(self, mock_agent_loop, mock_config): app = AgentChatCLIApp() async with app.run_test(): app.ui_state.start_thinking() - await app.message_bus.handle_agent_message( + await app.renderer.render_message( AgentMessage(type=AgentMessageType.STREAM_EVENT, data={"text": "test"}) ) - await app.message_bus.handle_agent_message( + await app.renderer.render_message( AgentMessage(type=AgentMessageType.RESULT, data=None) ) - assert app.message_bus.current_agent_message is None - assert app.message_bus.current_response_text == "" + assert app.renderer._stream.widget is None + assert app.renderer._stream.text == "" async def test_handles_assistant_with_tool_use(self, mock_agent_loop, mock_config): app = AgentChatCLIApp() @@ -99,16 +99,16 @@ async def test_handles_assistant_with_tool_use(self, mock_agent_loop, mock_confi }, ) - await app.message_bus.handle_agent_message(message) + await app.renderer.render_message(message) - assert app.message_bus.current_agent_message is None + assert app.renderer._stream.widget is None async def test_ignores_empty_stream_chunks(self, mock_agent_loop, mock_config): app = AgentChatCLIApp() async with app.run_test(): - await app.message_bus.handle_agent_message( + await app.renderer.render_message( AgentMessage(type=AgentMessageType.STREAM_EVENT, data={"text": ""}) ) - assert app.message_bus.current_response_text == "" - assert app.message_bus.current_agent_message is None + assert app.renderer._stream.text == "" + assert app.renderer._stream.widget is None