2 Commits

Author SHA1 Message Date
Yeachan-Heo
705c62257c Improve terminal output so Rust CLI renders readable rich responses
The Rust CLI was still surfacing raw markdown fragments and raw tool JSON in places where the terminal UI should present styled, human-readable output. This change routes assistant text through the terminal markdown renderer, strengthens the markdown ANSI path for headings/links/lists/code blocks, and converts common tool calls/results into concise terminal-native summaries with readable bash output and edit previews.

Constraint: Must match Claude Code-style behavior without copying the upstream TypeScript source
Constraint: Keep the fix scoped to rusty-claude-cli rendering and formatting paths
Rejected: Port TS rendering components directly | prohibited by task constraints
Rejected: Leave tool JSON and only style markdown | still fails the requested terminal UX
Confidence: high
Scope-risk: moderate
Reversibility: clean
Directive: Keep tool formatting human-readable first; do not reintroduce raw JSON dumps for common tools without a fallback-only guard
Tested: cargo test -p rusty-claude-cli
Tested: cargo build --release
Not-tested: Live end-to-end API streaming against a real Anthropic session
2026-04-01 03:14:45 +00:00
Yeachan-Heo
ac95f0387c feat: allow multiple in_progress todos for parallel workflows 2026-04-01 02:55:13 +00:00
12 changed files with 1035 additions and 121 deletions

View File

@@ -0,0 +1 @@
{"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

@@ -0,0 +1 @@
{"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

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

View File

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

File diff suppressed because one or more lines are too long

22
rust/.clawd-todos.json Normal file
View File

@@ -0,0 +1,22 @@
[
{
"content": "Phase 0: Structural Cleanup — spawn 4 agents for 0.1-0.4",
"activeForm": "Executing Phase 0: Structural Cleanup via sub-agents",
"status": "in_progress"
},
{
"content": "Phase 1.1-1.2: Status bar with live HUD and token counter",
"activeForm": "Awaiting Phase 0",
"status": "pending"
},
{
"content": "Phase 2.4: Remove artificial 8ms stream delay",
"activeForm": "Awaiting Phase 0",
"status": "pending"
},
{
"content": "Phase 3.1: Collapsible tool output",
"activeForm": "Awaiting Phase 0",
"status": "pending"
}
]

View File

@@ -0,0 +1,221 @@
# 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

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

View File

@@ -0,0 +1,221 @@
# 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

@@ -22,7 +22,7 @@ use commands::{
}; };
use compat_harness::{extract_manifest, UpstreamPaths}; use compat_harness::{extract_manifest, UpstreamPaths};
use init::initialize_repo; use init::initialize_repo;
use render::{Spinner, TerminalRenderer}; use render::{MarkdownStreamState, 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,
@@ -2011,6 +2011,8 @@ 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;
@@ -2038,9 +2040,11 @@ 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() {
write!(out, "{text}") if let Some(rendered) = markdown_stream.push(&renderer, &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));
} }
} }
@@ -2051,6 +2055,11 @@ 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))
@@ -2069,6 +2078,11 @@ impl ApiClient for AnthropicRuntimeClient {
} }
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);
} }
} }
@@ -2171,56 +2185,49 @@ 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" => parsed "bash" | "Bash" => format_bash_call(&parsed),
.get("command") "read_file" | "Read" => {
.and_then(|v| v.as_str()) let path = extract_tool_path(&parsed);
.map(|cmd| truncate_for_summary(cmd, 120)) format!("\x1b[2m📄 Reading {path}\x1b[0m")
.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 = parsed let path = extract_tool_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(|v| v.as_str()) .and_then(|value| value.as_str())
.map_or(0, |c| c.lines().count()); .map_or(0, |content| content.lines().count());
format!("{path} ({lines} lines)") format!("\x1b[1;32m✏ Writing {path}\x1b[0m \x1b[2m({lines} lines)\x1b[0m")
} }
"edit_file" | "Edit" => { "edit_file" | "Edit" => {
let path = parsed let path = extract_tool_path(&parsed);
.get("file_path") let old_value = parsed
.or_else(|| parsed.get("path")) .get("old_string")
.and_then(|v| v.as_str()) .or_else(|| parsed.get("oldString"))
.unwrap_or("?"); .and_then(|value| value.as_str())
path.to_string() .unwrap_or_default();
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" => parsed "glob_search" | "Glob" => format_search_start("🔎 Glob", &parsed),
.get("pattern") "grep_search" | "Grep" => format_search_start("🔎 Grep", &parsed),
.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(|v| v.as_str()) .and_then(|value| value.as_str())
.unwrap_or("?") .unwrap_or("?")
.to_string(), .to_string(),
_ => summarize_tool_payload(input), _ => summarize_tool_payload(input),
}; };
let border = "".repeat(name.len() + 6); let border = "".repeat(name.len() + 8);
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"
) )
@@ -2232,8 +2239,269 @@ 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())
{
lines[0].push_str(&format!(" backgrounded ({task_id})"));
} else if let Some(status) = parsed
.get("returnCodeInterpretation")
.and_then(|value| value.as_str())
.filter(|status| !status.is_empty())
{
lines[0].push_str(&format!(" {status}"));
}
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(|value| value.as_u64())
.unwrap_or(1);
let num_lines = file
.get("numLines")
.and_then(|value| value.as_u64())
.unwrap_or(0);
let total_lines = file
.get("totalLines")
.and_then(|value| 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(|content| content.lines().count())
.unwrap_or(0);
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(|value| 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(|value| 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(|value| value.as_u64())
.unwrap_or(0);
let num_files = parsed
.get("numFiles")
.and_then(|value| 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 {
@@ -2264,7 +2532,8 @@ fn push_output_block(
match block { match block {
OutputContentBlock::Text { text } => { OutputContentBlock::Text { text } => {
if !text.is_empty() { if !text.is_empty() {
write!(out, "{text}") let rendered = TerminalRenderer::new().markdown_to_ansi(&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));
@@ -3056,9 +3325,35 @@ 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("read_file", r#"{"contents":"hello"}"#, false); let done = format_tool_result(
assert!(done.contains("read_file:")); "read_file",
assert!(done.contains("contents")); r#"{"file":{"filePath":"src/main.rs","content":"hello","numLines":1,"startLine":1,"totalLines":1}}"#,
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,7 +1,5 @@
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};
@@ -22,6 +20,7 @@ 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,
@@ -37,6 +36,7 @@ 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,32 +154,63 @@ 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 styled = text.to_string(); let mut style = text.stylize();
if self.strong > 0 {
styled = format!("{}", styled.bold().with(theme.strong)); if matches!(self.heading_level, Some(1 | 2)) || self.strong > 0 {
style = style.bold();
} }
if self.emphasis > 0 { if self.emphasis > 0 {
styled = format!("{}", styled.italic().with(theme.emphasis)); style = style.italic();
}
if self.quote > 0 {
styled = format!("{}", styled.with(theme.quote));
}
styled
} }
fn capture_target_mut<'a>(&'a mut self, output: &'a mut String) -> &'a mut String { if let Some(level) = self.heading_level {
if let Some(table) = self.table.as_mut() { style = match level {
&mut table.current_cell 1 => style.with(theme.heading),
} else { 2 => style.white(),
output 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 {
style = style.with(theme.quote);
}
format!("{style}")
}
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 {
output.push_str(text);
}
}
fn append_styled(&mut self, output: &mut String, text: &str, theme: &ColorTheme) {
let styled = self.style_text(text, theme);
self.append_raw(output, &styled);
} }
} }
@@ -238,6 +269,11 @@ 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,
@@ -249,15 +285,21 @@ impl TerminalRenderer {
in_code_block: &mut bool, in_code_block: &mut bool,
) { ) {
match event { match event {
Event::Start(Tag::Heading { level, .. }) => self.start_heading(level as u8, output), Event::Start(Tag::Heading { level, .. }) => {
Event::End(TagEnd::Heading(..) | TagEnd::Paragraph) => output.push_str("\n\n"), self.start_heading(state, level as u8, output)
}
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.capture_target_mut(output).push('\n'); state.append_raw(output, "\n");
} }
Event::Start(Tag::List(first_item)) => { Event::Start(Tag::List(first_item)) => {
let kind = match first_item { let kind = match first_item {
@@ -293,41 +335,52 @@ 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.capture_target_mut(output).push_str(&rendered); state.append_raw(output, &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.capture_target_mut(output).push_str(&html); state.append_raw(output, &html);
} }
Event::FootnoteReference(reference) => { Event::FootnoteReference(reference) => {
let _ = write!(state.capture_target_mut(output), "[{reference}]"); state.append_raw(output, &format!("[{reference}]"));
} }
Event::TaskListMarker(done) => { Event::TaskListMarker(done) => {
state state.append_raw(output, if done { "[x] " } else { "[ ] " });
.capture_target_mut(output)
.push_str(if done { "[x] " } else { "[ ] " });
} }
Event::InlineMath(math) | Event::DisplayMath(math) => { Event::InlineMath(math) | Event::DisplayMath(math) => {
state.capture_target_mut(output).push_str(&math); state.append_raw(output, &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!("[{dest_url}]") format!("[{label}]({})", link.destination)
.underlined() .underlined()
.with(self.color_theme.link) .with(self.color_theme.link)
); );
state.capture_target_mut(output).push_str(&rendered); state.append_raw(output, &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.capture_target_mut(output).push_str(&rendered); state.append_raw(output, &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) => {
@@ -369,19 +422,15 @@ impl TerminalRenderer {
} }
} }
Event::Start(Tag::Paragraph | Tag::MetadataBlock(..) | _) Event::Start(Tag::Paragraph | Tag::MetadataBlock(..) | _)
| Event::End(TagEnd::Link | TagEnd::Image | TagEnd::MetadataBlock(..) | _) => {} | Event::End(TagEnd::Image | TagEnd::MetadataBlock(..) | _) => {}
} }
} }
fn start_heading(&self, level: u8, output: &mut String) { fn start_heading(&self, state: &mut RenderState, 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) {
@@ -405,20 +454,27 @@ impl TerminalRenderer {
} }
fn start_code_block(&self, code_language: &str, output: &mut String) { fn start_code_block(&self, code_language: &str, output: &mut String) {
if !code_language.is_empty() { let label = if code_language.is_empty() {
"code".to_string()
} else {
code_language.to_string()
};
let _ = writeln!( let _ = writeln!(
output, output,
"{}", "{}",
format!("╭─ {code_language}").with(self.color_theme.heading) format!("╭─ {label}")
.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));
if !code_language.is_empty() { let _ = write!(
let _ = write!(output, "{}", "╰─".with(self.color_theme.heading)); output,
} "{}",
"╰─".bold().with(self.color_theme.code_block_border)
);
output.push_str("\n\n"); output.push_str("\n\n");
} }
@@ -433,8 +489,7 @@ impl TerminalRenderer {
if in_code_block { if in_code_block {
code_buffer.push_str(text); code_buffer.push_str(text);
} else { } else {
let rendered = state.style_text(text, &self.color_theme); state.append_styled(output, text, &self.color_theme);
state.capture_target_mut(output).push_str(&rendered);
} }
} }
@@ -521,9 +576,10 @@ 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) => {
colored_output.push_str(&as_24_bit_terminal_escaped(&ranges[..], false)); let escaped = as_24_bit_terminal_escaped(&ranges[..], false);
colored_output.push_str(&apply_code_block_background(&escaped));
} }
Err(_) => colored_output.push_str(line), Err(_) => colored_output.push_str(&apply_code_block_background(line)),
} }
} }
@@ -531,16 +587,83 @@ 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.render_markdown(markdown); let rendered_markdown = self.markdown_to_ansi(markdown);
for chunk in rendered_markdown.split_inclusive(char::is_whitespace) { write!(out, "{rendered_markdown}")?;
write!(out, "{chunk}")?; if !rendered_markdown.ends_with('\n') {
out.flush()?; writeln!(out)?;
thread::sleep(Duration::from_millis(8));
} }
writeln!(out) out.flush()
} }
} }
#[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()
} }
@@ -569,7 +692,7 @@ fn strip_ansi(input: &str) -> String {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{strip_ansi, Spinner, TerminalRenderer}; use super::{strip_ansi, MarkdownStreamState, Spinner, TerminalRenderer};
#[test] #[test]
fn renders_markdown_with_styling_and_lists() { fn renders_markdown_with_styling_and_lists() {
@@ -583,16 +706,28 @@ 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.render_markdown("```rust\nfn hi() { println!(\"hi\"); }\n```"); terminal_renderer.markdown_to_ansi("```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]
@@ -623,6 +758,26 @@ 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

@@ -1259,15 +1259,7 @@ 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"));
} }
let in_progress = todos // Allow multiple in_progress items for parallel workflows
.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"));
} }
@@ -2654,7 +2646,8 @@ 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"));
let too_many_active = execute_tool( // Multiple in_progress items are now allowed for parallel workflows
let _multi_active = execute_tool(
"TodoWrite", "TodoWrite",
&json!({ &json!({
"todos": [ "todos": [
@@ -2663,8 +2656,7 @@ mod tests {
] ]
}), }),
) )
.expect_err("multiple in-progress todos should fail"); .expect("multiple in-progress todos should succeed");
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",