1 Commits

Author SHA1 Message Date
Yeachan-Heo
b4e0579477 auto: save WIP progress from rcc session 2026-04-01 04:01:37 +00:00
34 changed files with 407 additions and 3174 deletions

View File

@@ -1,5 +0,0 @@
{
"permissions": {
"defaultMode": "dontAsk"
}
}

0
.codex Normal file
View File

3
.gitignore vendored
View File

@@ -2,6 +2,3 @@ __pycache__/
archive/ archive/
.omx/ .omx/
.clawd-agents/ .clawd-agents/
# Claude Code local artifacts
.claude/settings.local.json
.claude/sessions/

View File

@@ -1,21 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Detected stack
- Languages: Rust.
- Frameworks: none detected from the supported starter markers.
## Verification
- Run Rust verification from `rust/`: `cargo fmt`, `cargo clippy --workspace --all-targets -- -D warnings`, `cargo test --workspace`
- `src/` and `tests/` are both present; update both surfaces together when behavior changes.
## Repository shape
- `rust/` contains the Rust workspace and active CLI/runtime implementation.
- `src/` contains source files that should stay consistent with generated guidance and tests.
- `tests/` contains validation surfaces that should be reviewed alongside code changes.
## Working agreement
- Prefer small, reviewable changes and keep generated bootstrap files aligned with actual repo workflows.
- Keep shared defaults in `.claude.json`; reserve `.claude/settings.local.json` for machine-local overrides.
- Do not overwrite existing `CLAUDE.md` content automatically; update it intentionally when repo workflows change.

View File

@@ -1 +0,0 @@
{"messages":[{"blocks":[{"text":"hello use bash tool for testing","type":"text"}],"role":"user"},{"blocks":[{"text":"\n\nHello! I'm ready to help. Let me run a quick bash command to confirm everything is working:","type":"text"},{"id":"toolu_01EuTzVfUK7iPRBvjZAovzfV","input":"{\"command\": \"echo \\\"Hello! Bash tool is working. 🎉\\\" && date && pwd\"}","name":"bash","type":"tool_use"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":4277,"output_tokens":92}},{"blocks":[{"is_error":false,"output":"{\n \"stdout\": \"Hello! Bash tool is working. 🎉\\nWed Apr 1 02:25:46 AM UTC 2026\\n/home/bellman/Workspace/clawd-code/rust\\n\",\n \"stderr\": \"\",\n \"rawOutputPath\": null,\n \"interrupted\": false,\n \"isImage\": null,\n \"backgroundTaskId\": null,\n \"backgroundedByUser\": null,\n \"assistantAutoBackgrounded\": null,\n \"dangerouslyDisableSandbox\": null,\n \"returnCodeInterpretation\": null,\n \"noOutputExpected\": false,\n \"structuredContent\": null,\n \"persistedOutputPath\": null,\n \"persistedOutputSize\": null,\n \"sandboxStatus\": {\n \"enabled\": true,\n \"requested\": {\n \"enabled\": true,\n \"namespace_restrictions\": true,\n \"network_isolation\": false,\n \"filesystem_mode\": \"workspace-only\",\n \"allowed_mounts\": []\n },\n \"supported\": true,\n \"active\": true,\n \"namespace_supported\": true,\n \"namespace_active\": true,\n \"network_supported\": true,\n \"network_active\": false,\n \"filesystem_mode\": \"workspace-only\",\n \"filesystem_active\": true,\n \"allowed_mounts\": [],\n \"in_container\": false,\n \"container_markers\": [],\n \"fallback_reason\": null\n }\n}","tool_name":"bash","tool_use_id":"toolu_01EuTzVfUK7iPRBvjZAovzfV","type":"tool_result"}],"role":"tool"},{"blocks":[{"text":"Bash tool is working perfectly! ✅\n\nHere's a quick summary:\n- **Status**: Operational 🎉\n- **Date**: Wed Apr 1, 2026\n- **Working directory**: `/home/bellman/Workspace/clawd-code/rust`\n- **Sandbox**: Enabled and active\n\nHow can I help you today?","type":"text"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":4746,"output_tokens":84}}],"version":1}

View File

@@ -1 +0,0 @@
{"messages":[{"blocks":[{"text":"who are you?","type":"text"}],"role":"user"},{"blocks":[{"text":"I'm **Claude**, an AI assistant built by Anthropic, currently running as a **Claude Code agent** (Claude Opus 4.6). I'm set up in your workspace at `/home/bellman/Workspace/clawd-code/rust` with the **oh-my-claudecode (OMC)** orchestration layer active.\n\nHere's what I can help you with:\n\n- **Software engineering tasks** — reading, writing, and refactoring code\n- **Debugging** — diagnosing issues, running tests, tracing errors\n- **Research & planning** — exploring codebases, understanding architecture, planning changes\n- **Shell commands** — building, testing, running tools in your environment\n- **Multi-agent workflows** — delegating specialized work (reviews, deep analysis, etc.) via OMC\n\nI have access to your local filesystem, can execute shell commands, search the web, and coordinate specialized sub-agents for complex tasks.\n\nWhat can I help you with today?","type":"text"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":4292,"output_tokens":220}}],"version":1}

View File

@@ -1 +0,0 @@
{"messages":[],"version":1}

View File

@@ -1 +0,0 @@
{"messages":[],"version":1}

File diff suppressed because one or more lines are too long

View File

@@ -1 +0,0 @@
{"messages":[{"blocks":[{"text":"clear","type":"text"}],"role":"user"},{"blocks":[{"text":"\n\nI've cleared the conversation. How can I help you today?","type":"text"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":4272,"output_tokens":17}}],"version":1}

View File

@@ -1 +0,0 @@
{"messages":[{"blocks":[{"text":"exit","type":"text"}],"role":"user"},{"blocks":[{"text":"\n\nGoodbye! 👋","type":"text"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":4272,"output_tokens":10}}],"version":1}

View File

@@ -1 +0,0 @@
{"messages":[],"version":1}

View File

@@ -1,27 +0,0 @@
[
{
"content": "Architecture & dependency analysis",
"activeForm": "Complete",
"status": "completed"
},
{
"content": "Runtime crate deep analysis",
"activeForm": "Complete",
"status": "completed"
},
{
"content": "CLI & Tools analysis",
"activeForm": "Complete",
"status": "completed"
},
{
"content": "Code quality verification",
"activeForm": "Complete",
"status": "completed"
},
{
"content": "Synthesize findings into unified report",
"activeForm": "Writing report",
"status": "in_progress"
}
]

View File

@@ -1,221 +0,0 @@
# TUI Enhancement Plan — Claw Code (`rusty-claude-cli`)
## Executive Summary
This plan covers a comprehensive analysis of the current terminal user interface and proposes phased enhancements that will transform the existing REPL/prompt CLI into a polished, modern TUI experience — while preserving the existing clean architecture and test coverage.
---
## 1. Current Architecture Analysis
### Crate Map
| Crate | Purpose | Lines | TUI Relevance |
|---|---|---|---|
| `rusty-claude-cli` | Main binary: REPL loop, arg parsing, rendering, API bridge | ~3,600 | **Primary TUI surface** |
| `runtime` | Session, conversation loop, config, permissions, compaction | ~5,300 | Provides data/state |
| `api` | Anthropic HTTP client + SSE streaming | ~1,500 | Provides stream events |
| `commands` | Slash command metadata/parsing/help | ~470 | Drives command dispatch |
| `tools` | 18 built-in tool implementations | ~3,500 | Tool execution display |
### Current TUI Components
| Component | File | What It Does Today | Quality |
|---|---|---|---|
| **Input** | `input.rs` (269 lines) | `rustyline`-based line editor with slash-command tab completion, Shift+Enter newline, history | ✅ Solid |
| **Rendering** | `render.rs` (641 lines) | Markdown→terminal rendering (headings, lists, tables, code blocks with syntect highlighting, blockquotes), spinner widget | ✅ Good |
| **App/REPL loop** | `main.rs` (3,159 lines) | The monolithic `LiveCli` struct: REPL loop, all slash command handlers, streaming output, tool call display, permission prompting, session management | ⚠️ Monolithic |
| **Alt App** | `app.rs` (398 lines) | An earlier `CliApp` prototype with `ConversationClient`, stream event handling, `TerminalRenderer`, output format support | ⚠️ Appears unused/legacy |
### Key Dependencies
- **crossterm 0.28** — terminal control (cursor, colors, clear)
- **pulldown-cmark 0.13** — Markdown parsing
- **syntect 5** — syntax highlighting
- **rustyline 15** — line editing with completion
- **serde_json** — tool I/O formatting
### Strengths
1. **Clean rendering pipeline**: Markdown rendering is well-structured with state tracking, table rendering, code highlighting
2. **Rich tool display**: Tool calls get box-drawing borders (`╭─ name ─╮`), results show ✓/✗ icons
3. **Comprehensive slash commands**: 15 commands covering model switching, permissions, sessions, config, diff, export
4. **Session management**: Full persistence, resume, list, switch, compaction
5. **Permission prompting**: Interactive Y/N approval for restricted tool calls
6. **Thorough tests**: Every formatting function, every parse path has unit tests
### Weaknesses & Gaps
1. **`main.rs` is a 3,159-line monolith** — all REPL logic, formatting, API bridging, session management, and tests in one file
2. **No alternate-screen / full-screen layout** — everything is inline scrolling output
3. **No progress bars** — only a single braille spinner; no indication of streaming progress or token counts during generation
4. **No visual diff rendering**`/diff` just dumps raw git diff text
5. **No syntax highlighting in streamed output** — markdown rendering only applies to tool results, not to the main assistant response stream
6. **No status bar / HUD** — model, tokens, session info not visible during interaction
7. **No image/attachment preview**`SendUserMessage` resolves attachments but never displays them
8. **Streaming is char-by-char with artificial delay**`stream_markdown` sleeps 8ms per whitespace-delimited chunk
9. **No color theme customization** — hardcoded `ColorTheme::default()`
10. **No resize handling** — no terminal size awareness for wrapping, truncation, or layout
11. **Dual app structs**`app.rs` has a separate `CliApp` that duplicates `LiveCli` from `main.rs`
12. **No pager for long outputs**`/status`, `/config`, `/memory` can overflow the viewport
13. **Tool results not collapsible** — large bash outputs flood the screen
14. **No thinking/reasoning indicator** — when the model is in "thinking" mode, no visual distinction
15. **No auto-complete for tool arguments** — only slash command names complete
---
## 2. Enhancement Plan
### Phase 0: Structural Cleanup (Foundation)
**Goal**: Break the monolith, remove dead code, establish the module structure for TUI work.
| Task | Description | Effort |
|---|---|---|
| 0.1 | **Extract `LiveCli` into `app.rs`** — Move the entire `LiveCli` struct, its impl, and helpers (`format_*`, `render_*`, session management) out of `main.rs` into focused modules: `app.rs` (core), `format.rs` (report formatting), `session_manager.rs` (session CRUD) | M |
| 0.2 | **Remove or merge the legacy `CliApp`** — The existing `app.rs` has an unused `CliApp` with its own `ConversationClient`-based rendering. Either delete it or merge its unique features (stream event handler pattern) into the active `LiveCli` | S |
| 0.3 | **Extract `main.rs` arg parsing** — The current `parse_args()` is a hand-rolled parser that duplicates the clap-based `args.rs`. Consolidate on the hand-rolled parser (it's more feature-complete) and move it to `args.rs`, or adopt clap fully | S |
| 0.4 | **Create a `tui/` module** — Introduce `crates/rusty-claude-cli/src/tui/mod.rs` as the namespace for all new TUI components: `status_bar.rs`, `layout.rs`, `tool_panel.rs`, etc. | S |
### Phase 1: Status Bar & Live HUD
**Goal**: Persistent information display during interaction.
| Task | Description | Effort |
|---|---|---|
| 1.1 | **Terminal-size-aware status line** — Use `crossterm::terminal::size()` to render a bottom-pinned status bar showing: model name, permission mode, session ID, cumulative token count, estimated cost | M |
| 1.2 | **Live token counter** — Update the status bar in real-time as `AssistantEvent::Usage` and `AssistantEvent::TextDelta` events arrive during streaming | M |
| 1.3 | **Turn duration timer** — Show elapsed time for the current turn (the `showTurnDuration` config already exists in Config tool but isn't wired up) | S |
| 1.4 | **Git branch indicator** — Display the current git branch in the status bar (already parsed via `parse_git_status_metadata`) | S |
### Phase 2: Enhanced Streaming Output
**Goal**: Make the main response stream visually rich and responsive.
| Task | Description | Effort |
|---|---|---|
| 2.1 | **Live markdown rendering** — Instead of raw text streaming, buffer text deltas and incrementally render Markdown as it arrives (heading detection, bold/italic, inline code). The existing `TerminalRenderer::render_markdown` can be adapted for incremental use | L |
| 2.2 | **Thinking indicator** — When extended thinking/reasoning is active, show a distinct animated indicator (e.g., `🧠 Reasoning...` with pulsing dots or a different spinner) instead of the generic `🦀 Thinking...` | S |
| 2.3 | **Streaming progress bar** — Add an optional horizontal progress indicator below the spinner showing approximate completion (based on max_tokens vs. output_tokens so far) | M |
| 2.4 | **Remove artificial stream delay** — The current `stream_markdown` sleeps 8ms per chunk. For tool results this is fine, but for the main response stream it should be immediate or configurable | S |
### Phase 3: Tool Call Visualization
**Goal**: Make tool execution legible and navigable.
| Task | Description | Effort |
|---|---|---|
| 3.1 | **Collapsible tool output** — For tool results longer than N lines (configurable, default 15), show a summary with `[+] Expand` hint; pressing a key reveals the full output. Initially implement as truncation with a "full output saved to file" fallback | M |
| 3.2 | **Syntax-highlighted tool results** — When tool results contain code (detected by tool name — `bash` stdout, `read_file` content, `REPL` output), apply syntect highlighting rather than rendering as plain text | M |
| 3.3 | **Tool call timeline** — For multi-tool turns, show a compact summary: `🔧 bash → ✓ | read_file → ✓ | edit_file → ✓ (3 tools, 1.2s)` after all tool calls complete | S |
| 3.4 | **Diff-aware edit_file display** — When `edit_file` succeeds, show a colored unified diff of the change instead of just `✓ edit_file: path` | M |
| 3.5 | **Permission prompt enhancement** — Style the approval prompt with box drawing, color the tool name, show a one-line summary of what the tool will do | S |
### Phase 4: Enhanced Slash Commands & Navigation
**Goal**: Improve information display and add missing features.
| Task | Description | Effort |
|---|---|---|
| 4.1 | **Colored `/diff` output** — Parse the git diff and render it with red/green coloring for removals/additions, similar to `delta` or `diff-so-fancy` | M |
| 4.2 | **Pager for long outputs** — When `/status`, `/config`, `/memory`, or `/diff` produce output longer than the terminal height, pipe through an internal pager (scroll with j/k/q) or external `$PAGER` | M |
| 4.3 | **`/search` command** — Add a new command to search conversation history by keyword | M |
| 4.4 | **`/undo` command** — Undo the last file edit by restoring from the `originalFile` data in `write_file`/`edit_file` tool results | M |
| 4.5 | **Interactive session picker** — Replace the text-based `/session list` with an interactive fuzzy-filterable list (up/down arrows to select, enter to switch) | L |
| 4.6 | **Tab completion for tool arguments** — Extend `SlashCommandHelper` to complete file paths after `/export`, model names after `/model`, session IDs after `/session switch` | M |
### Phase 5: Color Themes & Configuration
**Goal**: User-customizable visual appearance.
| Task | Description | Effort |
|---|---|---|
| 5.1 | **Named color themes** — Add `dark` (current default), `light`, `solarized`, `catppuccin` themes. Wire to the existing `Config` tool's `theme` setting | M |
| 5.2 | **ANSI-256 / truecolor detection** — Detect terminal capabilities and fall back gracefully (no colors → 16 colors → 256 → truecolor) | M |
| 5.3 | **Configurable spinner style** — Allow choosing between braille dots, bar, moon phases, etc. | S |
| 5.4 | **Banner customization** — Make the ASCII art banner optional or configurable via settings | S |
### Phase 6: Full-Screen TUI Mode (Stretch)
**Goal**: Optional alternate-screen layout for power users.
| Task | Description | Effort |
|---|---|---|
| 6.1 | **Add `ratatui` dependency** — Introduce `ratatui` (terminal UI framework) as an optional dependency for the full-screen mode | S |
| 6.2 | **Split-pane layout** — Top pane: conversation with scrollback; Bottom pane: input area; Right sidebar (optional): tool status/todo list | XL |
| 6.3 | **Scrollable conversation view** — Navigate past messages with PgUp/PgDn, search within conversation | L |
| 6.4 | **Keyboard shortcuts panel** — Show `?` help overlay with all keybindings | M |
| 6.5 | **Mouse support** — Click to expand tool results, scroll conversation, select text for copy | L |
---
## 3. Priority Recommendation
### Immediate (High Impact, Moderate Effort)
1. **Phase 0** — Essential cleanup. The 3,159-line `main.rs` is the #1 maintenance risk and blocks clean TUI additions.
2. **Phase 1.11.2** — Status bar with live tokens. Highest-impact UX win: users constantly want to know token usage.
3. **Phase 2.4** — Remove artificial delay. Low effort, immediately noticeable improvement.
4. **Phase 3.1** — Collapsible tool output. Large bash outputs currently wreck readability.
### Near-Term (Next Sprint)
5. **Phase 2.1** — Live markdown rendering. Makes the core interaction feel polished.
6. **Phase 3.2** — Syntax-highlighted tool results.
7. **Phase 3.4** — Diff-aware edit display.
8. **Phase 4.1** — Colored diff for `/diff`.
### Longer-Term
9. **Phase 5** — Color themes (user demand-driven).
10. **Phase 4.24.6** — Enhanced navigation and commands.
11. **Phase 6** — Full-screen mode (major undertaking, evaluate after earlier phases ship).
---
## 4. Architecture Recommendations
### Module Structure After Phase 0
```
crates/rusty-claude-cli/src/
├── main.rs # Entrypoint, arg dispatch only (~100 lines)
├── args.rs # CLI argument parsing (consolidate existing two parsers)
├── app.rs # LiveCli struct, REPL loop, turn execution
├── format.rs # All report formatting (status, cost, model, permissions, etc.)
├── session_mgr.rs # Session CRUD: create, resume, list, switch, persist
├── init.rs # Repo initialization (unchanged)
├── input.rs # Line editor (unchanged, minor extensions)
├── render.rs # TerminalRenderer, Spinner (extended)
└── tui/
├── mod.rs # TUI module root
├── status_bar.rs # Persistent bottom status line
├── tool_panel.rs # Tool call visualization (boxes, timelines, collapsible)
├── diff_view.rs # Colored diff rendering
├── pager.rs # Internal pager for long outputs
└── theme.rs # Color theme definitions and selection
```
### Key Design Principles
1. **Keep the inline REPL as the default** — Full-screen TUI should be opt-in (`--tui` flag)
2. **Everything testable without a terminal** — All formatting functions take `&mut impl Write`, never assume stdout directly
3. **Streaming-first** — Rendering should work incrementally, not buffering the entire response
4. **Respect `crossterm` for all terminal control** — Don't mix raw ANSI escape codes with crossterm (the current codebase does this in the startup banner)
5. **Feature-gate heavy dependencies**`ratatui` should be behind a `full-tui` feature flag
---
## 5. Risk Assessment
| Risk | Mitigation |
|---|---|
| Breaking the working REPL during refactor | Phase 0 is pure restructuring with existing test coverage as safety net |
| Terminal compatibility issues (tmux, SSH, Windows) | Rely on crossterm's abstraction; test in degraded environments |
| Performance regression with rich rendering | Profile before/after; keep the fast path (raw streaming) always available |
| Scope creep into Phase 6 | Ship Phases 03 as a coherent release before starting Phase 6 |
| `app.rs` vs `main.rs` confusion | Phase 0.2 explicitly resolves this by removing the legacy `CliApp` |
---
*Generated: 2026-03-31 | Workspace: `rust/` | Branch: `dev/rust`*

View File

@@ -1,3 +0,0 @@
version = "12"
[overrides]

10
rust/Cargo.lock generated
View File

@@ -25,7 +25,6 @@ dependencies = [
"runtime", "runtime",
"serde", "serde",
"serde_json", "serde_json",
"telemetry",
"tokio", "tokio",
] ]
@@ -1097,7 +1096,6 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
"telemetry",
"tokio", "tokio",
"walkdir", "walkdir",
] ]
@@ -1430,14 +1428,6 @@ dependencies = [
"yaml-rust", "yaml-rust",
] ]
[[package]]
name = "telemetry"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
]
[[package]] [[package]]
name = "thiserror" name = "thiserror"
version = "2.0.18" version = "2.0.18"

View File

@@ -1,149 +1,230 @@
# 🦞 Claw Code — Rust Implementation # Rusty Claude CLI
A high-performance Rust rewrite of the Claude Code CLI agent harness. Built for speed, safety, and native tool execution. `rust/` contains the Rust workspace for the integrated `rusty-claude-cli` deliverable.
It is intended to be something you can clone, build, and run directly.
## Quick Start ## Workspace layout
```bash ```text
# Build
cd rust/
cargo build --release
# Run interactive REPL
./target/release/claw
# One-shot prompt
./target/release/claw prompt "explain this codebase"
# With specific model
./target/release/claw --model sonnet prompt "fix the bug in main.rs"
```
## Configuration
Set your API credentials:
```bash
export ANTHROPIC_API_KEY="sk-ant-..."
# Or use a proxy
export ANTHROPIC_BASE_URL="https://your-proxy.com"
```
Or authenticate via OAuth:
```bash
claw login
```
## Features
| Feature | Status |
|---------|--------|
| Anthropic API + streaming | ✅ |
| OAuth login/logout | ✅ |
| Interactive REPL (rustyline) | ✅ |
| Tool system (bash, read, write, edit, grep, glob) | ✅ |
| Web tools (search, fetch) | ✅ |
| Sub-agent orchestration | ✅ |
| Todo tracking | ✅ |
| Notebook editing | ✅ |
| CLAUDE.md / project memory | ✅ |
| Config file hierarchy (.claude.json) | ✅ |
| Permission system | ✅ |
| MCP server lifecycle | ✅ |
| Session persistence + resume | ✅ |
| Extended thinking (thinking blocks) | ✅ |
| Cost tracking + usage display | ✅ |
| Git integration | ✅ |
| Markdown terminal rendering (ANSI) | ✅ |
| Model aliases (opus/sonnet/haiku) | ✅ |
| Slash commands (/status, /compact, /clear, etc.) | ✅ |
| Hooks (PreToolUse/PostToolUse) | 🔧 Config only |
| Plugin system | 📋 Planned |
| Skills registry | 📋 Planned |
## Model Aliases
Short names resolve to the latest model versions:
| Alias | Resolves To |
|-------|------------|
| `opus` | `claude-opus-4-6` |
| `sonnet` | `claude-sonnet-4-6` |
| `haiku` | `claude-haiku-4-5-20251213` |
## CLI Flags
```
claw [OPTIONS] [COMMAND]
Options:
--model MODEL Set the model (alias or full name)
--dangerously-skip-permissions Skip all permission checks
--permission-mode MODE Set read-only, workspace-write, or danger-full-access
--allowedTools TOOLS Restrict enabled tools
--output-format FORMAT Output format (text or json)
--version, -V Print version info
Commands:
prompt <text> One-shot prompt (non-interactive)
login Authenticate via OAuth
logout Clear stored credentials
init Initialize project config
doctor Check environment health
self-update Update to latest version
```
## Slash Commands (REPL)
| Command | Description |
|---------|-------------|
| `/help` | Show help |
| `/status` | Show session status (model, tokens, cost) |
| `/cost` | Show cost breakdown |
| `/compact` | Compact conversation history |
| `/clear` | Clear conversation |
| `/model [name]` | Show or switch model |
| `/permissions` | Show or switch permission mode |
| `/config [section]` | Show config (env, hooks, model) |
| `/memory` | Show CLAUDE.md contents |
| `/diff` | Show git diff |
| `/export [path]` | Export conversation |
| `/session [id]` | Resume a previous session |
| `/version` | Show version |
## Workspace Layout
```
rust/ rust/
├── Cargo.toml # Workspace root ├── Cargo.toml
├── Cargo.lock ├── Cargo.lock
├── README.md
└── crates/ └── crates/
├── api/ # Anthropic API client + SSE streaming ├── api/ # Anthropic API client + SSE streaming support
├── commands/ # Shared slash-command registry ├── commands/ # Shared slash-command metadata/help surfaces
├── compat-harness/ # TS manifest extraction harness ├── compat-harness/ # Upstream TS manifest extraction harness
├── runtime/ # Session, config, permissions, MCP, prompts ├── runtime/ # Session/runtime/config/prompt orchestration
├── rusty-claude-cli/ # Main CLI binary (`claw`) ├── rusty-claude-cli/ # Main CLI binary
└── tools/ # Built-in tool implementations └── tools/ # Built-in tool implementations
``` ```
### Crate Responsibilities ## Prerequisites
- **api** — HTTP client, SSE stream parser, request/response types, auth (API key + OAuth bearer) - Rust toolchain installed (`rustup`, stable toolchain)
- **commands** — Slash command definitions and help text generation - Network access and Anthropic credentials for live prompt/REPL usage
- **compat-harness** — Extracts tool/prompt manifests from upstream TS source
- **runtime** — `ConversationRuntime` agentic loop, `ConfigLoader` hierarchy, `Session` persistence, permission policy, MCP client, system prompt assembly, usage tracking
- **rusty-claude-cli** — REPL, one-shot prompt, streaming display, tool call rendering, CLI argument parsing
- **tools** — Tool specs + execution: Bash, ReadFile, WriteFile, EditFile, GlobSearch, GrepSearch, WebSearch, WebFetch, Agent, TodoWrite, NotebookEdit, Skill, ToolSearch, REPL runtimes
## Stats ## Build
- **~20K lines** of Rust From the repository root:
- **6 crates** in workspace
- **Binary name:** `claw`
- **Default model:** `claude-opus-4-6`
- **Default permissions:** `danger-full-access`
## License ```bash
cd rust
cargo build --release -p rusty-claude-cli
```
See repository root. The optimized binary will be written to:
```bash
./target/release/rusty-claude-cli
```
## Test
Run the verified workspace test suite used for release-readiness:
```bash
cd rust
cargo test --workspace --exclude compat-harness
```
## Quick start
### Show help
```bash
cd rust
cargo run -p rusty-claude-cli -- --help
```
### Print version
```bash
cd rust
cargo run -p rusty-claude-cli -- --version
```
### Login with OAuth
Configure `settings.json` with an `oauth` block containing `clientId`, `authorizeUrl`, `tokenUrl`, optional `callbackPort`, and optional `scopes`, then run:
```bash
cd rust
cargo run -p rusty-claude-cli -- login
```
This opens the browser, listens on the configured localhost callback, exchanges the auth code for tokens, and stores OAuth credentials in `~/.claude/credentials.json` (or `$CLAUDE_CONFIG_HOME/credentials.json`).
### Logout
```bash
cd rust
cargo run -p rusty-claude-cli -- logout
```
This removes only the stored OAuth credentials and preserves unrelated JSON fields in `credentials.json`.
### Self-update
```bash
cd rust
cargo run -p rusty-claude-cli -- self-update
```
The command checks the latest GitHub release for `instructkr/clawd-code`, compares it to the current binary version, downloads the matching binary asset plus checksum manifest, verifies SHA-256, replaces the current executable, and prints the release changelog. If no published release or matching asset exists, it exits safely with an explanatory message.
## Usage examples
### 1) Prompt mode
Send one prompt, stream the answer, then exit:
```bash
cd rust
cargo run -p rusty-claude-cli -- prompt "Summarize the architecture of this repository"
```
Use a specific model:
```bash
cd rust
cargo run -p rusty-claude-cli -- --model claude-sonnet-4-20250514 prompt "List the key crates in this workspace"
```
Restrict enabled tools in an interactive session:
```bash
cd rust
cargo run -p rusty-claude-cli -- --allowedTools read,glob
```
Bootstrap Claude project files for the current repo:
```bash
cd rust
cargo run -p rusty-claude-cli -- init
```
### 2) REPL mode
Start the interactive shell:
```bash
cd rust
cargo run -p rusty-claude-cli --
```
Inside the REPL, useful commands include:
```text
/help
/status
/model claude-sonnet-4-20250514
/permissions workspace-write
/cost
/compact
/memory
/config
/init
/diff
/version
/export notes.txt
/sessions
/session list
/exit
```
### 3) Resume an existing session
Inspect or maintain a saved session file without entering the REPL:
```bash
cd rust
cargo run -p rusty-claude-cli -- --resume session-123456 /status /compact /cost
```
You can also inspect memory/config state for a restored session:
```bash
cd rust
cargo run -p rusty-claude-cli -- --resume ~/.claude/sessions/session-123456.json /memory /config
```
## Available commands
### Top-level CLI commands
- `prompt <text...>` — run one prompt non-interactively
- `--resume <session-id-or-path> [/commands...]` — inspect or maintain a saved session stored under `~/.claude/sessions/`
- `dump-manifests` — print extracted upstream manifest counts
- `bootstrap-plan` — print the current bootstrap skeleton
- `system-prompt [--cwd PATH] [--date YYYY-MM-DD]` — render the synthesized system prompt
- `self-update` — update the installed binary from the latest GitHub release when a matching asset is available
- `--help` / `-h` — show CLI help
- `--version` / `-V` — print the CLI version and build info locally (no API call)
- `--output-format text|json` — choose non-interactive prompt output rendering
- `--allowedTools <tool[,tool...]>` — restrict enabled tools for interactive sessions and prompt-mode tool use
### Interactive slash commands
- `/help` — show command help
- `/status` — show current session status
- `/compact` — compact local session history
- `/model [model]` — inspect or switch the active model
- `/permissions [read-only|workspace-write|danger-full-access]` — inspect or switch permissions
- `/clear [--confirm]` — clear the current local session
- `/cost` — show token usage totals
- `/resume <session-id-or-path>` — load a saved session into the REPL
- `/config [env|hooks|model]` — inspect discovered Claude config
- `/memory` — inspect loaded instruction memory files
- `/init` — bootstrap `.claude.json`, `.claude/`, `CLAUDE.md`, and local ignore rules
- `/diff` — show the current git diff for the workspace
- `/version` — print version and build metadata locally
- `/export [file]` — export the current conversation transcript
- `/sessions` — list recent managed local sessions from `~/.claude/sessions/`
- `/session [list|switch <session-id>]` — inspect or switch managed local sessions
- `/exit` — leave the REPL
## Environment variables
### Anthropic/API
- `ANTHROPIC_API_KEY` — highest-precedence API credential
- `ANTHROPIC_AUTH_TOKEN` — bearer-token override used when no API key is set
- Persisted OAuth credentials in `~/.claude/credentials.json` — used when neither env var is set
- `ANTHROPIC_BASE_URL` — override the Anthropic API base URL
- `ANTHROPIC_MODEL` — default model used by selected live integration tests
### CLI/runtime
- `RUSTY_CLAUDE_PERMISSION_MODE` — default REPL permission mode (`read-only`, `workspace-write`, or `danger-full-access`)
- `CLAUDE_CONFIG_HOME` — override Claude config discovery root
- `CLAUDE_CODE_REMOTE` — enable remote-session bootstrap handling when supported
- `CLAUDE_CODE_REMOTE_SESSION_ID` — remote session identifier when using remote mode
- `CLAUDE_CODE_UPSTREAM` — override the upstream TS source path for compat-harness extraction
- `CLAWD_WEB_SEARCH_BASE_URL` — override the built-in web search service endpoint used by tooling
## Notes
- `compat-harness` exists to compare the Rust port against the upstream TypeScript codebase and is intentionally excluded from the requested release test run.
- The CLI currently focuses on a practical integrated workflow: prompt execution, REPL operation, session inspection/resume, config discovery, and tool/runtime plumbing.

View File

@@ -1,221 +0,0 @@
# TUI Enhancement Plan — Claw Code (`rusty-claude-cli`)
## Executive Summary
This plan covers a comprehensive analysis of the current terminal user interface and proposes phased enhancements that will transform the existing REPL/prompt CLI into a polished, modern TUI experience — while preserving the existing clean architecture and test coverage.
---
## 1. Current Architecture Analysis
### Crate Map
| Crate | Purpose | Lines | TUI Relevance |
|---|---|---|---|
| `rusty-claude-cli` | Main binary: REPL loop, arg parsing, rendering, API bridge | ~3,600 | **Primary TUI surface** |
| `runtime` | Session, conversation loop, config, permissions, compaction | ~5,300 | Provides data/state |
| `api` | Anthropic HTTP client + SSE streaming | ~1,500 | Provides stream events |
| `commands` | Slash command metadata/parsing/help | ~470 | Drives command dispatch |
| `tools` | 18 built-in tool implementations | ~3,500 | Tool execution display |
### Current TUI Components
| Component | File | What It Does Today | Quality |
|---|---|---|---|
| **Input** | `input.rs` (269 lines) | `rustyline`-based line editor with slash-command tab completion, Shift+Enter newline, history | ✅ Solid |
| **Rendering** | `render.rs` (641 lines) | Markdown→terminal rendering (headings, lists, tables, code blocks with syntect highlighting, blockquotes), spinner widget | ✅ Good |
| **App/REPL loop** | `main.rs` (3,159 lines) | The monolithic `LiveCli` struct: REPL loop, all slash command handlers, streaming output, tool call display, permission prompting, session management | ⚠️ Monolithic |
| **Alt App** | `app.rs` (398 lines) | An earlier `CliApp` prototype with `ConversationClient`, stream event handling, `TerminalRenderer`, output format support | ⚠️ Appears unused/legacy |
### Key Dependencies
- **crossterm 0.28** — terminal control (cursor, colors, clear)
- **pulldown-cmark 0.13** — Markdown parsing
- **syntect 5** — syntax highlighting
- **rustyline 15** — line editing with completion
- **serde_json** — tool I/O formatting
### Strengths
1. **Clean rendering pipeline**: Markdown rendering is well-structured with state tracking, table rendering, code highlighting
2. **Rich tool display**: Tool calls get box-drawing borders (`╭─ name ─╮`), results show ✓/✗ icons
3. **Comprehensive slash commands**: 15 commands covering model switching, permissions, sessions, config, diff, export
4. **Session management**: Full persistence, resume, list, switch, compaction
5. **Permission prompting**: Interactive Y/N approval for restricted tool calls
6. **Thorough tests**: Every formatting function, every parse path has unit tests
### Weaknesses & Gaps
1. **`main.rs` is a 3,159-line monolith** — all REPL logic, formatting, API bridging, session management, and tests in one file
2. **No alternate-screen / full-screen layout** — everything is inline scrolling output
3. **No progress bars** — only a single braille spinner; no indication of streaming progress or token counts during generation
4. **No visual diff rendering**`/diff` just dumps raw git diff text
5. **No syntax highlighting in streamed output** — markdown rendering only applies to tool results, not to the main assistant response stream
6. **No status bar / HUD** — model, tokens, session info not visible during interaction
7. **No image/attachment preview**`SendUserMessage` resolves attachments but never displays them
8. **Streaming is char-by-char with artificial delay**`stream_markdown` sleeps 8ms per whitespace-delimited chunk
9. **No color theme customization** — hardcoded `ColorTheme::default()`
10. **No resize handling** — no terminal size awareness for wrapping, truncation, or layout
11. **Dual app structs**`app.rs` has a separate `CliApp` that duplicates `LiveCli` from `main.rs`
12. **No pager for long outputs**`/status`, `/config`, `/memory` can overflow the viewport
13. **Tool results not collapsible** — large bash outputs flood the screen
14. **No thinking/reasoning indicator** — when the model is in "thinking" mode, no visual distinction
15. **No auto-complete for tool arguments** — only slash command names complete
---
## 2. Enhancement Plan
### Phase 0: Structural Cleanup (Foundation)
**Goal**: Break the monolith, remove dead code, establish the module structure for TUI work.
| Task | Description | Effort |
|---|---|---|
| 0.1 | **Extract `LiveCli` into `app.rs`** — Move the entire `LiveCli` struct, its impl, and helpers (`format_*`, `render_*`, session management) out of `main.rs` into focused modules: `app.rs` (core), `format.rs` (report formatting), `session_manager.rs` (session CRUD) | M |
| 0.2 | **Remove or merge the legacy `CliApp`** — The existing `app.rs` has an unused `CliApp` with its own `ConversationClient`-based rendering. Either delete it or merge its unique features (stream event handler pattern) into the active `LiveCli` | S |
| 0.3 | **Extract `main.rs` arg parsing** — The current `parse_args()` is a hand-rolled parser that duplicates the clap-based `args.rs`. Consolidate on the hand-rolled parser (it's more feature-complete) and move it to `args.rs`, or adopt clap fully | S |
| 0.4 | **Create a `tui/` module** — Introduce `crates/rusty-claude-cli/src/tui/mod.rs` as the namespace for all new TUI components: `status_bar.rs`, `layout.rs`, `tool_panel.rs`, etc. | S |
### Phase 1: Status Bar & Live HUD
**Goal**: Persistent information display during interaction.
| Task | Description | Effort |
|---|---|---|
| 1.1 | **Terminal-size-aware status line** — Use `crossterm::terminal::size()` to render a bottom-pinned status bar showing: model name, permission mode, session ID, cumulative token count, estimated cost | M |
| 1.2 | **Live token counter** — Update the status bar in real-time as `AssistantEvent::Usage` and `AssistantEvent::TextDelta` events arrive during streaming | M |
| 1.3 | **Turn duration timer** — Show elapsed time for the current turn (the `showTurnDuration` config already exists in Config tool but isn't wired up) | S |
| 1.4 | **Git branch indicator** — Display the current git branch in the status bar (already parsed via `parse_git_status_metadata`) | S |
### Phase 2: Enhanced Streaming Output
**Goal**: Make the main response stream visually rich and responsive.
| Task | Description | Effort |
|---|---|---|
| 2.1 | **Live markdown rendering** — Instead of raw text streaming, buffer text deltas and incrementally render Markdown as it arrives (heading detection, bold/italic, inline code). The existing `TerminalRenderer::render_markdown` can be adapted for incremental use | L |
| 2.2 | **Thinking indicator** — When extended thinking/reasoning is active, show a distinct animated indicator (e.g., `🧠 Reasoning...` with pulsing dots or a different spinner) instead of the generic `🦀 Thinking...` | S |
| 2.3 | **Streaming progress bar** — Add an optional horizontal progress indicator below the spinner showing approximate completion (based on max_tokens vs. output_tokens so far) | M |
| 2.4 | **Remove artificial stream delay** — The current `stream_markdown` sleeps 8ms per chunk. For tool results this is fine, but for the main response stream it should be immediate or configurable | S |
### Phase 3: Tool Call Visualization
**Goal**: Make tool execution legible and navigable.
| Task | Description | Effort |
|---|---|---|
| 3.1 | **Collapsible tool output** — For tool results longer than N lines (configurable, default 15), show a summary with `[+] Expand` hint; pressing a key reveals the full output. Initially implement as truncation with a "full output saved to file" fallback | M |
| 3.2 | **Syntax-highlighted tool results** — When tool results contain code (detected by tool name — `bash` stdout, `read_file` content, `REPL` output), apply syntect highlighting rather than rendering as plain text | M |
| 3.3 | **Tool call timeline** — For multi-tool turns, show a compact summary: `🔧 bash → ✓ | read_file → ✓ | edit_file → ✓ (3 tools, 1.2s)` after all tool calls complete | S |
| 3.4 | **Diff-aware edit_file display** — When `edit_file` succeeds, show a colored unified diff of the change instead of just `✓ edit_file: path` | M |
| 3.5 | **Permission prompt enhancement** — Style the approval prompt with box drawing, color the tool name, show a one-line summary of what the tool will do | S |
### Phase 4: Enhanced Slash Commands & Navigation
**Goal**: Improve information display and add missing features.
| Task | Description | Effort |
|---|---|---|
| 4.1 | **Colored `/diff` output** — Parse the git diff and render it with red/green coloring for removals/additions, similar to `delta` or `diff-so-fancy` | M |
| 4.2 | **Pager for long outputs** — When `/status`, `/config`, `/memory`, or `/diff` produce output longer than the terminal height, pipe through an internal pager (scroll with j/k/q) or external `$PAGER` | M |
| 4.3 | **`/search` command** — Add a new command to search conversation history by keyword | M |
| 4.4 | **`/undo` command** — Undo the last file edit by restoring from the `originalFile` data in `write_file`/`edit_file` tool results | M |
| 4.5 | **Interactive session picker** — Replace the text-based `/session list` with an interactive fuzzy-filterable list (up/down arrows to select, enter to switch) | L |
| 4.6 | **Tab completion for tool arguments** — Extend `SlashCommandHelper` to complete file paths after `/export`, model names after `/model`, session IDs after `/session switch` | M |
### Phase 5: Color Themes & Configuration
**Goal**: User-customizable visual appearance.
| Task | Description | Effort |
|---|---|---|
| 5.1 | **Named color themes** — Add `dark` (current default), `light`, `solarized`, `catppuccin` themes. Wire to the existing `Config` tool's `theme` setting | M |
| 5.2 | **ANSI-256 / truecolor detection** — Detect terminal capabilities and fall back gracefully (no colors → 16 colors → 256 → truecolor) | M |
| 5.3 | **Configurable spinner style** — Allow choosing between braille dots, bar, moon phases, etc. | S |
| 5.4 | **Banner customization** — Make the ASCII art banner optional or configurable via settings | S |
### Phase 6: Full-Screen TUI Mode (Stretch)
**Goal**: Optional alternate-screen layout for power users.
| Task | Description | Effort |
|---|---|---|
| 6.1 | **Add `ratatui` dependency** — Introduce `ratatui` (terminal UI framework) as an optional dependency for the full-screen mode | S |
| 6.2 | **Split-pane layout** — Top pane: conversation with scrollback; Bottom pane: input area; Right sidebar (optional): tool status/todo list | XL |
| 6.3 | **Scrollable conversation view** — Navigate past messages with PgUp/PgDn, search within conversation | L |
| 6.4 | **Keyboard shortcuts panel** — Show `?` help overlay with all keybindings | M |
| 6.5 | **Mouse support** — Click to expand tool results, scroll conversation, select text for copy | L |
---
## 3. Priority Recommendation
### Immediate (High Impact, Moderate Effort)
1. **Phase 0** — Essential cleanup. The 3,159-line `main.rs` is the #1 maintenance risk and blocks clean TUI additions.
2. **Phase 1.11.2** — Status bar with live tokens. Highest-impact UX win: users constantly want to know token usage.
3. **Phase 2.4** — Remove artificial delay. Low effort, immediately noticeable improvement.
4. **Phase 3.1** — Collapsible tool output. Large bash outputs currently wreck readability.
### Near-Term (Next Sprint)
5. **Phase 2.1** — Live markdown rendering. Makes the core interaction feel polished.
6. **Phase 3.2** — Syntax-highlighted tool results.
7. **Phase 3.4** — Diff-aware edit display.
8. **Phase 4.1** — Colored diff for `/diff`.
### Longer-Term
9. **Phase 5** — Color themes (user demand-driven).
10. **Phase 4.24.6** — Enhanced navigation and commands.
11. **Phase 6** — Full-screen mode (major undertaking, evaluate after earlier phases ship).
---
## 4. Architecture Recommendations
### Module Structure After Phase 0
```
crates/rusty-claude-cli/src/
├── main.rs # Entrypoint, arg dispatch only (~100 lines)
├── args.rs # CLI argument parsing (consolidate existing two parsers)
├── app.rs # LiveCli struct, REPL loop, turn execution
├── format.rs # All report formatting (status, cost, model, permissions, etc.)
├── session_mgr.rs # Session CRUD: create, resume, list, switch, persist
├── init.rs # Repo initialization (unchanged)
├── input.rs # Line editor (unchanged, minor extensions)
├── render.rs # TerminalRenderer, Spinner (extended)
└── tui/
├── mod.rs # TUI module root
├── status_bar.rs # Persistent bottom status line
├── tool_panel.rs # Tool call visualization (boxes, timelines, collapsible)
├── diff_view.rs # Colored diff rendering
├── pager.rs # Internal pager for long outputs
└── theme.rs # Color theme definitions and selection
```
### Key Design Principles
1. **Keep the inline REPL as the default** — Full-screen TUI should be opt-in (`--tui` flag)
2. **Everything testable without a terminal** — All formatting functions take `&mut impl Write`, never assume stdout directly
3. **Streaming-first** — Rendering should work incrementally, not buffering the entire response
4. **Respect `crossterm` for all terminal control** — Don't mix raw ANSI escape codes with crossterm (the current codebase does this in the startup banner)
5. **Feature-gate heavy dependencies**`ratatui` should be behind a `full-tui` feature flag
---
## 5. Risk Assessment
| Risk | Mitigation |
|---|---|
| Breaking the working REPL during refactor | Phase 0 is pure restructuring with existing test coverage as safety net |
| Terminal compatibility issues (tmux, SSH, Windows) | Rely on crossterm's abstraction; test in degraded environments |
| Performance regression with rich rendering | Profile before/after; keep the fast path (raw streaming) always available |
| Scope creep into Phase 6 | Ship Phases 03 as a coherent release before starting Phase 6 |
| `app.rs` vs `main.rs` confusion | Phase 0.2 explicitly resolves this by removing the legacy `CliApp` |
---
*Generated: 2026-03-31 | Workspace: `rust/` | Branch: `dev/rust`*

View File

@@ -10,7 +10,6 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus
runtime = { path = "../runtime" } runtime = { path = "../runtime" }
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
telemetry = { path = "../telemetry" }
tokio = { version = "1", features = ["io-util", "macros", "net", "rt-multi-thread", "time"] } tokio = { version = "1", features = ["io-util", "macros", "net", "rt-multi-thread", "time"] }
[lints] [lints]

View File

@@ -2,19 +2,17 @@ use std::collections::VecDeque;
use std::time::{Duration, SystemTime, UNIX_EPOCH}; use std::time::{Duration, SystemTime, UNIX_EPOCH};
use runtime::{ use runtime::{
format_usd, load_oauth_credentials, pricing_for_model, save_oauth_credentials, OAuthConfig, load_oauth_credentials, save_oauth_credentials, OAuthConfig, OAuthRefreshRequest,
OAuthRefreshRequest, OAuthTokenExchangeRequest, OAuthTokenExchangeRequest,
}; };
use serde::Deserialize; use serde::Deserialize;
use serde_json::{Map, Value};
use telemetry::{AnalyticsEvent, AnthropicRequestProfile, ClientIdentity, SessionTracer};
use crate::error::ApiError; use crate::error::ApiError;
use crate::sse::SseParser; use crate::sse::SseParser;
use crate::types::{MessageRequest, MessageResponse, StreamEvent}; use crate::types::{MessageRequest, MessageResponse, StreamEvent};
const DEFAULT_BASE_URL: &str = "https://api.anthropic.com"; const DEFAULT_BASE_URL: &str = "https://api.anthropic.com";
const MESSAGES_PATH: &str = "/v1/messages"; const ANTHROPIC_VERSION: &str = "2023-06-01";
const REQUEST_ID_HEADER: &str = "request-id"; const REQUEST_ID_HEADER: &str = "request-id";
const ALT_REQUEST_ID_HEADER: &str = "x-request-id"; const ALT_REQUEST_ID_HEADER: &str = "x-request-id";
const DEFAULT_INITIAL_BACKOFF: Duration = Duration::from_millis(200); const DEFAULT_INITIAL_BACKOFF: Duration = Duration::from_millis(200);
@@ -110,8 +108,6 @@ pub struct AnthropicClient {
max_retries: u32, max_retries: u32,
initial_backoff: Duration, initial_backoff: Duration,
max_backoff: Duration, max_backoff: Duration,
request_profile: AnthropicRequestProfile,
session_tracer: Option<SessionTracer>,
} }
impl AnthropicClient { impl AnthropicClient {
@@ -124,8 +120,6 @@ impl AnthropicClient {
max_retries: DEFAULT_MAX_RETRIES, max_retries: DEFAULT_MAX_RETRIES,
initial_backoff: DEFAULT_INITIAL_BACKOFF, initial_backoff: DEFAULT_INITIAL_BACKOFF,
max_backoff: DEFAULT_MAX_BACKOFF, max_backoff: DEFAULT_MAX_BACKOFF,
request_profile: AnthropicRequestProfile::default(),
session_tracer: None,
} }
} }
@@ -138,8 +132,6 @@ impl AnthropicClient {
max_retries: DEFAULT_MAX_RETRIES, max_retries: DEFAULT_MAX_RETRIES,
initial_backoff: DEFAULT_INITIAL_BACKOFF, initial_backoff: DEFAULT_INITIAL_BACKOFF,
max_backoff: DEFAULT_MAX_BACKOFF, max_backoff: DEFAULT_MAX_BACKOFF,
request_profile: AnthropicRequestProfile::default(),
session_tracer: None,
} }
} }
@@ -184,39 +176,6 @@ impl AnthropicClient {
self self
} }
#[must_use]
pub fn with_request_profile(mut self, request_profile: AnthropicRequestProfile) -> Self {
self.request_profile = request_profile;
self
}
#[must_use]
pub fn with_client_identity(mut self, client_identity: ClientIdentity) -> Self {
self.request_profile.client_identity = client_identity;
self
}
#[must_use]
pub fn with_beta(mut self, beta: impl Into<String>) -> Self {
let beta = beta.into();
if !self.request_profile.betas.contains(&beta) {
self.request_profile.betas.push(beta);
}
self
}
#[must_use]
pub fn with_extra_body_param(mut self, key: impl Into<String>, value: Value) -> Self {
self.request_profile.extra_body.insert(key.into(), value);
self
}
#[must_use]
pub fn with_session_tracer(mut self, session_tracer: SessionTracer) -> Self {
self.session_tracer = Some(session_tracer);
self
}
#[must_use] #[must_use]
pub fn with_retry_policy( pub fn with_retry_policy(
mut self, mut self,
@@ -252,7 +211,6 @@ impl AnthropicClient {
if response.request_id.is_none() { if response.request_id.is_none() {
response.request_id = request_id; response.request_id = request_id;
} }
self.record_response_usage(&response);
Ok(response) Ok(response)
} }
@@ -321,30 +279,18 @@ impl AnthropicClient {
loop { loop {
attempts += 1; attempts += 1;
self.record_request_started(request, attempts);
match self.send_raw_request(request).await { match self.send_raw_request(request).await {
Ok(response) => match expect_success(response).await { Ok(response) => match expect_success(response).await {
Ok(response) => { Ok(response) => return Ok(response),
self.record_request_succeeded(request, attempts, &response);
return Ok(response);
}
Err(error) if error.is_retryable() && attempts <= self.max_retries + 1 => { Err(error) if error.is_retryable() && attempts <= self.max_retries + 1 => {
self.record_request_failed(request, attempts, &error);
last_error = Some(error); last_error = Some(error);
} }
Err(error) => { Err(error) => return Err(error),
self.record_request_failed(request, attempts, &error);
return Err(error);
}
}, },
Err(error) if error.is_retryable() && attempts <= self.max_retries + 1 => { Err(error) if error.is_retryable() && attempts <= self.max_retries + 1 => {
self.record_request_failed(request, attempts, &error);
last_error = Some(error); last_error = Some(error);
} }
Err(error) => { Err(error) => return Err(error),
self.record_request_failed(request, attempts, &error);
return Err(error);
}
} }
if attempts > self.max_retries { if attempts > self.max_retries {
@@ -364,213 +310,18 @@ impl AnthropicClient {
&self, &self,
request: &MessageRequest, request: &MessageRequest,
) -> Result<reqwest::Response, ApiError> { ) -> Result<reqwest::Response, ApiError> {
let request_url = format!("{}{}", self.base_url.trim_end_matches('/'), MESSAGES_PATH); let request_url = format!("{}/v1/messages", self.base_url.trim_end_matches('/'));
let mut request_builder = self let request_builder = self
.http .http
.post(&request_url) .post(&request_url)
.header("anthropic-version", ANTHROPIC_VERSION)
.header("content-type", "application/json"); .header("content-type", "application/json");
for (name, value) in self.request_profile.header_pairs() {
request_builder = request_builder.header(name, value);
}
let mut request_builder = self.auth.apply(request_builder); let mut request_builder = self.auth.apply(request_builder);
let request_body = self.request_profile.render_json_body(request)?; request_builder = request_builder.json(request);
request_builder = request_builder.json(&request_body);
request_builder.send().await.map_err(ApiError::from) request_builder.send().await.map_err(ApiError::from)
} }
fn record_request_started(&self, request: &MessageRequest, attempt: u32) {
if let Some(tracer) = &self.session_tracer {
tracer.record_http_request_started(
attempt,
"POST",
MESSAGES_PATH,
self.request_attributes(request),
);
}
}
fn record_request_succeeded(
&self,
request: &MessageRequest,
attempt: u32,
response: &reqwest::Response,
) {
if let Some(tracer) = &self.session_tracer {
tracer.record_http_request_succeeded(
attempt,
"POST",
MESSAGES_PATH,
response.status().as_u16(),
request_id_from_headers(response.headers()),
self.request_attributes(request),
);
}
}
fn record_request_failed(&self, request: &MessageRequest, attempt: u32, error: &ApiError) {
if let Some(tracer) = &self.session_tracer {
tracer.record_http_request_failed(
attempt,
"POST",
MESSAGES_PATH,
error.to_string(),
error.is_retryable(),
self.error_attributes(request, error),
);
}
}
fn record_response_usage(&self, response: &MessageResponse) {
let Some(tracer) = &self.session_tracer else {
return;
};
let cost = response.usage.estimated_cost_usd(&response.model);
let pricing_source = if pricing_for_model(&response.model).is_some() {
"model-specific"
} else {
"default-sonnet"
};
let mut properties = Map::new();
properties.insert("model".to_string(), Value::String(response.model.clone()));
properties.insert(
"pricing_source".to_string(),
Value::String(pricing_source.to_string()),
);
properties.insert(
"input_tokens".to_string(),
Value::from(response.usage.input_tokens),
);
properties.insert(
"output_tokens".to_string(),
Value::from(response.usage.output_tokens),
);
properties.insert(
"cache_creation_input_tokens".to_string(),
Value::from(response.usage.cache_creation_input_tokens),
);
properties.insert(
"cache_read_input_tokens".to_string(),
Value::from(response.usage.cache_read_input_tokens),
);
properties.insert(
"total_tokens".to_string(),
Value::from(response.usage.total_tokens()),
);
properties.insert(
"estimated_cost_usd".to_string(),
Value::String(format_usd(cost.total_cost_usd())),
);
properties.insert(
"estimated_input_cost_usd".to_string(),
Value::String(format_usd(cost.input_cost_usd)),
);
properties.insert(
"estimated_output_cost_usd".to_string(),
Value::String(format_usd(cost.output_cost_usd)),
);
properties.insert(
"estimated_cache_creation_cost_usd".to_string(),
Value::String(format_usd(cost.cache_creation_cost_usd)),
);
properties.insert(
"estimated_cache_read_cost_usd".to_string(),
Value::String(format_usd(cost.cache_read_cost_usd)),
);
if let Some(request_id) = &response.request_id {
properties.insert("request_id".to_string(), Value::String(request_id.clone()));
}
tracer.record_analytics(AnalyticsEvent {
namespace: "api".to_string(),
action: "message_usage".to_string(),
properties,
});
}
fn request_attributes(&self, request: &MessageRequest) -> Map<String, Value> {
let mut attributes = Map::new();
attributes.insert("model".to_string(), Value::String(request.model.clone()));
attributes.insert("stream".to_string(), Value::Bool(request.stream));
attributes.insert("max_tokens".to_string(), Value::from(request.max_tokens));
attributes.insert(
"message_count".to_string(),
Value::from(u64::try_from(request.messages.len()).unwrap_or(u64::MAX)),
);
attributes.insert(
"tool_count".to_string(),
Value::from(
u64::try_from(request.tools.as_ref().map_or(0, Vec::len)).unwrap_or(u64::MAX),
),
);
attributes.insert(
"beta_count".to_string(),
Value::from(u64::try_from(self.request_profile.betas.len()).unwrap_or(u64::MAX)),
);
if !self.request_profile.betas.is_empty() {
attributes.insert(
"betas".to_string(),
Value::Array(
self.request_profile
.betas
.iter()
.cloned()
.map(Value::String)
.collect(),
),
);
}
if !self.request_profile.extra_body.is_empty() {
attributes.insert(
"extra_body_keys".to_string(),
Value::Array(
self.request_profile
.extra_body
.keys()
.cloned()
.map(Value::String)
.collect(),
),
);
}
attributes
}
fn error_attributes(&self, request: &MessageRequest, error: &ApiError) -> Map<String, Value> {
let mut attributes = self.request_attributes(request);
match error {
ApiError::Api {
status,
error_type,
message,
..
} => {
attributes.insert("status".to_string(), Value::from(status.as_u16()));
if let Some(error_type) = error_type {
attributes.insert("error_type".to_string(), Value::String(error_type.clone()));
}
if let Some(message) = message {
attributes.insert("api_message".to_string(), Value::String(message.clone()));
}
}
ApiError::Http(_) => {
attributes.insert("error_type".to_string(), Value::String("http".to_string()));
}
ApiError::Json(_) => {
attributes.insert("error_type".to_string(), Value::String("json".to_string()));
}
_ => {
attributes.insert(
"error_type".to_string(),
Value::String("client".to_string()),
);
}
}
attributes
}
fn backoff_for_attempt(&self, attempt: u32) -> Result<Duration, ApiError> { fn backoff_for_attempt(&self, attempt: u32) -> Result<Duration, ApiError> {
let Some(multiplier) = 1_u32.checked_shl(attempt.saturating_sub(1)) else { let Some(multiplier) = 1_u32.checked_shl(attempt.saturating_sub(1)) else {
return Err(ApiError::BackoffOverflow { return Err(ApiError::BackoffOverflow {

View File

@@ -15,9 +15,3 @@ pub use types::{
MessageResponse, MessageStartEvent, MessageStopEvent, OutputContentBlock, StreamEvent, MessageResponse, MessageStartEvent, MessageStopEvent, OutputContentBlock, StreamEvent,
ToolChoice, ToolDefinition, ToolResultContentBlock, Usage, ToolChoice, ToolDefinition, ToolResultContentBlock, Usage,
}; };
pub use telemetry::{
AnalyticsEvent, AnthropicRequestProfile, ClientIdentity, JsonlTelemetrySink,
MemoryTelemetrySink, SessionTraceRecord, SessionTracer, TelemetryEvent, TelemetrySink,
DEFAULT_ANTHROPIC_VERSION,
};

View File

@@ -1,4 +1,3 @@
use runtime::{pricing_for_model, TokenUsage, UsageCostEstimate};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
@@ -151,29 +150,7 @@ pub struct Usage {
impl Usage { impl Usage {
#[must_use] #[must_use]
pub const fn total_tokens(&self) -> u32 { pub const fn total_tokens(&self) -> u32 {
self.input_tokens self.input_tokens + self.output_tokens
+ self.output_tokens
+ self.cache_creation_input_tokens
+ self.cache_read_input_tokens
}
#[must_use]
pub const fn token_usage(&self) -> TokenUsage {
TokenUsage {
input_tokens: self.input_tokens,
output_tokens: self.output_tokens,
cache_creation_input_tokens: self.cache_creation_input_tokens,
cache_read_input_tokens: self.cache_read_input_tokens,
}
}
#[must_use]
pub fn estimated_cost_usd(&self, model: &str) -> UsageCostEstimate {
let usage = self.token_usage();
pricing_for_model(model).map_or_else(
|| usage.estimate_cost_usd(),
|pricing| usage.estimate_cost_usd_with_pricing(pricing),
)
} }
} }
@@ -233,47 +210,3 @@ pub enum StreamEvent {
ContentBlockStop(ContentBlockStopEvent), ContentBlockStop(ContentBlockStopEvent),
MessageStop(MessageStopEvent), MessageStop(MessageStopEvent),
} }
#[cfg(test)]
mod tests {
use runtime::format_usd;
use super::{MessageResponse, Usage};
#[test]
fn usage_total_tokens_includes_cache_tokens() {
let usage = Usage {
input_tokens: 10,
cache_creation_input_tokens: 2,
cache_read_input_tokens: 3,
output_tokens: 4,
};
assert_eq!(usage.total_tokens(), 19);
assert_eq!(usage.token_usage().total_tokens(), 19);
}
#[test]
fn message_response_estimates_cost_from_model_usage() {
let response = MessageResponse {
id: "msg_cost".to_string(),
kind: "message".to_string(),
role: "assistant".to_string(),
content: Vec::new(),
model: "claude-sonnet-4-20250514".to_string(),
stop_reason: Some("end_turn".to_string()),
stop_sequence: None,
usage: Usage {
input_tokens: 1_000_000,
cache_creation_input_tokens: 100_000,
cache_read_input_tokens: 200_000,
output_tokens: 500_000,
},
request_id: None,
};
let cost = response.usage.estimated_cost_usd(&response.model);
assert_eq!(format_usd(cost.total_cost_usd()), "$54.6750");
assert_eq!(response.total_tokens(), 1_800_000);
}
}

View File

@@ -8,7 +8,6 @@ use api::{
StreamEvent, ToolChoice, ToolDefinition, StreamEvent, ToolChoice, ToolDefinition,
}; };
use serde_json::json; use serde_json::json;
use telemetry::{ClientIdentity, MemoryTelemetrySink, SessionTracer, TelemetryEvent};
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tokio::sync::Mutex; use tokio::sync::Mutex;
@@ -65,18 +64,6 @@ async fn send_message_posts_json_and_parses_response() {
request.headers.get("authorization").map(String::as_str), request.headers.get("authorization").map(String::as_str),
Some("Bearer proxy-token") Some("Bearer proxy-token")
); );
assert_eq!(
request.headers.get("anthropic-version").map(String::as_str),
Some("2023-06-01")
);
assert_eq!(
request.headers.get("user-agent").map(String::as_str),
Some("claude-code/0.1.0")
);
assert_eq!(
request.headers.get("anthropic-beta").map(String::as_str),
Some("claude-code-20250219,prompt-caching-scope-2026-01-05")
);
let body: serde_json::Value = let body: serde_json::Value =
serde_json::from_str(&request.body).expect("request body should be json"); serde_json::from_str(&request.body).expect("request body should be json");
assert_eq!( assert_eq!(
@@ -86,115 +73,6 @@ async fn send_message_posts_json_and_parses_response() {
assert!(body.get("stream").is_none()); assert!(body.get("stream").is_none());
assert_eq!(body["tools"][0]["name"], json!("get_weather")); assert_eq!(body["tools"][0]["name"], json!("get_weather"));
assert_eq!(body["tool_choice"]["type"], json!("auto")); assert_eq!(body["tool_choice"]["type"], json!("auto"));
assert_eq!(
body["betas"],
json!(["claude-code-20250219", "prompt-caching-scope-2026-01-05"])
);
}
#[tokio::test]
async fn send_message_applies_request_profile_and_records_telemetry() {
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
let server = spawn_server(
state.clone(),
vec![http_response_with_headers(
"200 OK",
"application/json",
concat!(
"{",
"\"id\":\"msg_profile\",",
"\"type\":\"message\",",
"\"role\":\"assistant\",",
"\"content\":[{\"type\":\"text\",\"text\":\"ok\"}],",
"\"model\":\"claude-3-7-sonnet-latest\",",
"\"stop_reason\":\"end_turn\",",
"\"stop_sequence\":null,",
"\"usage\":{\"input_tokens\":1,\"cache_creation_input_tokens\":2,\"cache_read_input_tokens\":3,\"output_tokens\":1}",
"}"
),
&[("request-id", "req_profile_123")],
)],
)
.await;
let sink = Arc::new(MemoryTelemetrySink::default());
let client = AnthropicClient::new("test-key")
.with_base_url(server.base_url())
.with_client_identity(ClientIdentity::new("claude-code", "9.9.9").with_runtime("rust-cli"))
.with_beta("tools-2026-04-01")
.with_extra_body_param("metadata", json!({"source": "clawd-code"}))
.with_session_tracer(SessionTracer::new("session-telemetry", sink.clone()));
let response = client
.send_message(&sample_request(false))
.await
.expect("request should succeed");
assert_eq!(response.request_id.as_deref(), Some("req_profile_123"));
let captured = state.lock().await;
let request = captured.first().expect("server should capture request");
assert_eq!(
request.headers.get("anthropic-beta").map(String::as_str),
Some("claude-code-20250219,prompt-caching-scope-2026-01-05,tools-2026-04-01")
);
assert_eq!(
request.headers.get("user-agent").map(String::as_str),
Some("claude-code/9.9.9")
);
let body: serde_json::Value =
serde_json::from_str(&request.body).expect("request body should be json");
assert_eq!(body["metadata"]["source"], json!("clawd-code"));
assert_eq!(
body["betas"],
json!([
"claude-code-20250219",
"prompt-caching-scope-2026-01-05",
"tools-2026-04-01"
])
);
let events = sink.events();
assert_eq!(events.len(), 6);
assert!(matches!(
&events[0],
TelemetryEvent::HttpRequestStarted {
session_id,
attempt: 1,
method,
path,
..
} if session_id == "session-telemetry" && method == "POST" && path == "/v1/messages"
));
assert!(matches!(
&events[1],
TelemetryEvent::SessionTrace(trace) if trace.name == "http_request_started"
));
assert!(matches!(
&events[2],
TelemetryEvent::HttpRequestSucceeded {
request_id,
status: 200,
..
} if request_id.as_deref() == Some("req_profile_123")
));
assert!(matches!(
&events[3],
TelemetryEvent::SessionTrace(trace) if trace.name == "http_request_succeeded"
));
assert!(matches!(
&events[4],
TelemetryEvent::Analytics(event)
if event.namespace == "api"
&& event.action == "message_usage"
&& event.properties.get("request_id") == Some(&json!("req_profile_123"))
&& event.properties.get("total_tokens") == Some(&json!(7))
&& event.properties.get("estimated_cost_usd") == Some(&json!("$0.0001"))
));
assert!(matches!(
&events[5],
TelemetryEvent::SessionTrace(trace) if trace.name == "analytics"
));
} }
#[tokio::test] #[tokio::test]

View File

@@ -11,7 +11,6 @@ glob = "0.3"
regex = "1" regex = "1"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
telemetry = { path = "../telemetry" }
tokio = { version = "1", features = ["io-util", "macros", "process", "rt", "rt-multi-thread", "time"] } tokio = { version = "1", features = ["io-util", "macros", "process", "rt", "rt-multi-thread", "time"] }
walkdir = "2" walkdir = "2"

View File

@@ -37,7 +37,6 @@ pub struct RuntimeConfig {
#[derive(Debug, Clone, PartialEq, Eq, Default)] #[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RuntimeFeatureConfig { pub struct RuntimeFeatureConfig {
hooks: RuntimeHookConfig,
mcp: McpConfigCollection, mcp: McpConfigCollection,
oauth: Option<OAuthConfig>, oauth: Option<OAuthConfig>,
model: Option<String>, model: Option<String>,
@@ -45,12 +44,6 @@ pub struct RuntimeFeatureConfig {
sandbox: SandboxConfig, sandbox: SandboxConfig,
} }
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RuntimeHookConfig {
pre_tool_use: Vec<String>,
post_tool_use: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default)] #[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct McpConfigCollection { pub struct McpConfigCollection {
servers: BTreeMap<String, ScopedMcpServerConfig>, servers: BTreeMap<String, ScopedMcpServerConfig>,
@@ -228,7 +221,6 @@ impl ConfigLoader {
let merged_value = JsonValue::Object(merged.clone()); let merged_value = JsonValue::Object(merged.clone());
let feature_config = RuntimeFeatureConfig { let feature_config = RuntimeFeatureConfig {
hooks: parse_optional_hooks_config(&merged_value)?,
mcp: McpConfigCollection { mcp: McpConfigCollection {
servers: mcp_servers, servers: mcp_servers,
}, },
@@ -286,11 +278,6 @@ impl RuntimeConfig {
&self.feature_config.mcp &self.feature_config.mcp
} }
#[must_use]
pub fn hooks(&self) -> &RuntimeHookConfig {
&self.feature_config.hooks
}
#[must_use] #[must_use]
pub fn oauth(&self) -> Option<&OAuthConfig> { pub fn oauth(&self) -> Option<&OAuthConfig> {
self.feature_config.oauth.as_ref() self.feature_config.oauth.as_ref()
@@ -313,17 +300,6 @@ impl RuntimeConfig {
} }
impl RuntimeFeatureConfig { impl RuntimeFeatureConfig {
#[must_use]
pub fn with_hooks(mut self, hooks: RuntimeHookConfig) -> Self {
self.hooks = hooks;
self
}
#[must_use]
pub fn hooks(&self) -> &RuntimeHookConfig {
&self.hooks
}
#[must_use] #[must_use]
pub fn mcp(&self) -> &McpConfigCollection { pub fn mcp(&self) -> &McpConfigCollection {
&self.mcp &self.mcp
@@ -350,26 +326,6 @@ impl RuntimeFeatureConfig {
} }
} }
impl RuntimeHookConfig {
#[must_use]
pub fn new(pre_tool_use: Vec<String>, post_tool_use: Vec<String>) -> Self {
Self {
pre_tool_use,
post_tool_use,
}
}
#[must_use]
pub fn pre_tool_use(&self) -> &[String] {
&self.pre_tool_use
}
#[must_use]
pub fn post_tool_use(&self) -> &[String] {
&self.post_tool_use
}
}
impl McpConfigCollection { impl McpConfigCollection {
#[must_use] #[must_use]
pub fn servers(&self) -> &BTreeMap<String, ScopedMcpServerConfig> { pub fn servers(&self) -> &BTreeMap<String, ScopedMcpServerConfig> {
@@ -468,22 +424,6 @@ fn parse_optional_model(root: &JsonValue) -> Option<String> {
.map(ToOwned::to_owned) .map(ToOwned::to_owned)
} }
fn parse_optional_hooks_config(root: &JsonValue) -> Result<RuntimeHookConfig, ConfigError> {
let Some(object) = root.as_object() else {
return Ok(RuntimeHookConfig::default());
};
let Some(hooks_value) = object.get("hooks") else {
return Ok(RuntimeHookConfig::default());
};
let hooks = expect_object(hooks_value, "merged settings.hooks")?;
Ok(RuntimeHookConfig {
pre_tool_use: optional_string_array(hooks, "PreToolUse", "merged settings.hooks")?
.unwrap_or_default(),
post_tool_use: optional_string_array(hooks, "PostToolUse", "merged settings.hooks")?
.unwrap_or_default(),
})
}
fn parse_optional_permission_mode( fn parse_optional_permission_mode(
root: &JsonValue, root: &JsonValue,
) -> Result<Option<ResolvedPermissionMode>, ConfigError> { ) -> Result<Option<ResolvedPermissionMode>, ConfigError> {
@@ -896,8 +836,6 @@ mod tests {
.and_then(JsonValue::as_object) .and_then(JsonValue::as_object)
.expect("hooks object") .expect("hooks object")
.contains_key("PostToolUse")); .contains_key("PostToolUse"));
assert_eq!(loaded.hooks().pre_tool_use(), &["base".to_string()]);
assert_eq!(loaded.hooks().post_tool_use(), &["project".to_string()]);
assert!(loaded.mcp().get("home").is_some()); assert!(loaded.mcp().get("home").is_some());
assert!(loaded.mcp().get("project").is_some()); assert!(loaded.mcp().get("project").is_some());

View File

@@ -1,14 +1,9 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use std::fmt::{Display, Formatter}; use std::fmt::{Display, Formatter};
use serde_json::{Map, Value};
use telemetry::SessionTracer;
use crate::compact::{ use crate::compact::{
compact_session, estimate_session_tokens, CompactionConfig, CompactionResult, compact_session, estimate_session_tokens, CompactionConfig, CompactionResult,
}; };
use crate::config::RuntimeFeatureConfig;
use crate::hooks::{HookRunResult, HookRunner};
use crate::permissions::{PermissionOutcome, PermissionPolicy, PermissionPrompter}; use crate::permissions::{PermissionOutcome, PermissionPolicy, PermissionPrompter};
use crate::session::{ContentBlock, ConversationMessage, Session}; use crate::session::{ContentBlock, ConversationMessage, Session};
use crate::usage::{TokenUsage, UsageTracker}; use crate::usage::{TokenUsage, UsageTracker};
@@ -99,8 +94,6 @@ pub struct ConversationRuntime<C, T> {
system_prompt: Vec<String>, system_prompt: Vec<String>,
max_iterations: usize, max_iterations: usize,
usage_tracker: UsageTracker, usage_tracker: UsageTracker,
hook_runner: HookRunner,
session_tracer: Option<SessionTracer>,
} }
impl<C, T> ConversationRuntime<C, T> impl<C, T> ConversationRuntime<C, T>
@@ -115,25 +108,6 @@ where
tool_executor: T, tool_executor: T,
permission_policy: PermissionPolicy, permission_policy: PermissionPolicy,
system_prompt: Vec<String>, system_prompt: Vec<String>,
) -> Self {
Self::new_with_features(
session,
api_client,
tool_executor,
permission_policy,
system_prompt,
&RuntimeFeatureConfig::default(),
)
}
#[must_use]
pub fn new_with_features(
session: Session,
api_client: C,
tool_executor: T,
permission_policy: PermissionPolicy,
system_prompt: Vec<String>,
feature_config: &RuntimeFeatureConfig,
) -> Self { ) -> Self {
let usage_tracker = UsageTracker::from_session(&session); let usage_tracker = UsageTracker::from_session(&session);
Self { Self {
@@ -144,8 +118,6 @@ where
system_prompt, system_prompt,
max_iterations: usize::MAX, max_iterations: usize::MAX,
usage_tracker, usage_tracker,
hook_runner: HookRunner::from_feature_config(feature_config),
session_tracer: None,
} }
} }
@@ -155,23 +127,14 @@ where
self self
} }
#[must_use]
pub fn with_session_tracer(mut self, session_tracer: SessionTracer) -> Self {
self.session_tracer = Some(session_tracer);
self
}
#[allow(clippy::too_many_lines)]
pub fn run_turn( pub fn run_turn(
&mut self, &mut self,
user_input: impl Into<String>, user_input: impl Into<String>,
mut prompter: Option<&mut dyn PermissionPrompter>, mut prompter: Option<&mut dyn PermissionPrompter>,
) -> Result<TurnSummary, RuntimeError> { ) -> Result<TurnSummary, RuntimeError> {
let user_input = user_input.into();
self.record_turn_started(&user_input);
self.session self.session
.messages .messages
.push(ConversationMessage::user_text(user_input)); .push(ConversationMessage::user_text(user_input.into()));
let mut assistant_messages = Vec::new(); let mut assistant_messages = Vec::new();
let mut tool_results = Vec::new(); let mut tool_results = Vec::new();
@@ -180,31 +143,17 @@ where
loop { loop {
iterations += 1; iterations += 1;
if iterations > self.max_iterations { if iterations > self.max_iterations {
let error = RuntimeError::new( return Err(RuntimeError::new(
"conversation loop exceeded the maximum number of iterations", "conversation loop exceeded the maximum number of iterations",
); ));
self.record_turn_failed(iterations, &error);
return Err(error);
} }
let request = ApiRequest { let request = ApiRequest {
system_prompt: self.system_prompt.clone(), system_prompt: self.system_prompt.clone(),
messages: self.session.messages.clone(), messages: self.session.messages.clone(),
}; };
let events = match self.api_client.stream(request) { let events = self.api_client.stream(request)?;
Ok(events) => events, let (assistant_message, usage) = build_assistant_message(events)?;
Err(error) => {
self.record_turn_failed(iterations, &error);
return Err(error);
}
};
let (assistant_message, usage) = match build_assistant_message(events) {
Ok(result) => result,
Err(error) => {
self.record_turn_failed(iterations, &error);
return Err(error);
}
};
if let Some(usage) = usage { if let Some(usage) = usage {
self.usage_tracker.record(usage); self.usage_tracker.record(usage);
} }
@@ -218,11 +167,6 @@ where
_ => None, _ => None,
}) })
.collect::<Vec<_>>(); .collect::<Vec<_>>();
self.record_assistant_iteration(
iterations,
&assistant_message,
pending_tool_uses.len(),
);
self.session.messages.push(assistant_message.clone()); self.session.messages.push(assistant_message.clone());
assistant_messages.push(assistant_message); assistant_messages.push(assistant_message);
@@ -232,7 +176,6 @@ where
} }
for (tool_use_id, tool_name, input) in pending_tool_uses { for (tool_use_id, tool_name, input) in pending_tool_uses {
self.record_tool_started(iterations, &tool_name);
let permission_outcome = if let Some(prompt) = prompter.as_mut() { let permission_outcome = if let Some(prompt) = prompter.as_mut() {
self.permission_policy self.permission_policy
.authorize(&tool_name, &input, Some(*prompt)) .authorize(&tool_name, &input, Some(*prompt))
@@ -242,61 +185,36 @@ where
let result_message = match permission_outcome { let result_message = match permission_outcome {
PermissionOutcome::Allow => { PermissionOutcome::Allow => {
let pre_hook_result = self.hook_runner.run_pre_tool_use(&tool_name, &input);
if pre_hook_result.is_denied() {
let deny_message = format!("PreToolUse hook denied tool `{tool_name}`");
ConversationMessage::tool_result(
tool_use_id,
tool_name,
format_hook_message(&pre_hook_result, &deny_message),
true,
)
} else {
let (mut output, mut is_error) =
match self.tool_executor.execute(&tool_name, &input) { match self.tool_executor.execute(&tool_name, &input) {
Ok(output) => (output, false), Ok(output) => ConversationMessage::tool_result(
Err(error) => (error.to_string(), true),
};
output = merge_hook_feedback(pre_hook_result.messages(), output, false);
let post_hook_result = self
.hook_runner
.run_post_tool_use(&tool_name, &input, &output, is_error);
if post_hook_result.is_denied() {
is_error = true;
}
output = merge_hook_feedback(
post_hook_result.messages(),
output,
post_hook_result.is_denied(),
);
ConversationMessage::tool_result(
tool_use_id, tool_use_id,
tool_name, tool_name,
output, output,
is_error, false,
) ),
Err(error) => ConversationMessage::tool_result(
tool_use_id,
tool_name,
error.to_string(),
true,
),
} }
} }
PermissionOutcome::Deny { reason } => { PermissionOutcome::Deny { reason } => {
ConversationMessage::tool_result(tool_use_id, tool_name, reason, true) ConversationMessage::tool_result(tool_use_id, tool_name, reason, true)
} }
}; };
self.record_tool_finished(iterations, &result_message);
self.session.messages.push(result_message.clone()); self.session.messages.push(result_message.clone());
tool_results.push(result_message); tool_results.push(result_message);
} }
} }
let summary = TurnSummary { Ok(TurnSummary {
assistant_messages, assistant_messages,
tool_results, tool_results,
iterations, iterations,
usage: self.usage_tracker.cumulative_usage(), usage: self.usage_tracker.cumulative_usage(),
}; })
self.record_turn_completed(&summary);
Ok(summary)
} }
#[must_use] #[must_use]
@@ -323,126 +241,6 @@ where
pub fn into_session(self) -> Session { pub fn into_session(self) -> Session {
self.session self.session
} }
fn record_turn_started(&self, user_input: &str) {
if let Some(tracer) = &self.session_tracer {
let mut attributes = Map::new();
attributes.insert(
"message_count_before".to_string(),
Value::from(u64::try_from(self.session.messages.len()).unwrap_or(u64::MAX)),
);
attributes.insert(
"input_chars".to_string(),
Value::from(u64::try_from(user_input.chars().count()).unwrap_or(u64::MAX)),
);
tracer.record("turn_started", attributes);
}
}
fn record_assistant_iteration(
&self,
iteration: usize,
assistant_message: &ConversationMessage,
pending_tool_count: usize,
) {
if let Some(tracer) = &self.session_tracer {
let mut attributes = Map::new();
attributes.insert(
"iteration".to_string(),
Value::from(u64::try_from(iteration).unwrap_or(u64::MAX)),
);
attributes.insert(
"block_count".to_string(),
Value::from(u64::try_from(assistant_message.blocks.len()).unwrap_or(u64::MAX)),
);
attributes.insert(
"pending_tool_count".to_string(),
Value::from(u64::try_from(pending_tool_count).unwrap_or(u64::MAX)),
);
tracer.record("assistant_iteration_completed", attributes);
}
}
fn record_tool_started(&self, iteration: usize, tool_name: &str) {
if let Some(tracer) = &self.session_tracer {
let mut attributes = Map::new();
attributes.insert(
"iteration".to_string(),
Value::from(u64::try_from(iteration).unwrap_or(u64::MAX)),
);
attributes.insert(
"tool_name".to_string(),
Value::String(tool_name.to_string()),
);
tracer.record("tool_execution_started", attributes);
}
}
fn record_tool_finished(&self, iteration: usize, result_message: &ConversationMessage) {
let Some(tracer) = &self.session_tracer else {
return;
};
let Some(ContentBlock::ToolResult {
tool_name,
is_error,
output,
..
}) = result_message.blocks.first()
else {
return;
};
let mut attributes = Map::new();
attributes.insert(
"iteration".to_string(),
Value::from(u64::try_from(iteration).unwrap_or(u64::MAX)),
);
attributes.insert("tool_name".to_string(), Value::String(tool_name.clone()));
attributes.insert("is_error".to_string(), Value::Bool(*is_error));
attributes.insert(
"output_chars".to_string(),
Value::from(u64::try_from(output.chars().count()).unwrap_or(u64::MAX)),
);
tracer.record("tool_execution_finished", attributes);
}
fn record_turn_completed(&self, summary: &TurnSummary) {
if let Some(tracer) = &self.session_tracer {
let mut attributes = Map::new();
attributes.insert(
"assistant_message_count".to_string(),
Value::from(u64::try_from(summary.assistant_messages.len()).unwrap_or(u64::MAX)),
);
attributes.insert(
"tool_result_count".to_string(),
Value::from(u64::try_from(summary.tool_results.len()).unwrap_or(u64::MAX)),
);
attributes.insert(
"iterations".to_string(),
Value::from(u64::try_from(summary.iterations).unwrap_or(u64::MAX)),
);
attributes.insert(
"total_input_tokens".to_string(),
Value::from(summary.usage.input_tokens),
);
attributes.insert(
"total_output_tokens".to_string(),
Value::from(summary.usage.output_tokens),
);
tracer.record("turn_completed", attributes);
}
}
fn record_turn_failed(&self, iteration: usize, error: &RuntimeError) {
if let Some(tracer) = &self.session_tracer {
let mut attributes = Map::new();
attributes.insert(
"iteration".to_string(),
Value::from(u64::try_from(iteration).unwrap_or(u64::MAX)),
);
attributes.insert("error".to_string(), Value::String(error.to_string()));
tracer.record("turn_failed", attributes);
}
}
} }
fn build_assistant_message( fn build_assistant_message(
@@ -492,32 +290,6 @@ fn flush_text_block(text: &mut String, blocks: &mut Vec<ContentBlock>) {
} }
} }
fn format_hook_message(result: &HookRunResult, fallback: &str) -> String {
if result.messages().is_empty() {
fallback.to_string()
} else {
result.messages().join("\n")
}
}
fn merge_hook_feedback(messages: &[String], output: String, denied: bool) -> String {
if messages.is_empty() {
return output;
}
let mut sections = Vec::new();
if !output.trim().is_empty() {
sections.push(output);
}
let label = if denied {
"Hook feedback (denied)"
} else {
"Hook feedback"
};
sections.push(format!("{label}:\n{}", messages.join("\n")));
sections.join("\n\n")
}
type ToolHandler = Box<dyn FnMut(&str) -> Result<String, ToolError>>; type ToolHandler = Box<dyn FnMut(&str) -> Result<String, ToolError>>;
#[derive(Default)] #[derive(Default)]
@@ -557,7 +329,6 @@ mod tests {
StaticToolExecutor, StaticToolExecutor,
}; };
use crate::compact::CompactionConfig; use crate::compact::CompactionConfig;
use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
use crate::permissions::{ use crate::permissions::{
PermissionMode, PermissionPolicy, PermissionPromptDecision, PermissionPrompter, PermissionMode, PermissionPolicy, PermissionPromptDecision, PermissionPrompter,
PermissionRequest, PermissionRequest,
@@ -566,8 +337,6 @@ mod tests {
use crate::session::{ContentBlock, MessageRole, Session}; use crate::session::{ContentBlock, MessageRole, Session};
use crate::usage::TokenUsage; use crate::usage::TokenUsage;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::Arc;
use telemetry::{MemoryTelemetrySink, SessionTracer, TelemetryEvent};
struct ScriptedApiClient { struct ScriptedApiClient {
call_count: usize, call_count: usize,
@@ -680,39 +449,6 @@ mod tests {
)); ));
} }
#[test]
fn records_runtime_session_trace_events() {
let sink = Arc::new(MemoryTelemetrySink::default());
let tracer = SessionTracer::new("session-runtime", sink.clone());
let mut runtime = ConversationRuntime::new(
Session::new(),
ScriptedApiClient { call_count: 0 },
StaticToolExecutor::new().register("add", |_input| Ok("4".to_string())),
PermissionPolicy::new(PermissionMode::WorkspaceWrite),
vec!["system".to_string()],
)
.with_session_tracer(tracer);
runtime
.run_turn("what is 2 + 2?", Some(&mut PromptAllowOnce))
.expect("conversation loop should succeed");
let events = sink.events();
let trace_names = events
.iter()
.filter_map(|event| match event {
TelemetryEvent::SessionTrace(trace) => Some(trace.name.as_str()),
_ => None,
})
.collect::<Vec<_>>();
assert!(trace_names.contains(&"turn_started"));
assert!(trace_names.contains(&"assistant_iteration_completed"));
assert!(trace_names.contains(&"tool_execution_started"));
assert!(trace_names.contains(&"tool_execution_finished"));
assert!(trace_names.contains(&"turn_completed"));
}
#[test] #[test]
fn records_denied_tool_results_when_prompt_rejects() { fn records_denied_tool_results_when_prompt_rejects() {
struct RejectPrompter; struct RejectPrompter;
@@ -767,141 +503,6 @@ mod tests {
)); ));
} }
#[test]
fn denies_tool_use_when_pre_tool_hook_blocks() {
struct SingleCallApiClient;
impl ApiClient for SingleCallApiClient {
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
if request
.messages
.iter()
.any(|message| message.role == MessageRole::Tool)
{
return Ok(vec![
AssistantEvent::TextDelta("blocked".to_string()),
AssistantEvent::MessageStop,
]);
}
Ok(vec![
AssistantEvent::ToolUse {
id: "tool-1".to_string(),
name: "blocked".to_string(),
input: r#"{"path":"secret.txt"}"#.to_string(),
},
AssistantEvent::MessageStop,
])
}
}
let mut runtime = ConversationRuntime::new_with_features(
Session::new(),
SingleCallApiClient,
StaticToolExecutor::new().register("blocked", |_input| {
panic!("tool should not execute when hook denies")
}),
PermissionPolicy::new(PermissionMode::DangerFullAccess),
vec!["system".to_string()],
&RuntimeFeatureConfig::default().with_hooks(RuntimeHookConfig::new(
vec![shell_snippet("printf 'blocked by hook'; exit 2")],
Vec::new(),
)),
);
let summary = runtime
.run_turn("use the tool", None)
.expect("conversation should continue after hook denial");
assert_eq!(summary.tool_results.len(), 1);
let ContentBlock::ToolResult {
is_error, output, ..
} = &summary.tool_results[0].blocks[0]
else {
panic!("expected tool result block");
};
assert!(
*is_error,
"hook denial should produce an error result: {output}"
);
assert!(
output.contains("denied tool") || output.contains("blocked by hook"),
"unexpected hook denial output: {output:?}"
);
}
#[test]
fn appends_post_tool_hook_feedback_to_tool_result() {
struct TwoCallApiClient {
calls: usize,
}
impl ApiClient for TwoCallApiClient {
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
self.calls += 1;
match self.calls {
1 => Ok(vec![
AssistantEvent::ToolUse {
id: "tool-1".to_string(),
name: "add".to_string(),
input: r#"{"lhs":2,"rhs":2}"#.to_string(),
},
AssistantEvent::MessageStop,
]),
2 => {
assert!(request
.messages
.iter()
.any(|message| message.role == MessageRole::Tool));
Ok(vec![
AssistantEvent::TextDelta("done".to_string()),
AssistantEvent::MessageStop,
])
}
_ => Err(RuntimeError::new("unexpected extra API call")),
}
}
}
let mut runtime = ConversationRuntime::new_with_features(
Session::new(),
TwoCallApiClient { calls: 0 },
StaticToolExecutor::new().register("add", |_input| Ok("4".to_string())),
PermissionPolicy::new(PermissionMode::DangerFullAccess),
vec!["system".to_string()],
&RuntimeFeatureConfig::default().with_hooks(RuntimeHookConfig::new(
vec![shell_snippet("printf 'pre hook ran'")],
vec![shell_snippet("printf 'post hook ran'")],
)),
);
let summary = runtime
.run_turn("use add", None)
.expect("tool loop succeeds");
assert_eq!(summary.tool_results.len(), 1);
let ContentBlock::ToolResult {
is_error, output, ..
} = &summary.tool_results[0].blocks[0]
else {
panic!("expected tool result block");
};
assert!(
!*is_error,
"post hook should preserve non-error result: {output:?}"
);
assert!(
output.contains('4'),
"tool output missing value: {output:?}"
);
assert!(
output.contains("pre hook ran"),
"tool output missing pre hook feedback: {output:?}"
);
assert!(
output.contains("post hook ran"),
"tool output missing post hook feedback: {output:?}"
);
}
#[test] #[test]
fn reconstructs_usage_tracker_from_restored_session() { fn reconstructs_usage_tracker_from_restored_session() {
struct SimpleApi; struct SimpleApi;
@@ -980,14 +581,4 @@ mod tests {
MessageRole::System MessageRole::System
); );
} }
#[cfg(windows)]
fn shell_snippet(script: &str) -> String {
script.replace('\'', "\"")
}
#[cfg(not(windows))]
fn shell_snippet(script: &str) -> String {
script.to_string()
}
} }

View File

@@ -1,353 +0,0 @@
use std::ffi::OsStr;
use std::process::Command;
use serde_json::json;
use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HookEvent {
PreToolUse,
PostToolUse,
}
impl HookEvent {
fn as_str(self) -> &'static str {
match self {
Self::PreToolUse => "PreToolUse",
Self::PostToolUse => "PostToolUse",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HookRunResult {
denied: bool,
messages: Vec<String>,
}
impl HookRunResult {
#[must_use]
pub fn allow(messages: Vec<String>) -> Self {
Self {
denied: false,
messages,
}
}
#[must_use]
pub fn is_denied(&self) -> bool {
self.denied
}
#[must_use]
pub fn messages(&self) -> &[String] {
&self.messages
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct HookRunner {
config: RuntimeHookConfig,
}
impl HookRunner {
#[must_use]
pub fn new(config: RuntimeHookConfig) -> Self {
Self { config }
}
#[must_use]
pub fn from_feature_config(feature_config: &RuntimeFeatureConfig) -> Self {
Self::new(feature_config.hooks().clone())
}
#[must_use]
pub fn run_pre_tool_use(&self, tool_name: &str, tool_input: &str) -> HookRunResult {
Self::run_commands(
HookEvent::PreToolUse,
self.config.pre_tool_use(),
tool_name,
tool_input,
None,
false,
)
}
#[must_use]
pub fn run_post_tool_use(
&self,
tool_name: &str,
tool_input: &str,
tool_output: &str,
is_error: bool,
) -> HookRunResult {
Self::run_commands(
HookEvent::PostToolUse,
self.config.post_tool_use(),
tool_name,
tool_input,
Some(tool_output),
is_error,
)
}
fn run_commands(
event: HookEvent,
commands: &[String],
tool_name: &str,
tool_input: &str,
tool_output: Option<&str>,
is_error: bool,
) -> HookRunResult {
if commands.is_empty() {
return HookRunResult::allow(Vec::new());
}
let payload = json!({
"hook_event_name": event.as_str(),
"tool_name": tool_name,
"tool_input": parse_tool_input(tool_input),
"tool_input_json": tool_input,
"tool_output": tool_output,
"tool_result_is_error": is_error,
})
.to_string();
let invocation = HookInvocation {
event,
tool_name,
tool_input,
tool_output,
is_error,
payload: &payload,
};
let mut messages = Vec::new();
for command in commands {
match Self::run_command(command, &invocation) {
HookCommandOutcome::Allow { message } => {
if let Some(message) = message {
messages.push(message);
}
}
HookCommandOutcome::Deny { message } => {
let message = message.unwrap_or_else(|| {
format!("{} hook denied tool `{tool_name}`", event.as_str())
});
messages.push(message);
return HookRunResult {
denied: true,
messages,
};
}
HookCommandOutcome::Warn { message } => messages.push(message),
}
}
HookRunResult::allow(messages)
}
fn run_command(command: &str, invocation: &HookInvocation<'_>) -> HookCommandOutcome {
let mut child = shell_command(command);
child.stdin(std::process::Stdio::piped());
child.stdout(std::process::Stdio::piped());
child.stderr(std::process::Stdio::piped());
child.env("HOOK_EVENT", invocation.event.as_str());
child.env("HOOK_TOOL_NAME", invocation.tool_name);
child.env("HOOK_TOOL_INPUT", invocation.tool_input);
child.env(
"HOOK_TOOL_IS_ERROR",
if invocation.is_error { "1" } else { "0" },
);
if let Some(tool_output) = invocation.tool_output {
child.env("HOOK_TOOL_OUTPUT", tool_output);
}
match child.output_with_stdin(invocation.payload.as_bytes()) {
Ok(output) => {
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let message = (!stdout.is_empty()).then_some(stdout);
match output.status.code() {
Some(0) => HookCommandOutcome::Allow { message },
Some(2) => HookCommandOutcome::Deny { message },
Some(code) => HookCommandOutcome::Warn {
message: format_hook_warning(
command,
code,
message.as_deref(),
stderr.as_str(),
),
},
None => HookCommandOutcome::Warn {
message: format!(
"{} hook `{command}` terminated by signal while handling `{}`",
invocation.event.as_str(),
invocation.tool_name
),
},
}
}
Err(error) => HookCommandOutcome::Warn {
message: format!(
"{} hook `{command}` failed to start for `{tool_name}`: {error}",
invocation.event.as_str(),
tool_name = invocation.tool_name
),
},
}
}
}
struct HookInvocation<'a> {
event: HookEvent,
tool_name: &'a str,
tool_input: &'a str,
tool_output: Option<&'a str>,
is_error: bool,
payload: &'a str,
}
enum HookCommandOutcome {
Allow { message: Option<String> },
Deny { message: Option<String> },
Warn { message: String },
}
fn parse_tool_input(tool_input: &str) -> serde_json::Value {
serde_json::from_str(tool_input).unwrap_or_else(|_| json!({ "raw": tool_input }))
}
fn format_hook_warning(command: &str, code: i32, stdout: Option<&str>, stderr: &str) -> String {
let mut message =
format!("Hook `{command}` exited with status {code}; allowing tool execution to continue");
if let Some(stdout) = stdout.filter(|stdout| !stdout.is_empty()) {
message.push_str(": ");
message.push_str(stdout);
} else if !stderr.is_empty() {
message.push_str(": ");
message.push_str(stderr);
}
message
}
fn shell_command(command: &str) -> CommandWithStdin {
#[cfg(windows)]
let mut command_builder = {
let mut command_builder = Command::new("cmd");
command_builder.arg("/C").arg(command);
CommandWithStdin::new(command_builder)
};
#[cfg(not(windows))]
let command_builder = {
let mut command_builder = Command::new("sh");
command_builder.arg("-lc").arg(command);
CommandWithStdin::new(command_builder)
};
command_builder
}
struct CommandWithStdin {
command: Command,
}
impl CommandWithStdin {
fn new(command: Command) -> Self {
Self { command }
}
fn stdin(&mut self, cfg: std::process::Stdio) -> &mut Self {
self.command.stdin(cfg);
self
}
fn stdout(&mut self, cfg: std::process::Stdio) -> &mut Self {
self.command.stdout(cfg);
self
}
fn stderr(&mut self, cfg: std::process::Stdio) -> &mut Self {
self.command.stderr(cfg);
self
}
fn env<K, V>(&mut self, key: K, value: V) -> &mut Self
where
K: AsRef<OsStr>,
V: AsRef<OsStr>,
{
self.command.env(key, value);
self
}
fn output_with_stdin(&mut self, stdin: &[u8]) -> std::io::Result<std::process::Output> {
let mut child = self.command.spawn()?;
if let Some(mut child_stdin) = child.stdin.take() {
use std::io::Write;
child_stdin.write_all(stdin)?;
}
child.wait_with_output()
}
}
#[cfg(test)]
mod tests {
use super::{HookRunResult, HookRunner};
use crate::config::{RuntimeFeatureConfig, RuntimeHookConfig};
#[test]
fn allows_exit_code_zero_and_captures_stdout() {
let runner = HookRunner::new(RuntimeHookConfig::new(
vec![shell_snippet("printf 'pre ok'")],
Vec::new(),
));
let result = runner.run_pre_tool_use("Read", r#"{"path":"README.md"}"#);
assert_eq!(result, HookRunResult::allow(vec!["pre ok".to_string()]));
}
#[test]
fn denies_exit_code_two() {
let runner = HookRunner::new(RuntimeHookConfig::new(
vec![shell_snippet("printf 'blocked by hook'; exit 2")],
Vec::new(),
));
let result = runner.run_pre_tool_use("Bash", r#"{"command":"pwd"}"#);
assert!(result.is_denied());
assert_eq!(result.messages(), &["blocked by hook".to_string()]);
}
#[test]
fn warns_for_other_non_zero_statuses() {
let runner = HookRunner::from_feature_config(&RuntimeFeatureConfig::default().with_hooks(
RuntimeHookConfig::new(
vec![shell_snippet("printf 'warning hook'; exit 1")],
Vec::new(),
),
));
let result = runner.run_pre_tool_use("Edit", r#"{"file":"src/lib.rs"}"#);
assert!(!result.is_denied());
assert!(result
.messages()
.iter()
.any(|message| message.contains("allowing tool execution to continue")));
}
#[cfg(windows)]
fn shell_snippet(script: &str) -> String {
script.replace('\'', "\"")
}
#[cfg(not(windows))]
fn shell_snippet(script: &str) -> String {
script.to_string()
}
}

View File

@@ -4,7 +4,6 @@ mod compact;
mod config; mod config;
mod conversation; mod conversation;
mod file_ops; mod file_ops;
mod hooks;
mod json; mod json;
mod mcp; mod mcp;
mod mcp_client; mod mcp_client;
@@ -27,8 +26,8 @@ pub use config::{
ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpClaudeAiProxyServerConfig, ConfigEntry, ConfigError, ConfigLoader, ConfigSource, McpClaudeAiProxyServerConfig,
McpConfigCollection, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig, McpConfigCollection, McpOAuthConfig, McpRemoteServerConfig, McpSdkServerConfig,
McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig, McpServerConfig, McpStdioServerConfig, McpTransport, McpWebSocketServerConfig, OAuthConfig,
ResolvedPermissionMode, RuntimeConfig, RuntimeFeatureConfig, RuntimeHookConfig, ResolvedPermissionMode, RuntimeConfig, RuntimeFeatureConfig, ScopedMcpServerConfig,
ScopedMcpServerConfig, CLAUDE_CODE_SETTINGS_SCHEMA_NAME, CLAUDE_CODE_SETTINGS_SCHEMA_NAME,
}; };
pub use conversation::{ pub use conversation::{
ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, StaticToolExecutor, ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError, StaticToolExecutor,
@@ -39,7 +38,6 @@ pub use file_ops::{
GrepSearchInput, GrepSearchOutput, ReadFileOutput, StructuredPatchHunk, TextFilePayload, GrepSearchInput, GrepSearchOutput, ReadFileOutput, StructuredPatchHunk, TextFilePayload,
WriteFileOutput, WriteFileOutput,
}; };
pub use hooks::{HookEvent, HookRunResult, HookRunner};
pub use mcp::{ pub use mcp::{
mcp_server_signature, mcp_tool_name, mcp_tool_prefix, normalize_name_for_mcp, mcp_server_signature, mcp_tool_name, mcp_tool_prefix, normalize_name_for_mcp,
scoped_mcp_config_hash, unwrap_ccr_proxy_url, scoped_mcp_config_hash, unwrap_ccr_proxy_url,

View File

@@ -1144,20 +1144,8 @@ mod tests {
} }
fn cleanup_script(script_path: &Path) { fn cleanup_script(script_path: &Path) {
if let Err(error) = fs::remove_file(script_path) { fs::remove_file(script_path).expect("cleanup script");
assert_eq!( fs::remove_dir_all(script_path.parent().expect("script parent")).expect("cleanup dir");
error.kind(),
std::io::ErrorKind::NotFound,
"cleanup script: {error}"
);
}
if let Err(error) = fs::remove_dir_all(script_path.parent().expect("script parent")) {
assert_eq!(
error.kind(),
std::io::ErrorKind::NotFound,
"cleanup dir: {error}"
);
}
} }
fn manager_server_config( fn manager_server_config(

View File

@@ -4,7 +4,6 @@ mod render;
use std::collections::{BTreeMap, BTreeSet}; use std::collections::{BTreeMap, BTreeSet};
use std::env; use std::env;
use std::fmt::Write as _;
use std::fs; use std::fs;
use std::io::{self, Read, Write}; use std::io::{self, Read, Write};
use std::net::TcpListener; use std::net::TcpListener;
@@ -14,9 +13,8 @@ use std::time::{SystemTime, UNIX_EPOCH};
use api::{ use api::{
resolve_startup_auth_source, AnthropicClient, AuthSource, ContentBlockDelta, InputContentBlock, resolve_startup_auth_source, AnthropicClient, AuthSource, ContentBlockDelta, InputContentBlock,
InputMessage, JsonlTelemetrySink, MessageRequest, MessageResponse, OutputContentBlock, InputMessage, MessageRequest, MessageResponse, OutputContentBlock,
SessionTracer, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, StreamEvent as ApiStreamEvent, ToolChoice, ToolDefinition, ToolResultContentBlock,
ToolResultContentBlock,
}; };
use commands::{ use commands::{
@@ -24,12 +22,12 @@ use commands::{
}; };
use compat_harness::{extract_manifest, UpstreamPaths}; use compat_harness::{extract_manifest, UpstreamPaths};
use init::initialize_repo; use init::initialize_repo;
use render::{MarkdownStreamState, Spinner, TerminalRenderer}; use render::{Spinner, TerminalRenderer};
use runtime::{ use runtime::{
clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt, clear_oauth_credentials, generate_pkce_pair, generate_state, load_system_prompt,
parse_oauth_callback_request_target, save_oauth_credentials, ApiClient, ApiRequest, parse_oauth_callback_request_target, save_oauth_credentials, ApiClient, ApiRequest,
AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock, AssistantEvent, CompactionConfig, ConfigLoader, ConfigSource, ContentBlock,
ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest, OAuthConfig, ConversationMessage, ConversationRuntime, MessageRole, OAuthAuthorizationRequest,
OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError, OAuthTokenExchangeRequest, PermissionMode, PermissionPolicy, ProjectContext, RuntimeError,
Session, TokenUsage, ToolError, ToolExecutor, UsageTracker, Session, TokenUsage, ToolError, ToolExecutor, UsageTracker,
}; };
@@ -46,7 +44,6 @@ fn max_tokens_for_model(model: &str) -> u32 {
} }
const DEFAULT_DATE: &str = "2026-03-31"; const DEFAULT_DATE: &str = "2026-03-31";
const DEFAULT_OAUTH_CALLBACK_PORT: u16 = 4545; const DEFAULT_OAUTH_CALLBACK_PORT: u16 = 4545;
const TELEMETRY_LOG_PATH_ENV: &str = "CLAW_TELEMETRY_LOG_PATH";
const VERSION: &str = env!("CARGO_PKG_VERSION"); const VERSION: &str = env!("CARGO_PKG_VERSION");
const BUILD_TARGET: Option<&str> = option_env!("TARGET"); const BUILD_TARGET: Option<&str> = option_env!("TARGET");
const GIT_SHA: Option<&str> = option_env!("GIT_SHA"); const GIT_SHA: Option<&str> = option_env!("GIT_SHA");
@@ -199,25 +196,6 @@ fn parse_args(args: &[String]) -> Result<CliAction, String> {
permission_mode = PermissionMode::DangerFullAccess; permission_mode = PermissionMode::DangerFullAccess;
index += 1; index += 1;
} }
"-p" => {
// Claude Code compat: -p "prompt" = one-shot prompt
let prompt = args[index + 1..].join(" ");
if prompt.trim().is_empty() {
return Err("-p requires a prompt string".to_string());
}
return Ok(CliAction::Prompt {
prompt,
model: resolve_model_alias(&model).to_string(),
output_format,
allowed_tools: normalize_allowed_tools(&allowed_tool_values)?,
permission_mode,
});
}
"--print" => {
// Claude Code compat: --print makes output non-interactive
output_format = CliOutputFormat::Text;
index += 1;
}
"--allowedTools" | "--allowed-tools" => { "--allowedTools" | "--allowed-tools" => {
let value = args let value = args
.get(index + 1) .get(index + 1)
@@ -450,26 +428,15 @@ fn print_bootstrap_plan() {
} }
} }
fn default_oauth_config() -> OAuthConfig {
OAuthConfig {
client_id: String::from("9d1c250a-e61b-44d9-88ed-5944d1962f5e"),
authorize_url: String::from("https://platform.claude.com/oauth/authorize"),
token_url: String::from("https://platform.claude.com/v1/oauth/token"),
callback_port: None,
manual_redirect_url: None,
scopes: vec![
String::from("user:profile"),
String::from("user:inference"),
String::from("user:sessions:claude_code"),
],
}
}
fn run_login() -> Result<(), Box<dyn std::error::Error>> { fn run_login() -> Result<(), Box<dyn std::error::Error>> {
let cwd = env::current_dir()?; let cwd = env::current_dir()?;
let config = ConfigLoader::default_for(&cwd).load()?; let config = ConfigLoader::default_for(&cwd).load()?;
let default_oauth = default_oauth_config(); let oauth = config.oauth().ok_or_else(|| {
let oauth = config.oauth().unwrap_or(&default_oauth); io::Error::new(
io::ErrorKind::NotFound,
"OAuth config is missing. Add settings.oauth.clientId/authorizeUrl/tokenUrl first.",
)
})?;
let callback_port = oauth.callback_port.unwrap_or(DEFAULT_OAUTH_CALLBACK_PORT); let callback_port = oauth.callback_port.unwrap_or(DEFAULT_OAUTH_CALLBACK_PORT);
let redirect_uri = runtime::loopback_redirect_uri(callback_port); let redirect_uri = runtime::loopback_redirect_uri(callback_port);
let pkce = generate_pkce_pair()?; let pkce = generate_pkce_pair()?;
@@ -998,7 +965,6 @@ impl LiveCli {
let session = create_managed_session_handle()?; let session = create_managed_session_handle()?;
let runtime = build_runtime( let runtime = build_runtime(
Session::new(), Session::new(),
&session.id,
model.clone(), model.clone(),
system_prompt.clone(), system_prompt.clone(),
enable_tools, enable_tools,
@@ -1090,7 +1056,6 @@ impl LiveCli {
let session = self.runtime.session().clone(); let session = self.runtime.session().clone();
let mut runtime = build_runtime( let mut runtime = build_runtime(
session, session,
&self.session.id,
self.model.clone(), self.model.clone(),
self.system_prompt.clone(), self.system_prompt.clone(),
true, true,
@@ -1237,7 +1202,6 @@ impl LiveCli {
let message_count = session.messages.len(); let message_count = session.messages.len();
self.runtime = build_runtime( self.runtime = build_runtime(
session, session,
&self.session.id,
model.clone(), model.clone(),
self.system_prompt.clone(), self.system_prompt.clone(),
true, true,
@@ -1281,7 +1245,6 @@ impl LiveCli {
self.permission_mode = permission_mode_from_label(normalized); self.permission_mode = permission_mode_from_label(normalized);
self.runtime = build_runtime( self.runtime = build_runtime(
session, session,
&self.session.id,
self.model.clone(), self.model.clone(),
self.system_prompt.clone(), self.system_prompt.clone(),
true, true,
@@ -1307,7 +1270,6 @@ impl LiveCli {
self.session = create_managed_session_handle()?; self.session = create_managed_session_handle()?;
self.runtime = build_runtime( self.runtime = build_runtime(
Session::new(), Session::new(),
&self.session.id,
self.model.clone(), self.model.clone(),
self.system_prompt.clone(), self.system_prompt.clone(),
true, true,
@@ -1343,7 +1305,6 @@ impl LiveCli {
let message_count = session.messages.len(); let message_count = session.messages.len();
self.runtime = build_runtime( self.runtime = build_runtime(
session, session,
&handle.id,
self.model.clone(), self.model.clone(),
self.system_prompt.clone(), self.system_prompt.clone(),
true, true,
@@ -1416,7 +1377,6 @@ impl LiveCli {
let message_count = session.messages.len(); let message_count = session.messages.len();
self.runtime = build_runtime( self.runtime = build_runtime(
session, session,
&handle.id,
self.model.clone(), self.model.clone(),
self.system_prompt.clone(), self.system_prompt.clone(),
true, true,
@@ -1447,7 +1407,6 @@ impl LiveCli {
let skipped = removed == 0; let skipped = removed == 0;
self.runtime = build_runtime( self.runtime = build_runtime(
result.compacted_session, result.compacted_session,
&self.session.id,
self.model.clone(), self.model.clone(),
self.system_prompt.clone(), self.system_prompt.clone(),
true, true,
@@ -1914,19 +1873,8 @@ fn build_system_prompt() -> Result<Vec<String>, Box<dyn std::error::Error>> {
)?) )?)
} }
fn build_runtime_feature_config(
) -> Result<runtime::RuntimeFeatureConfig, Box<dyn std::error::Error>> {
let cwd = env::current_dir()?;
Ok(ConfigLoader::default_for(cwd)
.load()?
.feature_config()
.clone())
}
#[allow(clippy::too_many_arguments)]
fn build_runtime( fn build_runtime(
session: Session, session: Session,
session_id: &str,
model: String, model: String,
system_prompt: Vec<String>, system_prompt: Vec<String>,
enable_tools: bool, enable_tools: bool,
@@ -1935,41 +1883,13 @@ fn build_runtime(
permission_mode: PermissionMode, permission_mode: PermissionMode,
) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>> ) -> Result<ConversationRuntime<AnthropicRuntimeClient, CliToolExecutor>, Box<dyn std::error::Error>>
{ {
let session_tracer = build_session_tracer(session_id)?; Ok(ConversationRuntime::new(
let api_client = match session_tracer.clone() {
Some(session_tracer) => {
AnthropicRuntimeClient::new(model, enable_tools, emit_output, allowed_tools.clone())?
.with_session_tracer(session_tracer)
}
None => {
AnthropicRuntimeClient::new(model, enable_tools, emit_output, allowed_tools.clone())?
}
};
let runtime = ConversationRuntime::new_with_features(
session, session,
api_client, AnthropicRuntimeClient::new(model, enable_tools, emit_output, allowed_tools.clone())?,
CliToolExecutor::new(allowed_tools, emit_output), CliToolExecutor::new(allowed_tools, emit_output),
permission_policy(permission_mode), permission_policy(permission_mode),
system_prompt, system_prompt,
&build_runtime_feature_config()?, ))
);
Ok(match session_tracer {
Some(session_tracer) => runtime.with_session_tracer(session_tracer),
None => runtime,
})
}
fn build_session_tracer(
session_id: &str,
) -> Result<Option<SessionTracer>, Box<dyn std::error::Error>> {
let Some(path) = env::var_os(TELEMETRY_LOG_PATH_ENV) else {
return Ok(None);
};
let sink = JsonlTelemetrySink::new(PathBuf::from(path))?;
Ok(Some(SessionTracer::new(
session_id.to_string(),
std::sync::Arc::new(sink),
)))
} }
struct CliPermissionPrompter { struct CliPermissionPrompter {
@@ -2044,11 +1964,6 @@ impl AnthropicRuntimeClient {
allowed_tools, allowed_tools,
}) })
} }
fn with_session_tracer(mut self, session_tracer: SessionTracer) -> Self {
self.client = self.client.with_session_tracer(session_tracer);
self
}
} }
fn resolve_cli_auth_source() -> Result<AuthSource, Box<dyn std::error::Error>> { fn resolve_cli_auth_source() -> Result<AuthSource, Box<dyn std::error::Error>> {
@@ -2096,8 +2011,6 @@ impl ApiClient for AnthropicRuntimeClient {
} else { } else {
&mut sink &mut sink
}; };
let renderer = TerminalRenderer::new();
let mut markdown_stream = MarkdownStreamState::default();
let mut events = Vec::new(); let mut events = Vec::new();
let mut pending_tool: Option<(String, String, String)> = None; let mut pending_tool: Option<(String, String, String)> = None;
let mut saw_stop = false; let mut saw_stop = false;
@@ -2125,11 +2038,9 @@ impl ApiClient for AnthropicRuntimeClient {
ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta { ApiStreamEvent::ContentBlockDelta(delta) => match delta.delta {
ContentBlockDelta::TextDelta { text } => { ContentBlockDelta::TextDelta { text } => {
if !text.is_empty() { if !text.is_empty() {
if let Some(rendered) = markdown_stream.push(&renderer, &text) { write!(out, "{text}")
write!(out, "{rendered}")
.and_then(|()| out.flush()) .and_then(|()| out.flush())
.map_err(|error| RuntimeError::new(error.to_string()))?; .map_err(|error| RuntimeError::new(error.to_string()))?;
}
events.push(AssistantEvent::TextDelta(text)); events.push(AssistantEvent::TextDelta(text));
} }
} }
@@ -2140,11 +2051,6 @@ impl ApiClient for AnthropicRuntimeClient {
} }
}, },
ApiStreamEvent::ContentBlockStop(_) => { ApiStreamEvent::ContentBlockStop(_) => {
if let Some(rendered) = markdown_stream.flush(&renderer) {
write!(out, "{rendered}")
.and_then(|()| out.flush())
.map_err(|error| RuntimeError::new(error.to_string()))?;
}
if let Some((id, name, input)) = pending_tool.take() { if let Some((id, name, input)) = pending_tool.take() {
// Display tool call now that input is fully accumulated // Display tool call now that input is fully accumulated
writeln!(out, "\n{}", format_tool_call_start(&name, &input)) writeln!(out, "\n{}", format_tool_call_start(&name, &input))
@@ -2154,15 +2060,15 @@ impl ApiClient for AnthropicRuntimeClient {
} }
} }
ApiStreamEvent::MessageDelta(delta) => { ApiStreamEvent::MessageDelta(delta) => {
events.push(AssistantEvent::Usage(delta.usage.token_usage())); events.push(AssistantEvent::Usage(TokenUsage {
input_tokens: delta.usage.input_tokens,
output_tokens: delta.usage.output_tokens,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
}));
} }
ApiStreamEvent::MessageStop(_) => { ApiStreamEvent::MessageStop(_) => {
saw_stop = true; saw_stop = true;
if let Some(rendered) = markdown_stream.flush(&renderer) {
write!(out, "{rendered}")
.and_then(|()| out.flush())
.map_err(|error| RuntimeError::new(error.to_string()))?;
}
events.push(AssistantEvent::MessageStop); events.push(AssistantEvent::MessageStop);
} }
} }
@@ -2265,49 +2171,56 @@ fn format_tool_call_start(name: &str, input: &str) -> String {
serde_json::from_str(input).unwrap_or(serde_json::Value::String(input.to_string())); serde_json::from_str(input).unwrap_or(serde_json::Value::String(input.to_string()));
let detail = match name { let detail = match name {
"bash" | "Bash" => format_bash_call(&parsed), "bash" | "Bash" => parsed
"read_file" | "Read" => { .get("command")
let path = extract_tool_path(&parsed); .and_then(|v| v.as_str())
format!("\x1b[2m📄 Reading {path}\x1b[0m") .map(|cmd| truncate_for_summary(cmd, 120))
} .unwrap_or_default(),
"read_file" | "Read" => parsed
.get("file_path")
.or_else(|| parsed.get("path"))
.and_then(|v| v.as_str())
.unwrap_or("?")
.to_string(),
"write_file" | "Write" => { "write_file" | "Write" => {
let path = extract_tool_path(&parsed); let path = parsed
.get("file_path")
.or_else(|| parsed.get("path"))
.and_then(|v| v.as_str())
.unwrap_or("?");
let lines = parsed let lines = parsed
.get("content") .get("content")
.and_then(|value| value.as_str()) .and_then(|v| v.as_str())
.map_or(0, |content| content.lines().count()); .map_or(0, |c| c.lines().count());
format!("\x1b[1;32m✏ Writing {path}\x1b[0m \x1b[2m({lines} lines)\x1b[0m") format!("{path} ({lines} lines)")
} }
"edit_file" | "Edit" => { "edit_file" | "Edit" => {
let path = extract_tool_path(&parsed); let path = parsed
let old_value = parsed .get("file_path")
.get("old_string") .or_else(|| parsed.get("path"))
.or_else(|| parsed.get("oldString")) .and_then(|v| v.as_str())
.and_then(|value| value.as_str()) .unwrap_or("?");
.unwrap_or_default(); path.to_string()
let new_value = parsed
.get("new_string")
.or_else(|| parsed.get("newString"))
.and_then(|value| value.as_str())
.unwrap_or_default();
format!(
"\x1b[1;33m📝 Editing {path}\x1b[0m{}",
format_patch_preview(old_value, new_value)
.map(|preview| format!("\n{preview}"))
.unwrap_or_default()
)
} }
"glob_search" | "Glob" => format_search_start("🔎 Glob", &parsed), "glob_search" | "Glob" => parsed
"grep_search" | "Grep" => format_search_start("🔎 Grep", &parsed), .get("pattern")
.and_then(|v| v.as_str())
.unwrap_or("?")
.to_string(),
"grep_search" | "Grep" => parsed
.get("pattern")
.and_then(|v| v.as_str())
.unwrap_or("?")
.to_string(),
"web_search" | "WebSearch" => parsed "web_search" | "WebSearch" => parsed
.get("query") .get("query")
.and_then(|value| value.as_str()) .and_then(|v| v.as_str())
.unwrap_or("?") .unwrap_or("?")
.to_string(), .to_string(),
_ => summarize_tool_payload(input), _ => summarize_tool_payload(input),
}; };
let border = "".repeat(name.len() + 8); let border = "".repeat(name.len() + 6);
format!( format!(
"\x1b[38;5;245m╭─ \x1b[1;36m{name}\x1b[0;38;5;245m ─╮\x1b[0m\n\x1b[38;5;245m│\x1b[0m {detail}\n\x1b[38;5;245m╰{border}\x1b[0m" "\x1b[38;5;245m╭─ \x1b[1;36m{name}\x1b[0;38;5;245m ─╮\x1b[0m\n\x1b[38;5;245m│\x1b[0m {detail}\n\x1b[38;5;245m╰{border}\x1b[0m"
) )
@@ -2319,269 +2232,9 @@ fn format_tool_result(name: &str, output: &str, is_error: bool) -> String {
} else { } else {
"\x1b[1;32m✓\x1b[0m" "\x1b[1;32m✓\x1b[0m"
}; };
if is_error {
let summary = truncate_for_summary(output.trim(), 160);
return if summary.is_empty() {
format!("{icon} \x1b[38;5;245m{name}\x1b[0m")
} else {
format!("{icon} \x1b[38;5;245m{name}\x1b[0m\n\x1b[38;5;203m{summary}\x1b[0m")
};
}
let parsed: serde_json::Value =
serde_json::from_str(output).unwrap_or(serde_json::Value::String(output.to_string()));
match name {
"bash" | "Bash" => format_bash_result(icon, &parsed),
"read_file" | "Read" => format_read_result(icon, &parsed),
"write_file" | "Write" => format_write_result(icon, &parsed),
"edit_file" | "Edit" => format_edit_result(icon, &parsed),
"glob_search" | "Glob" => format_glob_result(icon, &parsed),
"grep_search" | "Grep" => format_grep_result(icon, &parsed),
_ => {
let summary = truncate_for_summary(output.trim(), 200); let summary = truncate_for_summary(output.trim(), 200);
format!("{icon} \x1b[38;5;245m{name}:\x1b[0m {summary}") format!("{icon} \x1b[38;5;245m{name}:\x1b[0m {summary}")
} }
}
}
fn extract_tool_path(parsed: &serde_json::Value) -> String {
parsed
.get("file_path")
.or_else(|| parsed.get("filePath"))
.or_else(|| parsed.get("path"))
.and_then(|value| value.as_str())
.unwrap_or("?")
.to_string()
}
fn format_search_start(label: &str, parsed: &serde_json::Value) -> String {
let pattern = parsed
.get("pattern")
.and_then(|value| value.as_str())
.unwrap_or("?");
let scope = parsed
.get("path")
.and_then(|value| value.as_str())
.unwrap_or(".");
format!("{label} {pattern}\n\x1b[2min {scope}\x1b[0m")
}
fn format_patch_preview(old_value: &str, new_value: &str) -> Option<String> {
if old_value.is_empty() && new_value.is_empty() {
return None;
}
Some(format!(
"\x1b[38;5;203m- {}\x1b[0m\n\x1b[38;5;70m+ {}\x1b[0m",
truncate_for_summary(first_visible_line(old_value), 72),
truncate_for_summary(first_visible_line(new_value), 72)
))
}
fn format_bash_call(parsed: &serde_json::Value) -> String {
let command = parsed
.get("command")
.and_then(|value| value.as_str())
.unwrap_or_default();
if command.is_empty() {
String::new()
} else {
format!(
"\x1b[48;5;236;38;5;255m $ {} \x1b[0m",
truncate_for_summary(command, 160)
)
}
}
fn first_visible_line(text: &str) -> &str {
text.lines()
.find(|line| !line.trim().is_empty())
.unwrap_or(text)
}
fn format_bash_result(icon: &str, parsed: &serde_json::Value) -> String {
let mut lines = vec![format!("{icon} \x1b[38;5;245mbash\x1b[0m")];
if let Some(task_id) = parsed
.get("backgroundTaskId")
.and_then(|value| value.as_str())
{
write!(&mut lines[0], " backgrounded ({task_id})").expect("write to string");
} else if let Some(status) = parsed
.get("returnCodeInterpretation")
.and_then(|value| value.as_str())
.filter(|status| !status.is_empty())
{
write!(&mut lines[0], " {status}").expect("write to string");
}
if let Some(stdout) = parsed.get("stdout").and_then(|value| value.as_str()) {
if !stdout.trim().is_empty() {
lines.push(stdout.trim_end().to_string());
}
}
if let Some(stderr) = parsed.get("stderr").and_then(|value| value.as_str()) {
if !stderr.trim().is_empty() {
lines.push(format!("\x1b[38;5;203m{}\x1b[0m", stderr.trim_end()));
}
}
lines.join("\n\n")
}
fn format_read_result(icon: &str, parsed: &serde_json::Value) -> String {
let file = parsed.get("file").unwrap_or(parsed);
let path = extract_tool_path(file);
let start_line = file
.get("startLine")
.and_then(serde_json::Value::as_u64)
.unwrap_or(1);
let num_lines = file
.get("numLines")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
let total_lines = file
.get("totalLines")
.and_then(serde_json::Value::as_u64)
.unwrap_or(num_lines);
let content = file
.get("content")
.and_then(|value| value.as_str())
.unwrap_or_default();
let end_line = start_line.saturating_add(num_lines.saturating_sub(1));
format!(
"{icon} \x1b[2m📄 Read {path} (lines {}-{} of {})\x1b[0m\n{}",
start_line,
end_line.max(start_line),
total_lines,
content
)
}
fn format_write_result(icon: &str, parsed: &serde_json::Value) -> String {
let path = extract_tool_path(parsed);
let kind = parsed
.get("type")
.and_then(|value| value.as_str())
.unwrap_or("write");
let line_count = parsed
.get("content")
.and_then(|value| value.as_str())
.map_or(0, |content| content.lines().count());
format!(
"{icon} \x1b[1;32m✏ {} {path}\x1b[0m \x1b[2m({line_count} lines)\x1b[0m",
if kind == "create" { "Wrote" } else { "Updated" },
)
}
fn format_structured_patch_preview(parsed: &serde_json::Value) -> Option<String> {
let hunks = parsed.get("structuredPatch")?.as_array()?;
let mut preview = Vec::new();
for hunk in hunks.iter().take(2) {
let lines = hunk.get("lines")?.as_array()?;
for line in lines.iter().filter_map(|value| value.as_str()).take(6) {
match line.chars().next() {
Some('+') => preview.push(format!("\x1b[38;5;70m{line}\x1b[0m")),
Some('-') => preview.push(format!("\x1b[38;5;203m{line}\x1b[0m")),
_ => preview.push(line.to_string()),
}
}
}
if preview.is_empty() {
None
} else {
Some(preview.join("\n"))
}
}
fn format_edit_result(icon: &str, parsed: &serde_json::Value) -> String {
let path = extract_tool_path(parsed);
let suffix = if parsed
.get("replaceAll")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false)
{
" (replace all)"
} else {
""
};
let preview = format_structured_patch_preview(parsed).or_else(|| {
let old_value = parsed
.get("oldString")
.and_then(|value| value.as_str())
.unwrap_or_default();
let new_value = parsed
.get("newString")
.and_then(|value| value.as_str())
.unwrap_or_default();
format_patch_preview(old_value, new_value)
});
match preview {
Some(preview) => format!("{icon} \x1b[1;33m📝 Edited {path}{suffix}\x1b[0m\n{preview}"),
None => format!("{icon} \x1b[1;33m📝 Edited {path}{suffix}\x1b[0m"),
}
}
fn format_glob_result(icon: &str, parsed: &serde_json::Value) -> String {
let num_files = parsed
.get("numFiles")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
let filenames = parsed
.get("filenames")
.and_then(|value| value.as_array())
.map(|files| {
files
.iter()
.filter_map(|value| value.as_str())
.take(8)
.collect::<Vec<_>>()
.join("\n")
})
.unwrap_or_default();
if filenames.is_empty() {
format!("{icon} \x1b[38;5;245mglob_search\x1b[0m matched {num_files} files")
} else {
format!("{icon} \x1b[38;5;245mglob_search\x1b[0m matched {num_files} files\n{filenames}")
}
}
fn format_grep_result(icon: &str, parsed: &serde_json::Value) -> String {
let num_matches = parsed
.get("numMatches")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
let num_files = parsed
.get("numFiles")
.and_then(serde_json::Value::as_u64)
.unwrap_or(0);
let content = parsed
.get("content")
.and_then(|value| value.as_str())
.unwrap_or_default();
let filenames = parsed
.get("filenames")
.and_then(|value| value.as_array())
.map(|files| {
files
.iter()
.filter_map(|value| value.as_str())
.take(8)
.collect::<Vec<_>>()
.join("\n")
})
.unwrap_or_default();
let summary = format!(
"{icon} \x1b[38;5;245mgrep_search\x1b[0m {num_matches} matches across {num_files} files"
);
if !content.trim().is_empty() {
format!("{summary}\n{}", content.trim_end())
} else if !filenames.is_empty() {
format!("{summary}\n{filenames}")
} else {
summary
}
}
fn summarize_tool_payload(payload: &str) -> String { fn summarize_tool_payload(payload: &str) -> String {
let compact = match serde_json::from_str::<serde_json::Value>(payload) { let compact = match serde_json::from_str::<serde_json::Value>(payload) {
@@ -2611,8 +2264,7 @@ fn push_output_block(
match block { match block {
OutputContentBlock::Text { text } => { OutputContentBlock::Text { text } => {
if !text.is_empty() { if !text.is_empty() {
let rendered = TerminalRenderer::new().markdown_to_ansi(&text); write!(out, "{text}")
write!(out, "{rendered}")
.and_then(|()| out.flush()) .and_then(|()| out.flush())
.map_err(|error| RuntimeError::new(error.to_string()))?; .map_err(|error| RuntimeError::new(error.to_string()))?;
events.push(AssistantEvent::TextDelta(text)); events.push(AssistantEvent::TextDelta(text));
@@ -2650,7 +2302,12 @@ fn response_to_events(
} }
} }
events.push(AssistantEvent::Usage(response.usage.token_usage())); events.push(AssistantEvent::Usage(TokenUsage {
input_tokens: response.usage.input_tokens,
output_tokens: response.usage.output_tokens,
cache_creation_input_tokens: response.usage.cache_creation_input_tokens,
cache_read_input_tokens: response.usage.cache_read_input_tokens,
}));
events.push(AssistantEvent::MessageStop); events.push(AssistantEvent::MessageStop);
Ok(events) Ok(events)
} }
@@ -3399,35 +3056,9 @@ mod tests {
assert!(start.contains("read_file")); assert!(start.contains("read_file"));
assert!(start.contains("src/main.rs")); assert!(start.contains("src/main.rs"));
let done = format_tool_result( let done = format_tool_result("read_file", r#"{"contents":"hello"}"#, false);
"read_file", assert!(done.contains("read_file:"));
r#"{"file":{"filePath":"src/main.rs","content":"hello","numLines":1,"startLine":1,"totalLines":1}}"#, assert!(done.contains("contents"));
false,
);
assert!(done.contains("📄 Read src/main.rs"));
assert!(done.contains("hello"));
}
#[test]
fn push_output_block_renders_markdown_text() {
let mut out = Vec::new();
let mut events = Vec::new();
let mut pending_tool = None;
push_output_block(
OutputContentBlock::Text {
text: "# Heading".to_string(),
},
&mut out,
&mut events,
&mut pending_tool,
false,
)
.expect("text block should render");
let rendered = String::from_utf8(out).expect("utf8");
assert!(rendered.contains("Heading"));
assert!(rendered.contains('\u{1b}'));
} }
#[test] #[test]

View File

@@ -1,5 +1,7 @@
use std::fmt::Write as FmtWrite; use std::fmt::Write as FmtWrite;
use std::io::{self, Write}; use std::io::{self, Write};
use std::thread;
use std::time::Duration;
use crossterm::cursor::{MoveToColumn, RestorePosition, SavePosition}; use crossterm::cursor::{MoveToColumn, RestorePosition, SavePosition};
use crossterm::style::{Color, Print, ResetColor, SetForegroundColor, Stylize}; use crossterm::style::{Color, Print, ResetColor, SetForegroundColor, Stylize};
@@ -20,7 +22,6 @@ pub struct ColorTheme {
link: Color, link: Color,
quote: Color, quote: Color,
table_border: Color, table_border: Color,
code_block_border: Color,
spinner_active: Color, spinner_active: Color,
spinner_done: Color, spinner_done: Color,
spinner_failed: Color, spinner_failed: Color,
@@ -36,7 +37,6 @@ impl Default for ColorTheme {
link: Color::Blue, link: Color::Blue,
quote: Color::DarkGrey, quote: Color::DarkGrey,
table_border: Color::DarkCyan, table_border: Color::DarkCyan,
code_block_border: Color::DarkGrey,
spinner_active: Color::Blue, spinner_active: Color::Blue,
spinner_done: Color::Green, spinner_done: Color::Green,
spinner_failed: Color::Red, spinner_failed: Color::Red,
@@ -154,64 +154,33 @@ impl TableState {
struct RenderState { struct RenderState {
emphasis: usize, emphasis: usize,
strong: usize, strong: usize,
heading_level: Option<u8>,
quote: usize, quote: usize,
list_stack: Vec<ListKind>, list_stack: Vec<ListKind>,
link_stack: Vec<LinkState>,
table: Option<TableState>, table: Option<TableState>,
} }
#[derive(Debug, Clone, PartialEq, Eq)]
struct LinkState {
destination: String,
text: String,
}
impl RenderState { impl RenderState {
fn style_text(&self, text: &str, theme: &ColorTheme) -> String { fn style_text(&self, text: &str, theme: &ColorTheme) -> String {
let mut style = text.stylize(); let mut styled = text.to_string();
if self.strong > 0 {
if matches!(self.heading_level, Some(1 | 2)) || self.strong > 0 { styled = format!("{}", styled.bold().with(theme.strong));
style = style.bold();
} }
if self.emphasis > 0 { if self.emphasis > 0 {
style = style.italic(); styled = format!("{}", styled.italic().with(theme.emphasis));
} }
if let Some(level) = self.heading_level {
style = match level {
1 => style.with(theme.heading),
2 => style.white(),
3 => style.with(Color::Blue),
_ => style.with(Color::Grey),
};
} else if self.strong > 0 {
style = style.with(theme.strong);
} else if self.emphasis > 0 {
style = style.with(theme.emphasis);
}
if self.quote > 0 { if self.quote > 0 {
style = style.with(theme.quote); styled = format!("{}", styled.with(theme.quote));
}
styled
} }
format!("{style}") fn capture_target_mut<'a>(&'a mut self, output: &'a mut String) -> &'a mut String {
} if let Some(table) = self.table.as_mut() {
&mut table.current_cell
fn append_raw(&mut self, output: &mut String, text: &str) {
if let Some(link) = self.link_stack.last_mut() {
link.text.push_str(text);
} else if let Some(table) = self.table.as_mut() {
table.current_cell.push_str(text);
} else { } else {
output.push_str(text); output
} }
} }
fn append_styled(&mut self, output: &mut String, text: &str, theme: &ColorTheme) {
let styled = self.style_text(text, theme);
self.append_raw(output, &styled);
}
} }
#[derive(Debug)] #[derive(Debug)]
@@ -269,11 +238,6 @@ impl TerminalRenderer {
output.trim_end().to_string() output.trim_end().to_string()
} }
#[must_use]
pub fn markdown_to_ansi(&self, markdown: &str) -> String {
self.render_markdown(markdown)
}
#[allow(clippy::too_many_lines)] #[allow(clippy::too_many_lines)]
fn render_event( fn render_event(
&self, &self,
@@ -285,21 +249,15 @@ impl TerminalRenderer {
in_code_block: &mut bool, in_code_block: &mut bool,
) { ) {
match event { match event {
Event::Start(Tag::Heading { level, .. }) => { Event::Start(Tag::Heading { level, .. }) => self.start_heading(level as u8, output),
Self::start_heading(state, level as u8, output); Event::End(TagEnd::Heading(..) | TagEnd::Paragraph) => output.push_str("\n\n"),
}
Event::End(TagEnd::Paragraph) => output.push_str("\n\n"),
Event::Start(Tag::BlockQuote(..)) => self.start_quote(state, output), Event::Start(Tag::BlockQuote(..)) => self.start_quote(state, output),
Event::End(TagEnd::BlockQuote(..)) => { Event::End(TagEnd::BlockQuote(..)) => {
state.quote = state.quote.saturating_sub(1); state.quote = state.quote.saturating_sub(1);
output.push('\n'); output.push('\n');
} }
Event::End(TagEnd::Heading(..)) => {
state.heading_level = None;
output.push_str("\n\n");
}
Event::End(TagEnd::Item) | Event::SoftBreak | Event::HardBreak => { Event::End(TagEnd::Item) | Event::SoftBreak | Event::HardBreak => {
state.append_raw(output, "\n"); state.capture_target_mut(output).push('\n');
} }
Event::Start(Tag::List(first_item)) => { Event::Start(Tag::List(first_item)) => {
let kind = match first_item { let kind = match first_item {
@@ -335,52 +293,41 @@ impl TerminalRenderer {
Event::Code(code) => { Event::Code(code) => {
let rendered = let rendered =
format!("{}", format!("`{code}`").with(self.color_theme.inline_code)); format!("{}", format!("`{code}`").with(self.color_theme.inline_code));
state.append_raw(output, &rendered); state.capture_target_mut(output).push_str(&rendered);
} }
Event::Rule => output.push_str("---\n"), Event::Rule => output.push_str("---\n"),
Event::Text(text) => { Event::Text(text) => {
self.push_text(text.as_ref(), state, output, code_buffer, *in_code_block); self.push_text(text.as_ref(), state, output, code_buffer, *in_code_block);
} }
Event::Html(html) | Event::InlineHtml(html) => { Event::Html(html) | Event::InlineHtml(html) => {
state.append_raw(output, &html); state.capture_target_mut(output).push_str(&html);
} }
Event::FootnoteReference(reference) => { Event::FootnoteReference(reference) => {
state.append_raw(output, &format!("[{reference}]")); let _ = write!(state.capture_target_mut(output), "[{reference}]");
} }
Event::TaskListMarker(done) => { Event::TaskListMarker(done) => {
state.append_raw(output, if done { "[x] " } else { "[ ] " }); state
.capture_target_mut(output)
.push_str(if done { "[x] " } else { "[ ] " });
} }
Event::InlineMath(math) | Event::DisplayMath(math) => { Event::InlineMath(math) | Event::DisplayMath(math) => {
state.append_raw(output, &math); state.capture_target_mut(output).push_str(&math);
} }
Event::Start(Tag::Link { dest_url, .. }) => { Event::Start(Tag::Link { dest_url, .. }) => {
state.link_stack.push(LinkState {
destination: dest_url.to_string(),
text: String::new(),
});
}
Event::End(TagEnd::Link) => {
if let Some(link) = state.link_stack.pop() {
let label = if link.text.is_empty() {
link.destination.clone()
} else {
link.text
};
let rendered = format!( let rendered = format!(
"{}", "{}",
format!("[{label}]({})", link.destination) format!("[{dest_url}]")
.underlined() .underlined()
.with(self.color_theme.link) .with(self.color_theme.link)
); );
state.append_raw(output, &rendered); state.capture_target_mut(output).push_str(&rendered);
}
} }
Event::Start(Tag::Image { dest_url, .. }) => { Event::Start(Tag::Image { dest_url, .. }) => {
let rendered = format!( let rendered = format!(
"{}", "{}",
format!("[image:{dest_url}]").with(self.color_theme.link) format!("[image:{dest_url}]").with(self.color_theme.link)
); );
state.append_raw(output, &rendered); state.capture_target_mut(output).push_str(&rendered);
} }
Event::Start(Tag::Table(..)) => state.table = Some(TableState::default()), Event::Start(Tag::Table(..)) => state.table = Some(TableState::default()),
Event::End(TagEnd::Table) => { Event::End(TagEnd::Table) => {
@@ -422,15 +369,19 @@ impl TerminalRenderer {
} }
} }
Event::Start(Tag::Paragraph | Tag::MetadataBlock(..) | _) Event::Start(Tag::Paragraph | Tag::MetadataBlock(..) | _)
| Event::End(TagEnd::Image | TagEnd::MetadataBlock(..) | _) => {} | Event::End(TagEnd::Link | TagEnd::Image | TagEnd::MetadataBlock(..) | _) => {}
} }
} }
fn start_heading(state: &mut RenderState, level: u8, output: &mut String) { fn start_heading(&self, level: u8, output: &mut String) {
state.heading_level = Some(level);
if !output.is_empty() {
output.push('\n'); output.push('\n');
} let prefix = match level {
1 => "# ",
2 => "## ",
3 => "### ",
_ => "#### ",
};
let _ = write!(output, "{}", prefix.bold().with(self.color_theme.heading));
} }
fn start_quote(&self, state: &mut RenderState, output: &mut String) { fn start_quote(&self, state: &mut RenderState, output: &mut String) {
@@ -454,27 +405,20 @@ impl TerminalRenderer {
} }
fn start_code_block(&self, code_language: &str, output: &mut String) { fn start_code_block(&self, code_language: &str, output: &mut String) {
let label = if code_language.is_empty() { if !code_language.is_empty() {
"code".to_string()
} else {
code_language.to_string()
};
let _ = writeln!( let _ = writeln!(
output, output,
"{}", "{}",
format!("╭─ {label}") format!("╭─ {code_language}").with(self.color_theme.heading)
.bold()
.with(self.color_theme.code_block_border)
); );
} }
}
fn finish_code_block(&self, code_buffer: &str, code_language: &str, output: &mut String) { fn finish_code_block(&self, code_buffer: &str, code_language: &str, output: &mut String) {
output.push_str(&self.highlight_code(code_buffer, code_language)); output.push_str(&self.highlight_code(code_buffer, code_language));
let _ = write!( if !code_language.is_empty() {
output, let _ = write!(output, "{}", "╰─".with(self.color_theme.heading));
"{}", }
"╰─".bold().with(self.color_theme.code_block_border)
);
output.push_str("\n\n"); output.push_str("\n\n");
} }
@@ -489,7 +433,8 @@ impl TerminalRenderer {
if in_code_block { if in_code_block {
code_buffer.push_str(text); code_buffer.push_str(text);
} else { } else {
state.append_styled(output, text, &self.color_theme); let rendered = state.style_text(text, &self.color_theme);
state.capture_target_mut(output).push_str(&rendered);
} }
} }
@@ -576,10 +521,9 @@ impl TerminalRenderer {
for line in LinesWithEndings::from(code) { for line in LinesWithEndings::from(code) {
match syntax_highlighter.highlight_line(line, &self.syntax_set) { match syntax_highlighter.highlight_line(line, &self.syntax_set) {
Ok(ranges) => { Ok(ranges) => {
let escaped = as_24_bit_terminal_escaped(&ranges[..], false); colored_output.push_str(&as_24_bit_terminal_escaped(&ranges[..], false));
colored_output.push_str(&apply_code_block_background(&escaped));
} }
Err(_) => colored_output.push_str(&apply_code_block_background(line)), Err(_) => colored_output.push_str(line),
} }
} }
@@ -587,83 +531,16 @@ impl TerminalRenderer {
} }
pub fn stream_markdown(&self, markdown: &str, out: &mut impl Write) -> io::Result<()> { pub fn stream_markdown(&self, markdown: &str, out: &mut impl Write) -> io::Result<()> {
let rendered_markdown = self.markdown_to_ansi(markdown); let rendered_markdown = self.render_markdown(markdown);
write!(out, "{rendered_markdown}")?; for chunk in rendered_markdown.split_inclusive(char::is_whitespace) {
if !rendered_markdown.ends_with('\n') { write!(out, "{chunk}")?;
writeln!(out)?; out.flush()?;
thread::sleep(Duration::from_millis(8));
} }
out.flush() writeln!(out)
} }
} }
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct MarkdownStreamState {
pending: String,
}
impl MarkdownStreamState {
#[must_use]
pub fn push(&mut self, renderer: &TerminalRenderer, delta: &str) -> Option<String> {
self.pending.push_str(delta);
let split = find_stream_safe_boundary(&self.pending)?;
let ready = self.pending[..split].to_string();
self.pending.drain(..split);
Some(renderer.markdown_to_ansi(&ready))
}
#[must_use]
pub fn flush(&mut self, renderer: &TerminalRenderer) -> Option<String> {
if self.pending.trim().is_empty() {
self.pending.clear();
None
} else {
let pending = std::mem::take(&mut self.pending);
Some(renderer.markdown_to_ansi(&pending))
}
}
}
fn apply_code_block_background(line: &str) -> String {
let trimmed = line.trim_end_matches('\n');
let trailing_newline = if trimmed.len() == line.len() {
""
} else {
"\n"
};
let with_background = trimmed.replace("\u{1b}[0m", "\u{1b}[0;48;5;236m");
format!("\u{1b}[48;5;236m{with_background}\u{1b}[0m{trailing_newline}")
}
fn find_stream_safe_boundary(markdown: &str) -> Option<usize> {
let mut in_fence = false;
let mut last_boundary = None;
for (offset, line) in markdown.split_inclusive('\n').scan(0usize, |cursor, line| {
let start = *cursor;
*cursor += line.len();
Some((start, line))
}) {
let trimmed = line.trim_start();
if trimmed.starts_with("```") || trimmed.starts_with("~~~") {
in_fence = !in_fence;
if !in_fence {
last_boundary = Some(offset + line.len());
}
continue;
}
if in_fence {
continue;
}
if trimmed.is_empty() {
last_boundary = Some(offset + line.len());
}
}
last_boundary
}
fn visible_width(input: &str) -> usize { fn visible_width(input: &str) -> usize {
strip_ansi(input).chars().count() strip_ansi(input).chars().count()
} }
@@ -692,7 +569,7 @@ fn strip_ansi(input: &str) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{strip_ansi, MarkdownStreamState, Spinner, TerminalRenderer}; use super::{strip_ansi, Spinner, TerminalRenderer};
#[test] #[test]
fn renders_markdown_with_styling_and_lists() { fn renders_markdown_with_styling_and_lists() {
@@ -706,28 +583,16 @@ mod tests {
assert!(markdown_output.contains('\u{1b}')); assert!(markdown_output.contains('\u{1b}'));
} }
#[test]
fn renders_links_as_colored_markdown_labels() {
let terminal_renderer = TerminalRenderer::new();
let markdown_output =
terminal_renderer.render_markdown("See [Claw](https://example.com/docs) now.");
let plain_text = strip_ansi(&markdown_output);
assert!(plain_text.contains("[Claw](https://example.com/docs)"));
assert!(markdown_output.contains('\u{1b}'));
}
#[test] #[test]
fn highlights_fenced_code_blocks() { fn highlights_fenced_code_blocks() {
let terminal_renderer = TerminalRenderer::new(); let terminal_renderer = TerminalRenderer::new();
let markdown_output = let markdown_output =
terminal_renderer.markdown_to_ansi("```rust\nfn hi() { println!(\"hi\"); }\n```"); terminal_renderer.render_markdown("```rust\nfn hi() { println!(\"hi\"); }\n```");
let plain_text = strip_ansi(&markdown_output); let plain_text = strip_ansi(&markdown_output);
assert!(plain_text.contains("╭─ rust")); assert!(plain_text.contains("╭─ rust"));
assert!(plain_text.contains("fn hi")); assert!(plain_text.contains("fn hi"));
assert!(markdown_output.contains('\u{1b}')); assert!(markdown_output.contains('\u{1b}'));
assert!(markdown_output.contains("[48;5;236m"));
} }
#[test] #[test]
@@ -758,26 +623,6 @@ mod tests {
assert!(markdown_output.contains('\u{1b}')); assert!(markdown_output.contains('\u{1b}'));
} }
#[test]
fn streaming_state_waits_for_complete_blocks() {
let renderer = TerminalRenderer::new();
let mut state = MarkdownStreamState::default();
assert_eq!(state.push(&renderer, "# Heading"), None);
let flushed = state
.push(&renderer, "\n\nParagraph\n\n")
.expect("completed block");
let plain_text = strip_ansi(&flushed);
assert!(plain_text.contains("Heading"));
assert!(plain_text.contains("Paragraph"));
assert_eq!(state.push(&renderer, "```rust\nfn main() {}\n"), None);
let code = state
.push(&renderer, "```\n")
.expect("closed code fence flushes");
assert!(strip_ansi(&code).contains("fn main()"));
}
#[test] #[test]
fn spinner_advances_frames() { fn spinner_advances_frames() {
let terminal_renderer = TerminalRenderer::new(); let terminal_renderer = TerminalRenderer::new();

View File

@@ -1,13 +0,0 @@
[package]
name = "telemetry"
version.workspace = true
edition.workspace = true
license.workspace = true
publish.workspace = true
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
[lints]
workspace = true

View File

@@ -1,526 +0,0 @@
use std::fmt::{Debug, Formatter};
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use serde_json::{Map, Value};
pub const DEFAULT_ANTHROPIC_VERSION: &str = "2023-06-01";
pub const DEFAULT_APP_NAME: &str = "claude-code";
pub const DEFAULT_RUNTIME: &str = "rust";
pub const DEFAULT_AGENTIC_BETA: &str = "claude-code-20250219";
pub const DEFAULT_PROMPT_CACHING_SCOPE_BETA: &str = "prompt-caching-scope-2026-01-05";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ClientIdentity {
pub app_name: String,
pub app_version: String,
pub runtime: String,
}
impl ClientIdentity {
#[must_use]
pub fn new(app_name: impl Into<String>, app_version: impl Into<String>) -> Self {
Self {
app_name: app_name.into(),
app_version: app_version.into(),
runtime: DEFAULT_RUNTIME.to_string(),
}
}
#[must_use]
pub fn with_runtime(mut self, runtime: impl Into<String>) -> Self {
self.runtime = runtime.into();
self
}
#[must_use]
pub fn user_agent(&self) -> String {
format!("{}/{}", self.app_name, self.app_version)
}
}
impl Default for ClientIdentity {
fn default() -> Self {
Self::new(DEFAULT_APP_NAME, env!("CARGO_PKG_VERSION"))
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AnthropicRequestProfile {
pub anthropic_version: String,
pub client_identity: ClientIdentity,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub betas: Vec<String>,
#[serde(default, skip_serializing_if = "Map::is_empty")]
pub extra_body: Map<String, Value>,
}
impl AnthropicRequestProfile {
#[must_use]
pub fn new(client_identity: ClientIdentity) -> Self {
Self {
anthropic_version: DEFAULT_ANTHROPIC_VERSION.to_string(),
client_identity,
betas: vec![
DEFAULT_AGENTIC_BETA.to_string(),
DEFAULT_PROMPT_CACHING_SCOPE_BETA.to_string(),
],
extra_body: Map::new(),
}
}
#[must_use]
pub fn with_beta(mut self, beta: impl Into<String>) -> Self {
let beta = beta.into();
if !self.betas.contains(&beta) {
self.betas.push(beta);
}
self
}
#[must_use]
pub fn with_extra_body(mut self, key: impl Into<String>, value: Value) -> Self {
self.extra_body.insert(key.into(), value);
self
}
#[must_use]
pub fn header_pairs(&self) -> Vec<(String, String)> {
let mut headers = vec![
(
"anthropic-version".to_string(),
self.anthropic_version.clone(),
),
("user-agent".to_string(), self.client_identity.user_agent()),
];
if !self.betas.is_empty() {
headers.push(("anthropic-beta".to_string(), self.betas.join(",")));
}
headers
}
pub fn render_json_body<T: Serialize>(&self, request: &T) -> Result<Value, serde_json::Error> {
let mut body = serde_json::to_value(request)?;
let object = body.as_object_mut().ok_or_else(|| {
serde_json::Error::io(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"request body must serialize to a JSON object",
))
})?;
for (key, value) in &self.extra_body {
object.insert(key.clone(), value.clone());
}
if !self.betas.is_empty() {
object.insert(
"betas".to_string(),
Value::Array(self.betas.iter().cloned().map(Value::String).collect()),
);
}
Ok(body)
}
}
impl Default for AnthropicRequestProfile {
fn default() -> Self {
Self::new(ClientIdentity::default())
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AnalyticsEvent {
pub namespace: String,
pub action: String,
#[serde(default, skip_serializing_if = "Map::is_empty")]
pub properties: Map<String, Value>,
}
impl AnalyticsEvent {
#[must_use]
pub fn new(namespace: impl Into<String>, action: impl Into<String>) -> Self {
Self {
namespace: namespace.into(),
action: action.into(),
properties: Map::new(),
}
}
#[must_use]
pub fn with_property(mut self, key: impl Into<String>, value: Value) -> Self {
self.properties.insert(key.into(), value);
self
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SessionTraceRecord {
pub session_id: String,
pub sequence: u64,
pub name: String,
pub timestamp_ms: u64,
#[serde(default, skip_serializing_if = "Map::is_empty")]
pub attributes: Map<String, Value>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum TelemetryEvent {
HttpRequestStarted {
session_id: String,
attempt: u32,
method: String,
path: String,
#[serde(default, skip_serializing_if = "Map::is_empty")]
attributes: Map<String, Value>,
},
HttpRequestSucceeded {
session_id: String,
attempt: u32,
method: String,
path: String,
status: u16,
#[serde(default, skip_serializing_if = "Option::is_none")]
request_id: Option<String>,
#[serde(default, skip_serializing_if = "Map::is_empty")]
attributes: Map<String, Value>,
},
HttpRequestFailed {
session_id: String,
attempt: u32,
method: String,
path: String,
error: String,
retryable: bool,
#[serde(default, skip_serializing_if = "Map::is_empty")]
attributes: Map<String, Value>,
},
Analytics(AnalyticsEvent),
SessionTrace(SessionTraceRecord),
}
pub trait TelemetrySink: Send + Sync {
fn record(&self, event: TelemetryEvent);
}
#[derive(Default)]
pub struct MemoryTelemetrySink {
events: Mutex<Vec<TelemetryEvent>>,
}
impl MemoryTelemetrySink {
#[must_use]
pub fn events(&self) -> Vec<TelemetryEvent> {
self.events
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
}
impl TelemetrySink for MemoryTelemetrySink {
fn record(&self, event: TelemetryEvent) {
self.events
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(event);
}
}
pub struct JsonlTelemetrySink {
path: PathBuf,
file: Mutex<File>,
}
impl Debug for JsonlTelemetrySink {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.debug_struct("JsonlTelemetrySink")
.field("path", &self.path)
.finish_non_exhaustive()
}
}
impl JsonlTelemetrySink {
pub fn new(path: impl AsRef<Path>) -> Result<Self, std::io::Error> {
let path = path.as_ref().to_path_buf();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let file = OpenOptions::new().create(true).append(true).open(&path)?;
Ok(Self {
path,
file: Mutex::new(file),
})
}
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
}
impl TelemetrySink for JsonlTelemetrySink {
fn record(&self, event: TelemetryEvent) {
let Ok(line) = serde_json::to_string(&event) else {
return;
};
let mut file = self
.file
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let _ = writeln!(file, "{line}");
let _ = file.flush();
}
}
#[derive(Clone)]
pub struct SessionTracer {
session_id: String,
sequence: Arc<AtomicU64>,
sink: Arc<dyn TelemetrySink>,
}
impl Debug for SessionTracer {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.debug_struct("SessionTracer")
.field("session_id", &self.session_id)
.finish_non_exhaustive()
}
}
impl SessionTracer {
#[must_use]
pub fn new(session_id: impl Into<String>, sink: Arc<dyn TelemetrySink>) -> Self {
Self {
session_id: session_id.into(),
sequence: Arc::new(AtomicU64::new(0)),
sink,
}
}
#[must_use]
pub fn session_id(&self) -> &str {
&self.session_id
}
pub fn record(&self, name: impl Into<String>, attributes: Map<String, Value>) {
let record = SessionTraceRecord {
session_id: self.session_id.clone(),
sequence: self.sequence.fetch_add(1, Ordering::Relaxed),
name: name.into(),
timestamp_ms: current_timestamp_ms(),
attributes,
};
self.sink.record(TelemetryEvent::SessionTrace(record));
}
pub fn record_http_request_started(
&self,
attempt: u32,
method: impl Into<String>,
path: impl Into<String>,
attributes: Map<String, Value>,
) {
let method = method.into();
let path = path.into();
self.sink.record(TelemetryEvent::HttpRequestStarted {
session_id: self.session_id.clone(),
attempt,
method: method.clone(),
path: path.clone(),
attributes: attributes.clone(),
});
self.record(
"http_request_started",
merge_trace_fields(method, path, attempt, attributes),
);
}
pub fn record_http_request_succeeded(
&self,
attempt: u32,
method: impl Into<String>,
path: impl Into<String>,
status: u16,
request_id: Option<String>,
attributes: Map<String, Value>,
) {
let method = method.into();
let path = path.into();
self.sink.record(TelemetryEvent::HttpRequestSucceeded {
session_id: self.session_id.clone(),
attempt,
method: method.clone(),
path: path.clone(),
status,
request_id: request_id.clone(),
attributes: attributes.clone(),
});
let mut trace_attributes = merge_trace_fields(method, path, attempt, attributes);
trace_attributes.insert("status".to_string(), Value::from(status));
if let Some(request_id) = request_id {
trace_attributes.insert("request_id".to_string(), Value::String(request_id));
}
self.record("http_request_succeeded", trace_attributes);
}
pub fn record_http_request_failed(
&self,
attempt: u32,
method: impl Into<String>,
path: impl Into<String>,
error: impl Into<String>,
retryable: bool,
attributes: Map<String, Value>,
) {
let method = method.into();
let path = path.into();
let error = error.into();
self.sink.record(TelemetryEvent::HttpRequestFailed {
session_id: self.session_id.clone(),
attempt,
method: method.clone(),
path: path.clone(),
error: error.clone(),
retryable,
attributes: attributes.clone(),
});
let mut trace_attributes = merge_trace_fields(method, path, attempt, attributes);
trace_attributes.insert("error".to_string(), Value::String(error));
trace_attributes.insert("retryable".to_string(), Value::Bool(retryable));
self.record("http_request_failed", trace_attributes);
}
pub fn record_analytics(&self, event: AnalyticsEvent) {
let mut attributes = event.properties.clone();
attributes.insert(
"namespace".to_string(),
Value::String(event.namespace.clone()),
);
attributes.insert("action".to_string(), Value::String(event.action.clone()));
self.sink.record(TelemetryEvent::Analytics(event));
self.record("analytics", attributes);
}
}
fn merge_trace_fields(
method: String,
path: String,
attempt: u32,
mut attributes: Map<String, Value>,
) -> Map<String, Value> {
attributes.insert("method".to_string(), Value::String(method));
attributes.insert("path".to_string(), Value::String(path));
attributes.insert("attempt".to_string(), Value::from(attempt));
attributes
}
fn current_timestamp_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis()
.try_into()
.unwrap_or(u64::MAX)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn request_profile_emits_headers_and_merges_body() {
let profile = AnthropicRequestProfile::new(
ClientIdentity::new("claude-code", "1.2.3").with_runtime("rust-cli"),
)
.with_beta("tools-2026-04-01")
.with_extra_body("metadata", serde_json::json!({"source": "test"}));
assert_eq!(
profile.header_pairs(),
vec![
(
"anthropic-version".to_string(),
DEFAULT_ANTHROPIC_VERSION.to_string()
),
("user-agent".to_string(), "claude-code/1.2.3".to_string()),
(
"anthropic-beta".to_string(),
"claude-code-20250219,prompt-caching-scope-2026-01-05,tools-2026-04-01"
.to_string(),
),
]
);
let body = profile
.render_json_body(&serde_json::json!({"model": "claude-sonnet"}))
.expect("body should serialize");
assert_eq!(
body["metadata"]["source"],
Value::String("test".to_string())
);
assert_eq!(
body["betas"],
serde_json::json!([
"claude-code-20250219",
"prompt-caching-scope-2026-01-05",
"tools-2026-04-01"
])
);
}
#[test]
fn session_tracer_records_structured_events_and_trace_sequence() {
let sink = Arc::new(MemoryTelemetrySink::default());
let tracer = SessionTracer::new("session-123", sink.clone());
tracer.record_http_request_started(1, "POST", "/v1/messages", Map::new());
tracer.record_analytics(
AnalyticsEvent::new("cli", "prompt_sent")
.with_property("model", Value::String("claude-opus".to_string())),
);
let events = sink.events();
assert!(matches!(
&events[0],
TelemetryEvent::HttpRequestStarted {
session_id,
attempt: 1,
method,
path,
..
} if session_id == "session-123" && method == "POST" && path == "/v1/messages"
));
assert!(matches!(
&events[1],
TelemetryEvent::SessionTrace(SessionTraceRecord { sequence: 0, name, .. })
if name == "http_request_started"
));
assert!(matches!(&events[2], TelemetryEvent::Analytics(_)));
assert!(matches!(
&events[3],
TelemetryEvent::SessionTrace(SessionTraceRecord { sequence: 1, name, .. })
if name == "analytics"
));
}
#[test]
fn jsonl_sink_persists_events() {
let path =
std::env::temp_dir().join(format!("telemetry-jsonl-{}.log", current_timestamp_ms()));
let sink = JsonlTelemetrySink::new(&path).expect("sink should create file");
sink.record(TelemetryEvent::Analytics(
AnalyticsEvent::new("cli", "turn_completed").with_property("ok", Value::Bool(true)),
));
let contents = std::fs::read_to_string(&path).expect("telemetry log should be readable");
assert!(contents.contains("\"type\":\"analytics\""));
assert!(contents.contains("\"action\":\"turn_completed\""));
let _ = std::fs::remove_file(path);
}
}

View File

@@ -13,7 +13,7 @@ use runtime::{
edit_file, execute_bash, glob_search, grep_search, load_system_prompt, read_file, write_file, edit_file, execute_bash, glob_search, grep_search, load_system_prompt, read_file, write_file,
ApiClient, ApiRequest, AssistantEvent, BashCommandInput, ContentBlock, ConversationMessage, ApiClient, ApiRequest, AssistantEvent, BashCommandInput, ContentBlock, ConversationMessage,
ConversationRuntime, GrepSearchInput, MessageRole, PermissionMode, PermissionPolicy, ConversationRuntime, GrepSearchInput, MessageRole, PermissionMode, PermissionPolicy,
RuntimeError, Session, ToolError, ToolExecutor, RuntimeError, Session, TokenUsage, ToolError, ToolExecutor,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
@@ -1280,7 +1280,15 @@ fn validate_todos(todos: &[TodoItem]) -> Result<(), String> {
if todos.is_empty() { if todos.is_empty() {
return Err(String::from("todos must not be empty")); return Err(String::from("todos must not be empty"));
} }
// Allow multiple in_progress items for parallel workflows let in_progress = todos
.iter()
.filter(|todo| matches!(todo.status, TodoStatus::InProgress))
.count();
if in_progress > 1 {
return Err(String::from(
"exactly zero or one todo items may be in_progress",
));
}
if todos.iter().any(|todo| todo.content.trim().is_empty()) { if todos.iter().any(|todo| todo.content.trim().is_empty()) {
return Err(String::from("todo content must not be empty")); return Err(String::from("todo content must not be empty"));
} }
@@ -1723,7 +1731,12 @@ impl ApiClient for AnthropicRuntimeClient {
} }
} }
ApiStreamEvent::MessageDelta(delta) => { ApiStreamEvent::MessageDelta(delta) => {
events.push(AssistantEvent::Usage(delta.usage.token_usage())); events.push(AssistantEvent::Usage(TokenUsage {
input_tokens: delta.usage.input_tokens,
output_tokens: delta.usage.output_tokens,
cache_creation_input_tokens: 0,
cache_read_input_tokens: 0,
}));
} }
ApiStreamEvent::MessageStop(_) => { ApiStreamEvent::MessageStop(_) => {
saw_stop = true; saw_stop = true;
@@ -1869,7 +1882,12 @@ fn response_to_events(response: MessageResponse) -> Vec<AssistantEvent> {
} }
} }
events.push(AssistantEvent::Usage(response.usage.token_usage())); events.push(AssistantEvent::Usage(TokenUsage {
input_tokens: response.usage.input_tokens,
output_tokens: response.usage.output_tokens,
cache_creation_input_tokens: response.usage.cache_creation_input_tokens,
cache_read_input_tokens: response.usage.cache_read_input_tokens,
}));
events.push(AssistantEvent::MessageStop); events.push(AssistantEvent::MessageStop);
events events
} }
@@ -3170,8 +3188,7 @@ mod tests {
.expect_err("empty todos should fail"); .expect_err("empty todos should fail");
assert!(empty.contains("todos must not be empty")); assert!(empty.contains("todos must not be empty"));
// Multiple in_progress items are now allowed for parallel workflows let too_many_active = execute_tool(
let _multi_active = execute_tool(
"TodoWrite", "TodoWrite",
&json!({ &json!({
"todos": [ "todos": [
@@ -3180,7 +3197,8 @@ mod tests {
] ]
}), }),
) )
.expect("multiple in-progress todos should succeed"); .expect_err("multiple in-progress todos should fail");
assert!(too_many_active.contains("zero or one todo items may be in_progress"));
let blank_content = execute_tool( let blank_content = execute_tool(
"TodoWrite", "TodoWrite",