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
17 changes: 13 additions & 4 deletions .github/workflows/type-check.yml → .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
name: Type Check
name: CI

on:
push:
Expand All @@ -7,7 +7,7 @@ on:
branches: [main]

jobs:
type-check:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
Expand All @@ -20,5 +20,14 @@ jobs:
- name: Install uv
uses: astral-sh/setup-uv@v4

- name: Run mypy
run: uv run mypy src/agent_chat_cli
- name: Install dependencies
run: uv sync --all-groups

- name: Run linter
run: uv run ruff check src tests

- name: Run type checker
run: uv run mypy src

- name: Run tests
run: uv run pytest
5 changes: 4 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
.PHONY: dev console lint install start type-check
.PHONY: dev console lint install start test type-check

install:
uv sync && uv run pre-commit install && cp .env.example .env && echo "Please edit the .env file with your API keys."
Expand All @@ -15,5 +15,8 @@ lint:
start:
uv run chat

test:
uv run pytest

type-check:
uv run mypy src
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,12 @@ Additional MCP servers are configured in `agent-chat-cli.config.yaml` and prompt
- `make type-check`
- Linting and formatting is via [Ruff](https://docs.astral.sh/ruff/)
- `make lint`
- Testing is via [pytest](https://docs.pytest.org/):
- `make test`

See [docs/architecture.md](docs/architecture.md) for an overview of the codebase structure.

### Textual Dev Console

Textual has an integrated logging console that one can boot separately from the app to receive logs.

Expand Down
163 changes: 163 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
# Architecture

Agent Chat CLI is a terminal-based chat interface for interacting with Claude agents, built with [Textual](https://textual.textualize.io/) and the [Claude Agent SDK](https://github.com/anthropics/claude-agent-sdk).

### Directory Structure

```
src/agent_chat_cli/
├── app.py # Main Textual application entry point
├── core/
│ ├── actions.py # User action handlers
│ ├── agent_loop.py # Claude Agent SDK client wrapper
│ ├── message_bus.py # Message routing from agent to UI
│ ├── ui_state.py # Centralized UI state management
│ └── styles.tcss # Textual CSS styles
├── components/
│ ├── balloon_spinner.py # Animated spinner widget
│ ├── caret.py # Input caret indicator
│ ├── chat_history.py # Chat message container
│ ├── flex.py # Horizontal flex container
│ ├── header.py # App header with MCP server status
│ ├── messages.py # Message data models and widgets
│ ├── spacer.py # Empty spacer widget
│ ├── thinking_indicator.py # "Agent is thinking" indicator
│ ├── tool_permission_prompt.py # Tool permission request UI
│ └── user_input.py # User text input widget
└── utils/
├── config.py # YAML config loading
├── enums.py # Shared enumerations
├── format_tool_input.py # Tool input formatting
├── logger.py # Logging setup
├── mcp_server_status.py # MCP server connection state
├── system_prompt.py # System prompt builder
└── tool_info.py # Tool name parsing
```

### Core Architecture

The application follows a loosely coupled architecture with four main orchestration objects:

```
┌─────────────────────────────────────────────────────────────┐
│ AgentChatCLIApp │
│ ┌───────────┐ ┌───────────┐ ┌─────────┐ ┌───────────┐ │
│ │ UIState │ │MessageBus │ │ Actions │ │ AgentLoop │ │
│ └─────┬─────┘ └─────┬─────┘ └────┬────┘ └─────┬─────┘ │
│ │ │ │ │ │
│ └──────────────┴─────────────┴──────────────┘ │
│ │ │
│ ┌─────────────────────────┴─────────────────────────────┐ │
│ │ Components │ │
│ │ Header │ ChatHistory │ ThinkingIndicator │ UserInput │ │
│ └────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```

### Core Modules

**UIState** (`core/ui_state.py`)
Centralized management of UI state behaviors. Handles:
- Thinking indicator visibility and cursor blink state
- 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.

**MessageBus** (`core/message_bus.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
- `SYSTEM` / `USER`: System and user messages
- `TOOL_PERMISSION_REQUEST`: Triggers permission prompt UI
- `RESULT`: Signals completion, resets state

**Actions** (`core/actions.py`)
User-initiated action handlers:
- `submit_user_message()`: Posts user message and queries agent
- `interrupt()`: Cancels current agent operation
- `new()`: Starts new conversation, clears history
- `respond_to_tool_permission()`: Handles permission prompt responses

**AgentLoop** (`core/agent_loop.py`)
Manages the Claude Agent SDK client lifecycle:
- Initializes `ClaudeSDKClient` with config and MCP servers
- Processes incoming messages via async generator
- Handles tool permission flow via `_can_use_tool()` callback
- Manages `query_queue` and `permission_response_queue` for async communication

### Message Flow

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
5. Tool use triggers permission prompt via `UIState.show_permission_prompt()`
6. User response flows back through `Actions.respond_to_tool_permission()`

### Components

**UserInput** (`components/user_input.py`)
Text input with:
- Enter to submit
- Ctrl+J for newlines (PR #10)
- Control commands: `exit`, `clear`

**ToolPermissionPrompt** (`components/tool_permission_prompt.py`)
Modal prompt for tool permission requests:
- Shows tool name and MCP server
- Enter to allow, ESC to deny, or type custom response
- Manages focus to prevent input elsewhere while visible

**ChatHistory** (`components/chat_history.py`)
Container for message widgets, handles `MessagePosted` events.

**ThinkingIndicator** (`components/thinking_indicator.py`)
Animated indicator shown during agent processing.

**Header** (`components/header.py`)
Displays available MCP servers with connection status via `MCPServerStatus` subscription.

### Configuration

Configuration is loaded from `agent-chat-cli.config.yaml`:

```yaml
system_prompt: "prompt.md" # File path or literal string
model: "claude-sonnet-4-20250514"
permission_mode: "bypass_permissions"

mcp_servers:
server_name:
description: "Server description"
command: "npx"
args: ["-y", "@some/mcp-server"]
env:
API_KEY: "$API_KEY"
enabled: true
prompt: "server_prompt.md"

agents:
agent_name:
description: "Agent description"
prompt: "agent_prompt.md"
tools: ["tool1", "tool2"]
```

### Key Patterns

**Reactive Properties**: Textual's `reactive` and `var` are used for automatic UI updates when state changes (e.g., `ThinkingIndicator.is_thinking`, `ToolPermissionPrompt.is_visible`).

**Async Queues**: Communication between UI and AgentLoop uses `asyncio.Queue` for decoupled async message passing.

**Observer Pattern**: `MCPServerStatus` uses callback subscriptions to notify components of connection state changes.

**TYPE_CHECKING Guards**: Circular import prevention via `if TYPE_CHECKING:` blocks for type hints.

### Testing

Tests use pytest with pytest-asyncio for async support and Textual's pilot testing framework for UI interactions.

```bash
make test
```
9 changes: 9 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ dependencies = [
dev = [
"mypy>=1.19.0",
"pre-commit>=4.3.0",
"pytest>=8.0.0",
"pytest-asyncio>=0.24.0",
"pytest-textual-snapshot>=1.0.0",
"ruff>=0.14.7",
"textual-dev>=1.8.0",
"types-pyyaml>=6.0.12.20250915",
Expand All @@ -30,3 +33,9 @@ dev = "textual_dev.cli:run"

[tool.uv]
package = true

[tool.pytest.ini_options]
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
testpaths = ["tests"]
pythonpath = ["src"]
Empty file added tests/__init__.py
Empty file.
Empty file added tests/components/__init__.py
Empty file.
79 changes: 79 additions & 0 deletions tests/components/test_chat_history.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import pytest
from textual.app import App, ComposeResult

from agent_chat_cli.components.chat_history import ChatHistory
from agent_chat_cli.components.messages import (
Message,
SystemMessage,
UserMessage,
AgentMessage,
ToolMessage,
)


class ChatHistoryApp(App):
def compose(self) -> ComposeResult:
yield ChatHistory()


class TestChatHistoryAddMessage:
@pytest.fixture
def app(self):
return ChatHistoryApp()

async def test_adds_system_message(self, app):
async with app.run_test():
chat_history = app.query_one(ChatHistory)
chat_history.add_message(Message.system("System alert"))

widgets = chat_history.query(SystemMessage)
assert len(widgets) == 1
assert widgets.first().message == "System alert"

async def test_adds_user_message(self, app):
async with app.run_test():
chat_history = app.query_one(ChatHistory)
chat_history.add_message(Message.user("Hello"))

widgets = chat_history.query(UserMessage)
assert len(widgets) == 1
assert widgets.first().message == "Hello"

async def test_adds_agent_message(self, app):
async with app.run_test():
chat_history = app.query_one(ChatHistory)
chat_history.add_message(Message.agent("I can help"))

widgets = chat_history.query(AgentMessage)
assert len(widgets) == 1
assert widgets.first().message == "I can help"

async def test_adds_tool_message_with_json_content(self, app):
async with app.run_test():
chat_history = app.query_one(ChatHistory)
chat_history.add_message(
Message.tool("read_file", '{"path": "/tmp/test.txt"}')
)

widgets = chat_history.query(ToolMessage)
assert len(widgets) == 1
assert widgets.first().tool_name == "read_file"
assert widgets.first().tool_input == {"path": "/tmp/test.txt"}

async def test_tool_message_handles_invalid_json(self, app):
async with app.run_test():
chat_history = app.query_one(ChatHistory)
chat_history.add_message(Message.tool("bash", "not valid json"))

widgets = chat_history.query(ToolMessage)
assert len(widgets) == 1
assert widgets.first().tool_input == {"raw": "not valid json"}

async def test_adds_multiple_messages(self, app):
async with app.run_test():
chat_history = app.query_one(ChatHistory)
chat_history.add_message(Message.user("First"))
chat_history.add_message(Message.agent("Second"))
chat_history.add_message(Message.user("Third"))

assert len(chat_history.children) == 3
60 changes: 60 additions & 0 deletions tests/components/test_header.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import pytest
from unittest.mock import MagicMock, patch

from textual.app import App, ComposeResult
from textual.widgets import Label

from agent_chat_cli.components.header import Header
from agent_chat_cli.utils.mcp_server_status import MCPServerStatus


@pytest.fixture(autouse=True)
def reset_mcp_status():
MCPServerStatus._mcp_servers = []
MCPServerStatus._callbacks = []
yield
MCPServerStatus._mcp_servers = []
MCPServerStatus._callbacks = []


@pytest.fixture
def mock_config():
with patch("agent_chat_cli.components.header.load_config") as mock:
mock.return_value = MagicMock(
mcp_servers={"filesystem": MagicMock(), "github": MagicMock()},
agents={"researcher": MagicMock()},
)
yield mock


class HeaderApp(App):
def compose(self) -> ComposeResult:
yield Header()


class TestHeaderMCPServerStatus:
async def test_subscribes_on_mount(self, mock_config):
app = HeaderApp()
async with app.run_test():
assert len(MCPServerStatus._callbacks) == 1

async def test_updates_label_on_status_change(self, mock_config):
app = HeaderApp()
async with app.run_test():
MCPServerStatus.update(
[
{"name": "filesystem", "status": "connected"},
{"name": "github", "status": "error"},
]
)

header = app.query_one(Header)
header._handle_mcp_server_status()

label = app.query_one("#header-mcp-servers", Label)
# Label stores markup in _content or we can check via render
content = label.render()
rendered = str(content)

assert "filesystem" in rendered
assert "github" in rendered
Loading