From 4afef6fe453912ef3136a20e8f8c5806b3592863 Mon Sep 17 00:00:00 2001 From: Christopher Pappas Date: Sun, 30 Nov 2025 11:42:02 -0800 Subject: [PATCH 1/4] docs: add architecture.md --- src/agent_chat_cli/docs/architecture.md | 159 ++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 src/agent_chat_cli/docs/architecture.md diff --git a/src/agent_chat_cli/docs/architecture.md b/src/agent_chat_cli/docs/architecture.md new file mode 100644 index 0000000..2067736 --- /dev/null +++ b/src/agent_chat_cli/docs/architecture.md @@ -0,0 +1,159 @@ +# Architecture + +## Overview + +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. + +## Directory Structure + +``` +src/agent_chat_cli/ +├── app.py # Main application entry point +├── components/ # UI components (Textual widgets) +│ ├── chat_history.py # Container for chat messages +│ ├── messages.py # Message widgets and UI Message type +│ ├── user_input.py # User input component +│ ├── thinking_indicator.py # Loading indicator +│ └── header.py # App header with config info +├── utils/ # Business logic and utilities +│ ├── agent_loop.py # Claude SDK conversation loop +│ ├── message_bus.py # Routes messages between agent and UI +│ ├── config.py # Configuration loading and validation +│ ├── enums.py # All enum types +│ ├── system_prompt.py # System prompt assembly +│ ├── format_tool_input.py # Tool input formatting +│ └── tool_info.py # MCP tool name parsing +└── utils/styles.tcss # Textual CSS styles +``` + +## Core Components + +### App Layer (`app.py`) +Main Textual application that initializes and coordinates all components. + +### Components Layer +Textual widgets responsible for UI rendering: +- **ChatHistory**: Container that displays message widgets +- **Message widgets**: SystemMessage, UserMessage, AgentMessage, ToolMessage +- **UserInput**: Handles user text input and submission +- **ThinkingIndicator**: Shows when agent is processing + +### Utils Layer + +#### Agent Loop (`agent_loop.py`) +Manages the conversation loop with Claude SDK: +- Maintains async queue for user queries +- Handles streaming responses +- Parses SDK messages into structured AgentMessage objects +- Emits AgentMessageType events (STREAM_EVENT, ASSISTANT, RESULT) + +#### Message Bus (`message_bus.py`) +Routes agent messages to appropriate UI components: +- Handles streaming text updates +- Mounts tool use messages +- Controls thinking indicator state +- Manages scroll-to-bottom behavior + +#### Config (`config.py`) +Loads and validates YAML configuration: +- Filters disabled MCP servers +- Loads prompts from files +- Expands environment variables +- Combines system prompt with MCP server prompts + +## Data Flow + +``` +User Input + ↓ +UserInput.on_input_submitted + ↓ +MessagePosted event → ChatHistory (immediate UI update) + ↓ +AgentLoop.query (added to queue) + ↓ +Claude SDK (streaming response) + ↓ +AgentLoop._handle_message + ↓ +AgentMessage (typed message) → MessageBus.handle_agent_message + ↓ +Match on AgentMessageType: + - STREAM_EVENT → Update streaming message widget + - ASSISTANT → Mount tool use widgets + - RESULT → Reset thinking indicator +``` + +## Key Types + +### Enums (`utils/enums.py`) + +**AgentMessageType**: Agent communication events +- ASSISTANT: Assistant message with content blocks +- STREAM_EVENT: Streaming text chunk +- RESULT: Response complete +- INIT, SYSTEM: Initialization and system events + +**ContentType**: Content block types +- TEXT: Text content +- TOOL_USE: Tool call +- CONTENT_BLOCK_DELTA: SDK streaming event type +- TEXT_DELTA: SDK text delta type + +**MessageType** (`components/messages.py`): UI message types +- SYSTEM, USER, AGENT, TOOL + +### Data Classes + +**AgentMessage** (`utils/agent_loop.py`): Structured message from agent loop +```python +@dataclass +class AgentMessage: + type: AgentMessageType + data: Any +``` + +**Message** (`components/messages.py`): UI message data +```python +@dataclass +class Message: + type: MessageType + content: str + metadata: dict[str, Any] | None = None +``` + +## Configuration System + +Configuration is loaded from `agent-chat-cli.config.yaml`: +- **system_prompt**: Base system prompt (supports file paths) +- **model**: Claude model to use +- **include_partial_messages**: Enable streaming +- **mcp_servers**: MCP server configurations (filtered by enabled flag) +- **agents**: Named agent configurations +- **disallowed_tools**: Tool filtering +- **permission_mode**: Permission handling mode + +MCP server prompts are automatically appended to the system prompt. + +## Event Flow + +### 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 + +### Agent Response Flow +1. AgentLoop receives SDK message +2. Parse into AgentMessage with AgentMessageType +3. MessageBus.handle_agent_message (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) +- Config loading combines multiple prompts into final system_prompt +- Tool names follow format: `mcp__servername__toolname` From 322e43869e1f812237591025c393b4698497b63d Mon Sep 17 00:00:00 2001 From: Christopher Pappas Date: Sun, 30 Nov 2025 11:52:51 -0800 Subject: [PATCH 2/4] feat: add session_id support --- src/agent_chat_cli/utils/agent_loop.py | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/agent_chat_cli/utils/agent_loop.py b/src/agent_chat_cli/utils/agent_loop.py index 1ec5288..58dcb0a 100644 --- a/src/agent_chat_cli/utils/agent_loop.py +++ b/src/agent_chat_cli/utils/agent_loop.py @@ -6,7 +6,12 @@ ClaudeAgentOptions, ClaudeSDKClient, ) -from claude_agent_sdk.types import AssistantMessage, TextBlock, ToolUseBlock +from claude_agent_sdk.types import ( + AssistantMessage, + SystemMessage, + TextBlock, + ToolUseBlock, +) from agent_chat_cli.utils.config import load_config from agent_chat_cli.utils.enums import AgentMessageType, ContentType @@ -22,12 +27,16 @@ class AgentLoop: def __init__( self, on_message: Callable[[AgentMessage], Awaitable[None]], + session_id: str | None = None, ) -> None: self.config = load_config() + self.session_id = session_id + + config_dict = self.config.model_dump() + if session_id: + config_dict["resume"] = session_id - self.client = ClaudeSDKClient( - options=ClaudeAgentOptions(**self.config.model_dump()) - ) + self.client = ClaudeSDKClient(options=ClaudeAgentOptions(**config_dict)) self.on_message = on_message self.query_queue: asyncio.Queue[str] = asyncio.Queue() @@ -49,6 +58,12 @@ async def start(self) -> None: await self.on_message(AgentMessage(type=AgentMessageType.RESULT, data=None)) async def _handle_message(self, message: Any) -> None: + if isinstance(message, SystemMessage): + if message.subtype == AgentMessageType.INIT.value and message.data.get( + "session_id" + ): + self.session_id = message.data["session_id"] + if hasattr(message, "event"): event = message.event # type: ignore[attr-defined] From 0f3dd950e564771bee12188de77aa1d1e5a1096e Mon Sep 17 00:00:00 2001 From: Christopher Pappas Date: Sun, 30 Nov 2025 12:16:06 -0800 Subject: [PATCH 3/4] feat: add makefile --- Makefile | 10 ++++++++++ README.md | 4 ++-- src/agent_chat_cli/app.py | 9 +++++++++ 3 files changed, 21 insertions(+), 2 deletions(-) create mode 100644 Makefile diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4bf0b9f --- /dev/null +++ b/Makefile @@ -0,0 +1,10 @@ +.PHONY: chat logs + +install: + uv sync && uv run pre-commit install + +chat: + uv run chat + +logs: + uv run textual console -x SYSTEM -x EVENT -x DEBUG -x INFO diff --git a/README.md b/README.md index 1318899..ebc245b 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,8 @@ This tool is for those who wish for slightly more control over their MCP servers This app uses [uv](https://github.com/astral-sh/uv) for package management so first install that. Then: - `git clone https://github.com/damassi/agent-chat-cli-python.git` -- `uv sync` -- `uv run chat` +- `make install` +- `make chat` Additional MCP servers are configured in `agent-chat-cli.config.yaml` and prompts added within the `prompts` folder. diff --git a/src/agent_chat_cli/app.py b/src/agent_chat_cli/app.py index 30e1c3a..6e52c03 100644 --- a/src/agent_chat_cli/app.py +++ b/src/agent_chat_cli/app.py @@ -1,7 +1,10 @@ +import logging import asyncio + from textual.app import App, ComposeResult from textual.containers import VerticalScroll from textual.binding import Binding +from textual.logging import TextualHandler from agent_chat_cli.components.header import Header from agent_chat_cli.components.chat_history import ChatHistory, MessagePosted @@ -14,6 +17,11 @@ load_dotenv() +logging.basicConfig( + level="NOTSET", + handlers=[TextualHandler()], +) + class AgentChatCLIApp(App): CSS_PATH = "utils/styles.tcss" @@ -37,6 +45,7 @@ def compose(self) -> ComposeResult: yield UserInput(query=self.agent_loop.query) async def on_mount(self) -> None: + logging.debug("Starting agent loop...") asyncio.create_task(self.agent_loop.start()) async def on_message_posted(self, event: MessagePosted) -> None: From ed587c27cdb5549df90d2522b1a4298c08654e33 Mon Sep 17 00:00:00 2001 From: Christopher Pappas Date: Sun, 30 Nov 2025 12:42:19 -0800 Subject: [PATCH 4/4] feat: add logger --- Makefile | 7 +++++-- README.md | 16 ++++++++++++++++ agent-chat-cli.config.yaml | 2 +- src/agent_chat_cli/app.py | 10 ++-------- src/agent_chat_cli/docs/architecture.md | 22 ---------------------- src/agent_chat_cli/utils/logger.py | 20 ++++++++++++++++++++ src/agent_chat_cli/utils/message_bus.py | 12 ++++++------ 7 files changed, 50 insertions(+), 39 deletions(-) create mode 100644 src/agent_chat_cli/utils/logger.py diff --git a/Makefile b/Makefile index 4bf0b9f..f0dc37b 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: chat logs +.PHONY: chat dev console install: uv sync && uv run pre-commit install @@ -6,5 +6,8 @@ install: chat: uv run chat -logs: +console: uv run textual console -x SYSTEM -x EVENT -x DEBUG -x INFO + +dev: + uv run textual run --dev -c chat diff --git a/README.md b/README.md index ebc245b..f9ba6d4 100644 --- a/README.md +++ b/README.md @@ -27,3 +27,19 @@ Additional MCP servers are configured in `agent-chat-cli.config.yaml` and prompt - Typechecking is via [MyPy](https://github.com/python/mypy): - `uv run mypy src` - Linting and formatting is via [Ruff](https://docs.astral.sh/ruff/) + +Textual has an integrated logging console which one can boot separately from the app to receive logs. + +In one terminal pane boot the console: + +```bash +make console +``` + +> Note: this command intentionally filters out more verbose notifications. See the Makefile to configure. + +And then in a second, start the textual dev server: + +```bash +make dev +``` diff --git a/agent-chat-cli.config.yaml b/agent-chat-cli.config.yaml index 58f3f2c..2072ccd 100644 --- a/agent-chat-cli.config.yaml +++ b/agent-chat-cli.config.yaml @@ -4,7 +4,7 @@ system_prompt: system.md # Model to use (e.g., sonnet, opus, haiku) -model: sonnet +model: haiku # Enable streaming responses include_partial_messages: true diff --git a/src/agent_chat_cli/app.py b/src/agent_chat_cli/app.py index 6e52c03..4f04613 100644 --- a/src/agent_chat_cli/app.py +++ b/src/agent_chat_cli/app.py @@ -1,10 +1,8 @@ -import logging import asyncio from textual.app import App, ComposeResult from textual.containers import VerticalScroll from textual.binding import Binding -from textual.logging import TextualHandler from agent_chat_cli.components.header import Header from agent_chat_cli.components.chat_history import ChatHistory, MessagePosted @@ -12,15 +10,12 @@ from agent_chat_cli.components.user_input import UserInput 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 dotenv import load_dotenv load_dotenv() - -logging.basicConfig( - level="NOTSET", - handlers=[TextualHandler()], -) +setup_logging() class AgentChatCLIApp(App): @@ -45,7 +40,6 @@ def compose(self) -> ComposeResult: yield UserInput(query=self.agent_loop.query) async def on_mount(self) -> None: - logging.debug("Starting agent loop...") asyncio.create_task(self.agent_loop.start()) async def on_message_posted(self, event: MessagePosted) -> None: diff --git a/src/agent_chat_cli/docs/architecture.md b/src/agent_chat_cli/docs/architecture.md index 2067736..31f89d9 100644 --- a/src/agent_chat_cli/docs/architecture.md +++ b/src/agent_chat_cli/docs/architecture.md @@ -4,28 +4,6 @@ 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. -## Directory Structure - -``` -src/agent_chat_cli/ -├── app.py # Main application entry point -├── components/ # UI components (Textual widgets) -│ ├── chat_history.py # Container for chat messages -│ ├── messages.py # Message widgets and UI Message type -│ ├── user_input.py # User input component -│ ├── thinking_indicator.py # Loading indicator -│ └── header.py # App header with config info -├── utils/ # Business logic and utilities -│ ├── agent_loop.py # Claude SDK conversation loop -│ ├── message_bus.py # Routes messages between agent and UI -│ ├── config.py # Configuration loading and validation -│ ├── enums.py # All enum types -│ ├── system_prompt.py # System prompt assembly -│ ├── format_tool_input.py # Tool input formatting -│ └── tool_info.py # MCP tool name parsing -└── utils/styles.tcss # Textual CSS styles -``` - ## Core Components ### App Layer (`app.py`) diff --git a/src/agent_chat_cli/utils/logger.py b/src/agent_chat_cli/utils/logger.py new file mode 100644 index 0000000..2a3acdd --- /dev/null +++ b/src/agent_chat_cli/utils/logger.py @@ -0,0 +1,20 @@ +import json +import logging +from typing import Any + +from textual.logging import TextualHandler + + +def setup_logging(): + logging.basicConfig( + level="NOTSET", + handlers=[TextualHandler()], + ) + + +def log(message: str): + logging.info(message) + + +def log_json(message: Any): + logging.info(json.dumps(message, indent=2)) diff --git a/src/agent_chat_cli/utils/message_bus.py b/src/agent_chat_cli/utils/message_bus.py index 4641193..49f6da9 100644 --- a/src/agent_chat_cli/utils/message_bus.py +++ b/src/agent_chat_cli/utils/message_bus.py @@ -23,12 +23,6 @@ def __init__(self, app: "App") -> None: self.current_agent_message: AgentMessageWidget | None = None self.current_response_text = "" - async def _scroll_to_bottom(self) -> None: - """Scroll the container to the bottom after a slight pause.""" - await asyncio.sleep(0.1) - container = self.app.query_one("#container") - container.scroll_end(animate=False, immediate=True) - async def handle_agent_message(self, message: AgentMessage) -> None: match message.type: case AgentMessageType.STREAM_EVENT: @@ -38,6 +32,12 @@ async def handle_agent_message(self, message: AgentMessage) -> None: case AgentMessageType.RESULT: await self._handle_result() + async def _scroll_to_bottom(self) -> None: + """Scroll the container to the bottom after a slight pause.""" + await asyncio.sleep(0.1) + container = self.app.query_one("#container") + container.scroll_end(animate=False, immediate=True) + async def _handle_stream_event(self, message: AgentMessage) -> None: text_chunk = message.data.get("text", "")