docs(tui): add sprint plans and agent guardrails for TUI parity
7 sprints (S0-S7), 44 stories, 29-day plan to achieve feature parity with Claude Code, OpenCode, Codex CLI, and Aider terminal interfaces. Includes Opus code review fixes and Four Laws guardrails integration. Authored by TheArchitectit
This commit is contained in:
parent
58e095a0c0
commit
a347a2f0c5
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 139118958cba9c083bf78c29f3efdc0034b24d14
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
# Claw Code TUI — Feature Parity Roadmap
|
||||
|
||||
> **Branch:** `feat-tui` | **Stack:** ratatui 0.29 + crossterm 0.28 + tui-textarea 0.7
|
||||
> **Goal:** Parity with Claude Code, OpenCode, Codex CLI, Aider terminal interfaces.
|
||||
> **Guardrails:** See `.guardrails/` — Four Laws mandatory on every story.
|
||||
|
||||
---
|
||||
|
||||
## Guardrails Integration
|
||||
|
||||
### Four Laws (Applied to Every Sprint)
|
||||
|
||||
| Law | Application |
|
||||
|-----|-------------|
|
||||
| **Read Before Editing** | Read every file before modifying. No exceptions. |
|
||||
| **Stay in Scope** | Each story lists IN/OUT scope. Only touch listed files. |
|
||||
| **Verify Before Committing** | `cargo build` + `cargo test` must pass before each commit. |
|
||||
| **Halt When Uncertain** | If syntax fails, test fails, or behavior unclear — stop and ask. |
|
||||
|
||||
### Commit Format (Every Story)
|
||||
```
|
||||
<type>(tui): <story description>
|
||||
|
||||
<why this change>
|
||||
|
||||
Authored by TheArchitectit
|
||||
```
|
||||
|
||||
### Three Strikes Rule
|
||||
- MAX 3 attempts per task
|
||||
- After 3 failures → HALT, report full history, recommend fresh session
|
||||
- Rollback between attempts: `git checkout HEAD -- <file>`
|
||||
|
||||
### Pre-Execution Checklist (Every File Edit)
|
||||
```
|
||||
[ ] File read before editing
|
||||
[ ] File is IN scope for this story
|
||||
[ ] Rollback command known (git checkout HEAD -- <file>)
|
||||
[ ] No feature creep (only changes listed in story)
|
||||
[ ] Previous sprint fixes not being undone
|
||||
[ ] cargo build passes after edit
|
||||
[ ] cargo test passes after edit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sprint Summary
|
||||
|
||||
| Sprint | Title | Duration | Stories | Goal |
|
||||
|--------|-------|----------|---------|------|
|
||||
| S0 | TUI Extraction & Architecture | 4 days | 5 | Extract from 20K-line main.rs |
|
||||
| S1 | Foundation & Bug Fixes | 4 days | 7 | Make TUI reliable for daily use |
|
||||
| S2 | Streaming & Markdown Rendering | 5 days | 6 | Real-time display + rich markdown |
|
||||
| S3 | Theme System | 3 days | 5 | User customization |
|
||||
| S4 | Keybindings & Command Palette | 4 days | 5 | UX parity |
|
||||
| S5 | Chat Modes & Diff Viewer | 2 days | 4 | Aider-style workflows |
|
||||
| S6 | Agent View & Multi-Session | 4 days | 5 | Multi-agent monitoring |
|
||||
| S7 | Polish & Ship | 3 days | 7 | Mouse, CJK, sidebar, final QA |
|
||||
| **Total** | | **29 days** | **44 stories** | |
|
||||
|
||||
---
|
||||
|
||||
## Architecture Decisions
|
||||
|
||||
### ADR-1: Conversation content model
|
||||
```rust
|
||||
pub enum ConversationContent {
|
||||
Plain { text: String, color: Color, bold: bool },
|
||||
Markdown { source: String },
|
||||
CodeDiff { diff: String },
|
||||
}
|
||||
```
|
||||
|
||||
### ADR-2: Theme architecture
|
||||
`TuiTheme` struct with named color fields. Loaded from JSON, built-in defaults compiled in.
|
||||
|
||||
### ADR-3: Keybinding abstraction
|
||||
`Action` enum decoupled from `KeyEvent`. Enables Vim/Emacs/Windows presets.
|
||||
|
||||
### ADR-4: Rendering context
|
||||
Replace 10-parameter `draw_frame()` with `RenderContext` struct.
|
||||
|
||||
### ADR-5: Markdown rendering — CACHE PER ENTRY
|
||||
Cache rendered `Vec<Line>` per `ConversationLine`. Re-render only on resize. Syntect assets loaded once via `once_cell::Lazy`.
|
||||
|
||||
### ADR-6: TUI module extraction
|
||||
Extract all TUI code from `main.rs` before feature work begins.
|
||||
|
||||
---
|
||||
|
||||
## Sprint Details
|
||||
|
||||
→ [Sprint 0: TUI Extraction](./sprint-0-extraction.md)
|
||||
→ [Sprint 1: Foundation](./sprint-1-foundation.md)
|
||||
→ [Sprint 2: Streaming & Markdown](./sprint-2-markdown.md)
|
||||
→ [Sprint 3: Themes](./sprint-3-themes.md)
|
||||
→ [Sprint 4: Keybindings](./sprint-4-keybindings.md)
|
||||
→ [Sprint 5: Chat Modes](./sprint-5-chat-modes.md)
|
||||
→ [Sprint 6: Agent View](./sprint-6-agent-view.md)
|
||||
→ [Sprint 7: Polish](./sprint-7-polish.md)
|
||||
|
|
@ -0,0 +1,344 @@
|
|||
# Sprint 0: TUI Extraction & Architecture
|
||||
|
||||
> **Duration:** 4 days | **Stories:** 5 | **Goal:** Extract TUI from 20K-line main.rs, establish panic recovery, clean module boundaries
|
||||
|
||||
---
|
||||
|
||||
## Why This Sprint Exists
|
||||
|
||||
`main.rs` is **20,020 lines**. TUI code is embedded at line 7146. Every subsequent sprint adds slash commands and event handling to this file. Without extraction first:
|
||||
- Merge conflicts between sprints are guaranteed
|
||||
- Code review is impossible (who can review a 20K file?)
|
||||
- Testing individual modules is blocked
|
||||
|
||||
This sprint creates the foundation all other sprints build on.
|
||||
|
||||
---
|
||||
|
||||
## S0-1: Extract TUI integration from main.rs
|
||||
|
||||
**Priority:** P0 — Critical
|
||||
**Assignee:** —
|
||||
**Estimate:** 1.5 days
|
||||
|
||||
### Description
|
||||
Move all TUI-specific code from `main.rs` into dedicated modules. Create a clean interface boundary.
|
||||
|
||||
### New Files
|
||||
|
||||
**`src/tui_repl.rs`** — TUI event loop and integration:
|
||||
```rust
|
||||
use crate::tui::{self, TuiApp, TuiReadOutcome, SharedDashboardState};
|
||||
use crate::tui_commands::TuiCommand;
|
||||
|
||||
pub struct TuiRepl;
|
||||
|
||||
impl TuiRepl {
|
||||
/// Entry point called from main.rs when `/tui` is typed.
|
||||
pub fn run(cli: &mut LiveCli) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let dashboard_state = SharedDashboardState::default();
|
||||
let mut app = TuiApp::init(dashboard_state.clone())?;
|
||||
|
||||
app.push_banner(&[
|
||||
tui::BannerLine { text: "🦀 Claw Code".to_string(), color: ratatui::style::Color::Cyan },
|
||||
]);
|
||||
app.push_banner(&[
|
||||
tui::BannerLine { text: format_connected_line(&cli.model), color: ratatui::style::Color::DarkGray },
|
||||
]);
|
||||
|
||||
let mut pending_input: Option<String> = None;
|
||||
|
||||
loop {
|
||||
// ... event loop (currently at main.rs:7159-7260)
|
||||
// ...
|
||||
}
|
||||
|
||||
app.restore_terminal()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`src/tui_commands.rs`** — Slash command dispatch for TUI mode:
|
||||
```rust
|
||||
pub enum TuiCommand {
|
||||
Theme(String), // /theme <name>
|
||||
Keys(String), // /keys <preset>
|
||||
Code, // /code
|
||||
Ask, // /ask
|
||||
Architect, // /architect
|
||||
Diff { staged: bool },// /diff [--staged]
|
||||
Undo { confirm: bool },// /undo [--confirm]
|
||||
Ls { path: Option<String> }, // /ls [path]
|
||||
Context, // /context
|
||||
Help, // /help
|
||||
}
|
||||
|
||||
impl TuiCommand {
|
||||
pub fn parse(input: &str) -> Option<Self> { ... }
|
||||
pub fn execute(&self, app: &mut TuiApp, cli: &mut LiveCli) -> Result<(), Error> { ... }
|
||||
}
|
||||
```
|
||||
|
||||
**`src/tui_update.rs`** — Dashboard state updates:
|
||||
```rust
|
||||
pub fn update_dashboard(state: &SharedDashboardState, cli: &LiveCli) { ... }
|
||||
pub fn format_connected_line(model: &str) -> String { ... }
|
||||
pub fn strip_ansi(text: &str) -> String { ... } // moved from main.rs
|
||||
```
|
||||
|
||||
### Implementation Steps
|
||||
|
||||
1. Create `src/tui_repl.rs` — move `run_tui_repl()` body (main.rs:7146-7262)
|
||||
2. Create `src/tui_commands.rs` — extract slash command parsing and execution
|
||||
3. Create `src/tui_update.rs` — move `update_dashboard()`, `strip_ansi()`, `format_connected_line()`
|
||||
4. Update `main.rs` — replace `run_tui_repl()` with delegation:
|
||||
```rust
|
||||
fn run_tui_repl(cli: LiveCli) -> Result<(), Box<dyn std::error::Error>> {
|
||||
tui_repl::TuiRepl::run(cli)
|
||||
}
|
||||
```
|
||||
Or remove entirely and call `TuiRepl::run()` directly.
|
||||
5. Add `mod` declarations to `main.rs`:
|
||||
```rust
|
||||
mod tui_repl;
|
||||
mod tui_commands;
|
||||
mod tui_update;
|
||||
```
|
||||
6. Verify `cargo build` succeeds
|
||||
7. Verify `cargo test` still passes
|
||||
|
||||
### Acceptance Criteria
|
||||
- [ ] `run_tui_repl()` body moved to `src/tui_repl.rs`
|
||||
- [ ] Slash command handling moved to `src/tui_commands.rs`
|
||||
- [ ] `update_dashboard()` and helpers moved to `src/tui_update.rs`
|
||||
- [ ] `main.rs` reduced by ~200+ lines
|
||||
- [ ] `cargo build --release` succeeds
|
||||
- [ ] `cargo test -p rusty-claude-cli` passes
|
||||
- [ ] `/tui` command still works in the REPL
|
||||
- [ ] No behavioral changes (pure extraction)
|
||||
|
||||
---
|
||||
|
||||
## S0-2: Add panic hook for terminal cleanup
|
||||
|
||||
**Priority:** P0 — Critical
|
||||
**Assignee:** —
|
||||
**Estimate:** 0.5 day
|
||||
|
||||
### Description
|
||||
If any TUI code panics, the terminal is left in raw mode with hidden cursor and alternate screen. The user sees a broken terminal and must manually run `reset`. Add a panic hook that restores terminal state before printing the panic message.
|
||||
|
||||
### Implementation
|
||||
|
||||
**File:** `src/tui_repl.rs`
|
||||
|
||||
```rust
|
||||
use std::panic;
|
||||
use crossterm::terminal::{disable_raw_mode, LeaveAlternateScreen};
|
||||
use crossterm::execute;
|
||||
|
||||
/// Install a panic hook that restores terminal state before printing the panic.
|
||||
/// Returns the previous hook so it can be restored on clean exit.
|
||||
pub fn install_panic_hook() -> Box<dyn Fn(&panic::PanicInfo<'_>) + Send + Sync + 'static> {
|
||||
let prev_hook = panic::take_hook();
|
||||
|
||||
panic::set_hook(Box::new(|info| {
|
||||
// Best-effort terminal cleanup — ignore errors since we're panicking
|
||||
let _ = disable_raw_mode();
|
||||
let _ = execute!(std::io::stdout(), LeaveAlternateScreen);
|
||||
let _ = execute!(std::io::stdout(), crossterm::cursor::Show);
|
||||
|
||||
// Print the panic message
|
||||
let payload = if let Some(s) = info.payload().downcast_ref::<&str>() {
|
||||
s.to_string()
|
||||
} else if let Some(s) = info.payload().downcast_ref::<String>() {
|
||||
s.clone()
|
||||
} else {
|
||||
"Box<dyn Any>".to_string()
|
||||
};
|
||||
|
||||
let location = info.location()
|
||||
.map(|l| format!(" at {}:{}", l.file(), l.line()))
|
||||
.unwrap_or_default();
|
||||
|
||||
eprintln!("\n🦀 Claw TUI crashed{location}: {payload}");
|
||||
eprintln!("Terminal has been restored to normal mode.\n");
|
||||
}));
|
||||
|
||||
prev_hook
|
||||
}
|
||||
|
||||
/// Restore the previous panic hook on clean exit.
|
||||
pub fn restore_panic_hook(hook: Box<dyn Fn(&panic::PanicInfo<'_>) + Send + Sync + 'static>) {
|
||||
panic::set_hook(hook);
|
||||
}
|
||||
```
|
||||
|
||||
Usage in `TuiRepl::run()`:
|
||||
```rust
|
||||
pub fn run(cli: &mut LiveCli) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let prev_hook = install_panic_hook();
|
||||
|
||||
// ... TUI event loop ...
|
||||
|
||||
restore_panic_hook(prev_hook);
|
||||
app.restore_terminal()?;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
### Acceptance Criteria
|
||||
- [ ] Panic hook installed at TUI startup
|
||||
- [ ] Previous hook restored on clean exit
|
||||
- [ ] If TUI panics: terminal is restored, panic message printed, terminal usable
|
||||
- [ ] No double-panic (hook itself doesn't panic)
|
||||
- [ ] `disable_raw_mode()` errors are silently ignored (best-effort)
|
||||
|
||||
### Tests
|
||||
```rust
|
||||
#[test]
|
||||
fn test_panic_hook_installs_and_restores() {
|
||||
// Verify hook can be installed and restored without panic
|
||||
let prev = install_panic_hook();
|
||||
restore_panic_hook(prev);
|
||||
}
|
||||
```
|
||||
|
||||
Manual test: induce a panic in TUI code (e.g., `panic!("test")`), verify terminal is usable after.
|
||||
|
||||
---
|
||||
|
||||
## S0-3: Add terminal resize handling
|
||||
|
||||
**Priority:** P1 — High
|
||||
**Assignee:** —
|
||||
**Estimate:** 0.5 day
|
||||
|
||||
### Description
|
||||
When the terminal is resized, the TUI must re-render with the new dimensions. Currently, resize events are ignored, causing layout corruption.
|
||||
|
||||
### Implementation
|
||||
|
||||
**File:** `src/tui_repl.rs` — in the event loop:
|
||||
|
||||
```rust
|
||||
Event::Resize(width, height) => {
|
||||
// ratatui::Terminal automatically picks up the new size on next
|
||||
// draw call via `f.area()`. We just need to force a redraw and
|
||||
// re-wrap the conversation.
|
||||
app.mark_resize(width, height);
|
||||
}
|
||||
```
|
||||
|
||||
**File:** `src/tui.rs` — add to `TuiApp`:
|
||||
|
||||
```rust
|
||||
pub fn mark_resize(&mut self, _width: u16, _height: u16) {
|
||||
// Clear cached wrapped lines so they get re-wrapped at new width
|
||||
self.invalidate_render_cache();
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
```
|
||||
|
||||
### Acceptance Criteria
|
||||
- [ ] Resizing terminal triggers re-render
|
||||
- [ ] Word-wrapping adjusts to new width
|
||||
- [ ] Dashboard panel stays correctly sized
|
||||
- [ ] Input area resizes appropriately
|
||||
- [ ] No layout corruption after resize
|
||||
|
||||
### Tests
|
||||
Manual: launch TUI, resize terminal repeatedly, verify layout adapts.
|
||||
|
||||
---
|
||||
|
||||
## S0-4: Add `unicode-width` dependency
|
||||
|
||||
**Priority:** P1 — High
|
||||
**Assignee:** —
|
||||
**Estimate:** 0.1 day
|
||||
|
||||
### Description
|
||||
Add the `unicode-width` crate to `Cargo.toml` for CJK-aware character width calculations. Needed by Sprint 7's word-wrapping fix, but adding now avoids a dependency change mid-sprint.
|
||||
|
||||
### Implementation
|
||||
|
||||
**File:** `rust/crates/rusty-claude-cli/Cargo.toml`
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
# ... existing deps ...
|
||||
unicode-width = "0.2"
|
||||
```
|
||||
|
||||
### Acceptance Criteria
|
||||
- [ ] `cargo build` succeeds with new dependency
|
||||
- [ ] `unicode_width::UnicodeWidthChar` available in the crate
|
||||
|
||||
---
|
||||
|
||||
## S0-5: Define shared TUI error type
|
||||
|
||||
**Priority:** P2 — Medium
|
||||
**Assignee:** —
|
||||
**Estimate:** 0.25 day
|
||||
|
||||
### Description
|
||||
Currently, TUI functions return `Box<dyn std::error::Error>`. Define a proper error type for better error handling across modules.
|
||||
|
||||
### Implementation
|
||||
|
||||
**New file:** `src/tui_error.rs`
|
||||
|
||||
```rust
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum TuiError {
|
||||
Io(std::io::Error),
|
||||
Terminal(String),
|
||||
Runtime(String),
|
||||
Config(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for TuiError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
TuiError::Io(e) => write!(f, "TUI I/O error: {e}"),
|
||||
TuiError::Terminal(msg) => write!(f, "Terminal error: {msg}"),
|
||||
TuiError::Runtime(msg) => write!(f, "Runtime error: {msg}"),
|
||||
TuiError::Config(msg) => write!(f, "Config error: {msg}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for TuiError {}
|
||||
|
||||
impl From<std::io::Error> for TuiError {
|
||||
fn from(e: std::io::Error) -> Self {
|
||||
TuiError::Io(e)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Acceptance Criteria
|
||||
- [ ] `TuiError` enum compiles
|
||||
- [ ] `From<std::io::Error>` conversion works
|
||||
- [ ] `Display` produces readable messages
|
||||
- [ ] All new TUI modules use `TuiError` instead of `Box<dyn Error>`
|
||||
|
||||
---
|
||||
|
||||
## Sprint 0 Definition of Done
|
||||
|
||||
- [ ] All 5 stories completed
|
||||
- [ ] TUI code extracted from `main.rs` into dedicated modules
|
||||
- [ ] Panic hook restores terminal on crash
|
||||
- [ ] Terminal resize handled gracefully
|
||||
- [ ] `unicode-width` in Cargo.toml
|
||||
- [ ] `cargo build --release` succeeds
|
||||
- [ ] `cargo test -p rusty-claude-cli` passes
|
||||
- [ ] `cargo clippy -p rusty-claude-cli` has no new warnings
|
||||
- [ ] `/tui` command works identically to before extraction
|
||||
- [ ] `main.rs` line count reduced (target: -200 lines)
|
||||
|
|
@ -0,0 +1,486 @@
|
|||
# Sprint 1: Foundation & Bug Fixes
|
||||
|
||||
> **Duration:** 4 days | **Stories:** 7 | **Goal:** Make TUI reliable for daily use
|
||||
> **Depends on:** Sprint 0 (extraction must be complete)
|
||||
|
||||
---
|
||||
|
||||
## S1-1: Fix ProviderSwap terminal breakage
|
||||
|
||||
**Priority:** P0 — Critical
|
||||
**Estimate:** 0.5 day
|
||||
|
||||
### Description
|
||||
After `Ctrl+P` triggers ProviderSwap, the setup wizard runs but the terminal never returns to TUI mode. The user is stuck in a broken plain terminal.
|
||||
|
||||
### Root Cause
|
||||
`src/main.rs` (now `src/tui_repl.rs` after S0) calls `app.restore_terminal()` (leaves alt screen), runs wizard, then calls `app.suspend()` which disables raw mode but never re-enters the TUI.
|
||||
|
||||
### Implementation
|
||||
|
||||
**File:** `src/tui_repl.rs`
|
||||
|
||||
```rust
|
||||
TuiReadOutcome::ProviderSwap => {
|
||||
// Stay in alternate screen, just disable raw mode for the wizard
|
||||
app.suspend()?;
|
||||
|
||||
setup_wizard::run_setup_wizard()?;
|
||||
|
||||
// Reload config and update model
|
||||
let cwd = std::env::current_dir().unwrap_or_default();
|
||||
if let Ok(config) = runtime::ConfigLoader::default_for(&cwd).load() {
|
||||
if let Some(new_model) = config.provider().model().map(str::to_string) {
|
||||
let _ = cli.set_model(Some(new_model));
|
||||
}
|
||||
}
|
||||
|
||||
// Re-enter TUI
|
||||
app.resume()?;
|
||||
app.push_system_message("Provider updated");
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** `suspend()` stays in alternate screen (just disables raw mode). `resume()` re-enables raw mode and redraws. The wizard runs on the alternate screen with echoing stdin, which is fine for text prompts. If the wizard needs a full normal terminal, use `app.restore_terminal()` → wizard → `TuiApp::init()` to fully reinitialize. Test both approaches.
|
||||
|
||||
### Acceptance Criteria
|
||||
- [ ] `Ctrl+P` runs setup wizard
|
||||
- [ ] After wizard completes, TUI resumes automatically
|
||||
- [ ] Provider/model updated in dashboard
|
||||
- [ ] No terminal corruption after repeated ProviderSwap cycles (test 5x)
|
||||
|
||||
---
|
||||
|
||||
## S1-2: Fix status message race condition
|
||||
|
||||
**Priority:** P0 — Critical
|
||||
**Estimate:** 0.1 day
|
||||
|
||||
### Description
|
||||
"Done" status message is set and immediately cleared in the same event loop tick, so it's never visible.
|
||||
|
||||
### Root Cause
|
||||
```rust
|
||||
app.set_status("Done");
|
||||
if let Ok(mut ds) = dashboard_state.write() {
|
||||
ds.status_message.clear(); // immediately clears — never rendered
|
||||
}
|
||||
```
|
||||
|
||||
### Implementation
|
||||
Remove the `ds.status_message.clear()` block. Status will be naturally overwritten when the next turn starts with `set_status("Thinking...")`.
|
||||
|
||||
### Acceptance Criteria
|
||||
- [ ] "✓ Done" visible in dashboard for at least 1 second after turn completes
|
||||
- [ ] Next turn's "Thinking..." overwrites it
|
||||
|
||||
---
|
||||
|
||||
## S1-3: Deduplicate ANSI strippers
|
||||
|
||||
**Priority:** P1 — High
|
||||
**Estimate:** 0.5 day
|
||||
|
||||
### Description
|
||||
Two independent ANSI escape strippers:
|
||||
- `strip_ansi_escapes()` in `src/tui.rs`
|
||||
- `strip_ansi()` in `src/main.rs` (now `src/tui_update.rs` after S0)
|
||||
|
||||
Neither handles OSC sequences (`ESC ]...BEL` or `ESC ]...ST`).
|
||||
|
||||
### Implementation
|
||||
|
||||
**File:** `src/tui_update.rs` (after S0 extraction)
|
||||
|
||||
Keep one canonical implementation, exported as `pub`:
|
||||
|
||||
```rust
|
||||
/// Strip ANSI escape sequences from text.
|
||||
/// Handles CSI (ESC [), OSC (ESC ]), and single-char escapes.
|
||||
pub fn strip_ansi(text: &str) -> String {
|
||||
let mut result = String::with_capacity(text.len());
|
||||
let mut chars = text.chars().peekable();
|
||||
|
||||
while let Some(ch) = chars.next() {
|
||||
if ch == '\x1b' {
|
||||
match chars.peek() {
|
||||
Some('[') => {
|
||||
// CSI sequence: ESC [ ... final_char
|
||||
chars.next(); // consume '['
|
||||
while let Some(&c) = chars.peek() {
|
||||
chars.next();
|
||||
if c.is_ascii_alphabetic() || c == '~' {
|
||||
break; // final character
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(']') => {
|
||||
// OSC sequence: ESC ] ... (BEL | ESC \)
|
||||
chars.next(); // consume ']'
|
||||
while let Some(&c) = chars.peek() {
|
||||
chars.next();
|
||||
if c == '\x07' {
|
||||
break; // BEL terminator
|
||||
}
|
||||
if c == '\x1b' {
|
||||
// Could be ESC \ (ST)
|
||||
if chars.peek() == Some(&'\\') {
|
||||
chars.next();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some('P') | Some('_') | Some('^') => {
|
||||
// DCS, APC, PM — terminated by ESC \
|
||||
chars.next();
|
||||
while let Some(&c) = chars.peek() {
|
||||
chars.next();
|
||||
if c == '\x1b' && chars.peek() == Some(&'\\') {
|
||||
chars.next();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// Single-char escape (e.g., ESC c for reset)
|
||||
chars.next();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result.push(ch);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
```
|
||||
|
||||
Delete `strip_ansi_escapes()` from `tui.rs`. Update all callers.
|
||||
|
||||
### Tests
|
||||
```rust
|
||||
#[test]
|
||||
fn test_strip_basic_csi() {
|
||||
assert_eq!(strip_ansi("\x1b[31mred\x1b[0m"), "red");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_osc_hyperlink() {
|
||||
assert_eq!(
|
||||
strip_ansi("\x1b]8;;http://example.com\x07link\x1b]8;;\x07"),
|
||||
"link"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_osc_with_st_terminator() {
|
||||
assert_eq!(
|
||||
strip_ansi("\x1b]8;;http://example.com\x1b\\link\x1b]8;;\x1b\\"),
|
||||
"link"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_nested_csi() {
|
||||
assert_eq!(strip_ansi("\x1b[1m\x1b[31mbold red\x1b[0m"), "bold red");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_256_color() {
|
||||
assert_eq!(strip_ansi("\x1b[38;5;196mred256\x1b[0m"), "red256");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_rgb_color() {
|
||||
assert_eq!(strip_ansi("\x1b[38;2;255;0;0mrgb\x1b[0m"), "rgb");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_no_escapes() {
|
||||
assert_eq!(strip_ansi("plain text"), "plain text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_cursor_movement() {
|
||||
assert_eq!(strip_ansi("\x1b[2J\x1b[Hclear"), "clear");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_dcs_sequence() {
|
||||
assert_eq!(strip_ansi("\x1bPq$d\x1b\\"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_empty_string() {
|
||||
assert_eq!(strip_ansi(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_strip_malformed_no_final_char() {
|
||||
// Unterminated CSI — should still consume until end
|
||||
assert_eq!(strip_ansi("\x1b[31m"), "");
|
||||
}
|
||||
```
|
||||
|
||||
### Acceptance Criteria
|
||||
- [ ] Single ANSI stripper implementation in `tui_update.rs`
|
||||
- [ ] Handles CSI, OSC (BEL + ST), DCS, APC, PM sequences
|
||||
- [ ] All 11 test cases pass
|
||||
- [ ] No ANSI artifacts visible in TUI conversation pane
|
||||
|
||||
---
|
||||
|
||||
## S1-4: Bound conversation memory
|
||||
|
||||
**Priority:** P1 — High
|
||||
**Estimate:** 0.5 day
|
||||
|
||||
### Description
|
||||
`Vec<ConversationLine>` grows unbounded. Long sessions consume increasing memory and slow `build_wrapped_conversation()`.
|
||||
|
||||
### Implementation
|
||||
|
||||
**File:** `src/tui.rs`
|
||||
|
||||
```rust
|
||||
const MAX_CONVERSATION_LINES: usize = 10_000;
|
||||
|
||||
fn auto_scroll(&mut self) {
|
||||
if self.conversation.len() > MAX_CONVERSATION_LINES {
|
||||
let drain_count = self.conversation.len() - MAX_CONVERSATION_LINES;
|
||||
self.conversation.drain(..drain_count);
|
||||
// Also drain cached render results for removed entries
|
||||
self.render_cache.drain(..drain_count);
|
||||
|
||||
// Insert trim notice as first line
|
||||
self.conversation.insert(0, ConversationLine {
|
||||
content: ConversationContent::Plain {
|
||||
text: "... (earlier messages trimmed)".to_string(),
|
||||
color: Color::DarkGray,
|
||||
bold: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
self.conversation_scroll = 0;
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
```
|
||||
|
||||
### Acceptance Criteria
|
||||
- [ ] Conversation never exceeds MAX_CONVERSATION_LINES
|
||||
- [ ] Oldest messages trimmed first
|
||||
- [ ] Trim notice appears as first line
|
||||
- [ ] Render cache also trimmed (prevents desync)
|
||||
- [ ] Memory stays flat in long sessions
|
||||
|
||||
### Test
|
||||
```rust
|
||||
#[test]
|
||||
fn test_conversation_memory_bound() {
|
||||
let mut app = create_test_app();
|
||||
for i in 0..12_000 {
|
||||
app.push_output(&format!("line {i}"), false);
|
||||
}
|
||||
assert!(app.conversation_len() <= MAX_CONVERSATION_LINES);
|
||||
// First line should be trim notice
|
||||
assert!(app.conversation_text(0).contains("trimmed"));
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## S1-5: Wire real token tracking
|
||||
|
||||
**Priority:** P1 — High
|
||||
**Estimate:** 1 day
|
||||
|
||||
### Description
|
||||
Dashboard shows `output_tokens: 0`, `cost_usd: 0.0`, `cache_read_tokens: 0` because `update_dashboard()` uses a crude char÷4 estimate. The runtime tracks real values.
|
||||
|
||||
### Implementation
|
||||
|
||||
**File:** `src/tui_update.rs`
|
||||
|
||||
The runtime's `TokenUsage` struct (from `runtime/src/usage.rs`):
|
||||
```rust
|
||||
// ACTUAL runtime struct — use these field names:
|
||||
pub struct TokenUsage {
|
||||
pub input_tokens: u32,
|
||||
pub output_tokens: u32,
|
||||
pub cache_creation_input_tokens: u32, // NOT "cache_creation_tokens"
|
||||
pub cache_read_input_tokens: u32, // NOT "cache_read_tokens"
|
||||
}
|
||||
```
|
||||
|
||||
Cost is computed separately via `UsageCostEstimate::total_cost_usd()`.
|
||||
|
||||
Update `update_dashboard()`:
|
||||
```rust
|
||||
pub fn update_dashboard(state: &SharedDashboardState, cli: &LiveCli) {
|
||||
if let Ok(mut ds) = state.write() {
|
||||
ds.model = cli.model.clone();
|
||||
ds.permission_mode = format!("{:?}", cli.permission_mode);
|
||||
|
||||
if let Some(rt) = cli.runtime.runtime.as_ref() {
|
||||
let session = rt.session();
|
||||
ds.session_id = Some(session.session_id.clone());
|
||||
ds.turn_count = session.messages.len() as u32;
|
||||
|
||||
// Use REAL token counts from runtime
|
||||
if let Some(usage) = session.total_usage() {
|
||||
ds.input_tokens = usage.input_tokens;
|
||||
ds.output_tokens = usage.output_tokens;
|
||||
ds.cache_creation_tokens = usage.cache_creation_input_tokens;
|
||||
ds.cache_read_tokens = usage.cache_read_input_tokens;
|
||||
}
|
||||
|
||||
// Compute cost using runtime's cost estimator
|
||||
if let Some(cost) = session.estimate_cost() {
|
||||
ds.cost_usd = cost.total_cost_usd();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** Verify the actual method names on the runtime session object (`total_usage()`, `estimate_cost()`, etc.) by reading `runtime/src/`. Adjust to match the real API.
|
||||
|
||||
### Acceptance Criteria
|
||||
- [ ] Dashboard shows real input/output token counts after each turn
|
||||
- [ ] Cache read and cache creation tokens displayed
|
||||
- [ ] Cost estimate shows non-zero value for paid models
|
||||
- [ ] Field names match runtime's `TokenUsage` struct exactly
|
||||
|
||||
### Test
|
||||
Manual: send a message, verify dashboard numbers are non-zero and match API response logs.
|
||||
|
||||
---
|
||||
|
||||
## S1-6: Wire terminal resize to conversation re-wrap
|
||||
|
||||
**Priority:** P1 — High
|
||||
**Estimate:** 0.25 day
|
||||
|
||||
### Description
|
||||
S0-3 handles resize events. This story ensures the conversation rendering cache is invalidated so word-wrapping adjusts to the new width.
|
||||
|
||||
### Implementation
|
||||
|
||||
**File:** `src/tui.rs`
|
||||
|
||||
```rust
|
||||
pub fn mark_resize(&mut self, _width: u16, _height: u16) {
|
||||
// Invalidate cached wrapped lines — they'll be re-wrapped at new width
|
||||
self.invalidate_render_cache();
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
|
||||
fn invalidate_render_cache(&mut self) {
|
||||
// Clear all cached Vec<Line> — next frame re-renders everything
|
||||
// This is fine: resize is infrequent and a single re-render is fast
|
||||
self.render_cache.clear();
|
||||
}
|
||||
```
|
||||
|
||||
### Acceptance Criteria
|
||||
- [ ] Terminal resize triggers full re-render
|
||||
- [ ] Word-wrapping adjusts to new width
|
||||
- [ ] No layout corruption
|
||||
|
||||
---
|
||||
|
||||
## S1-7: Write TUI unit tests
|
||||
|
||||
**Priority:** P1 — High
|
||||
**Estimate:** 1 day
|
||||
|
||||
### Description
|
||||
Zero test coverage for TUI logic. The rendering code doesn't need terminal tests, but the data transformations do.
|
||||
|
||||
### Implementation
|
||||
|
||||
**New file:** `tests/tui_unit_tests.rs`
|
||||
|
||||
```rust
|
||||
// 1. ANSI stripping — see S1-3 for full test list
|
||||
|
||||
// 2. Word wrapping
|
||||
#[test]
|
||||
fn test_wrap_empty() {
|
||||
assert_eq!(wrap_line("", 80), vec![""]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrap_exact_width() {
|
||||
assert_eq!(wrap_line("hello", 5), vec!["hello"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrap_overflow_with_break() {
|
||||
let wrapped = wrap_line("hello world foo", 10);
|
||||
assert!(wrapped.len() >= 2);
|
||||
assert!(wrapped[0].len() <= 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrap_hyphen_break() {
|
||||
let wrapped = wrap_line("well-known-author", 10);
|
||||
assert!(wrapped.len() >= 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrap_no_break_point() {
|
||||
// Hard break mid-word when no space/hyphen available
|
||||
let wrapped = wrap_line("abcdefghijklmnop", 5);
|
||||
assert!(wrapped.len() >= 3);
|
||||
}
|
||||
|
||||
// 3. Dashboard state
|
||||
#[test]
|
||||
fn test_dashboard_default_values() {
|
||||
let state = DashboardState::default();
|
||||
assert_eq!(state.input_tokens, 0);
|
||||
assert_eq!(state.output_tokens, 0);
|
||||
assert_eq!(state.cost_usd, 0.0);
|
||||
}
|
||||
|
||||
// 4. Conversation management
|
||||
#[test]
|
||||
fn test_push_user_input() {
|
||||
let mut app = create_test_app();
|
||||
app.push_user_input("hello");
|
||||
assert!(app.conversation_len() >= 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_push_output_strips_ansi() {
|
||||
let mut app = create_test_app();
|
||||
app.push_output("\x1b[31mred\x1b[0m", false);
|
||||
// Should not contain escape characters
|
||||
let text = app.conversation_text(app.conversation_len() - 1);
|
||||
assert!(!text.contains('\x1b'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_auto_scroll_resets_offset() {
|
||||
let mut app = create_test_app();
|
||||
app.conversation_scroll = 100;
|
||||
app.push_user_input("new message");
|
||||
assert_eq!(app.conversation_scroll, 0);
|
||||
}
|
||||
```
|
||||
|
||||
### Acceptance Criteria
|
||||
- [ ] All tests pass with `cargo test -p rusty-claude-cli`
|
||||
- [ ] Tests cover: ANSI stripping, word wrapping, dashboard state, conversation management
|
||||
- [ ] No terminal or ratatui dependencies in tests (pure data logic only)
|
||||
|
||||
---
|
||||
|
||||
## Sprint 1 Definition of Done
|
||||
|
||||
- [ ] All 7 stories completed and tested
|
||||
- [ ] `cargo test -p rusty-claude-cli` passes
|
||||
- [ ] `cargo clippy -p rusty-claude-cli` has no new warnings
|
||||
- [ ] Manual smoke test: launch TUI, send 3 messages, Ctrl+P provider swap, verify dashboard updates, resize terminal
|
||||
- [ ] Commit: `fix(tui): sprint 1 — foundation and bug fixes`
|
||||
|
|
@ -0,0 +1,883 @@
|
|||
# Sprint 2: Streaming & Markdown Rendering
|
||||
|
||||
> **Duration:** 5 days | **Stories:** 6 | **Goal:** Real-time response display + rich markdown rendering
|
||||
> **Depends on:** Sprint 0 (extraction), Sprint 1 (bug fixes)
|
||||
|
||||
---
|
||||
|
||||
## Why This Sprint Is 5 Days (Not 3)
|
||||
|
||||
Opus review correctly identified:
|
||||
1. **Streaming rendering is missing entirely** — table stakes for any TUI competitor
|
||||
2. **Syntect API errors** — `HighlightLines::new()` returns `Result`, code won't compile
|
||||
3. **Syntect initialization is expensive** (10-50ms) — needs lazy loading
|
||||
4. **Markdown rendering on every frame is too slow** — needs per-entry caching
|
||||
5. **`looks_like_markdown()` heuristics** need careful handling
|
||||
|
||||
---
|
||||
|
||||
## S2-1: Add streaming response rendering
|
||||
|
||||
**Priority:** P0 — Critical
|
||||
**Estimate:** 1.5 days
|
||||
|
||||
### Description
|
||||
Currently, assistant responses are captured into a `Vec<u8>` buffer and rendered only after the turn completes. All competitors (Claude Code, OpenCode, Codex CLI) stream responses in real-time. The user should see tokens appear as they're generated.
|
||||
|
||||
### Implementation
|
||||
|
||||
**File:** `src/tui.rs` — add streaming state to `TuiApp`:
|
||||
|
||||
```rust
|
||||
pub struct TuiApp {
|
||||
// ... existing fields
|
||||
|
||||
/// Streaming state for current assistant response
|
||||
stream_buffer: String,
|
||||
stream_is_active: bool,
|
||||
stream_last_update: std::time::Instant,
|
||||
}
|
||||
|
||||
impl TuiApp {
|
||||
/// Called by the streaming callback as new tokens arrive.
|
||||
/// Appends to the streaming buffer and triggers a redraw.
|
||||
pub fn push_stream_token(&mut self, token: &str) {
|
||||
self.stream_buffer.push_str(token);
|
||||
self.stream_is_active = true;
|
||||
self.stream_last_update = std::time::Instant::now();
|
||||
self.needs_redraw = true;
|
||||
}
|
||||
|
||||
/// Called when the assistant's response is complete.
|
||||
/// Moves stream buffer into permanent conversation and resets.
|
||||
pub fn finish_stream(&mut self) {
|
||||
if !self.stream_buffer.is_empty() {
|
||||
// Check if the complete response looks like markdown
|
||||
if looks_like_markdown(&self.stream_buffer) {
|
||||
self.conversation.push(ConversationLine {
|
||||
content: ConversationContent::Markdown {
|
||||
source: self.stream_buffer.clone(),
|
||||
},
|
||||
rendered_cache: None, // Will be rendered on next frame
|
||||
});
|
||||
} else {
|
||||
for line in self.stream_buffer.lines() {
|
||||
self.conversation.push(ConversationLine {
|
||||
content: ConversationContent::Plain {
|
||||
text: line.to_string(),
|
||||
color: Color::White,
|
||||
bold: false,
|
||||
},
|
||||
rendered_cache: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
self.stream_buffer.clear();
|
||||
self.stream_is_active = false;
|
||||
self.auto_scroll();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**File:** `src/tui_repl.rs` — modify turn execution:
|
||||
|
||||
```rust
|
||||
// In the turn execution loop, instead of buffering to Vec<u8>:
|
||||
let stream_state = app.stream_state_ref();
|
||||
let result = cli.run_turn_streaming(&trimmed, |token: &str| {
|
||||
// Called by the runtime as each token arrives
|
||||
stream_state.push_stream_token(token);
|
||||
});
|
||||
app.finish_stream();
|
||||
```
|
||||
|
||||
**File:** `src/tui.rs` — render streaming content in conversation pane:
|
||||
|
||||
```rust
|
||||
fn build_conversation_lines(&self, width: u16) -> Vec<Line<'static>> {
|
||||
let mut all_lines = Vec::new();
|
||||
|
||||
// Render completed conversation entries (cached)
|
||||
for entry in &self.conversation {
|
||||
if let Some(cached) = &entry.rendered_cache {
|
||||
all_lines.extend(cached.iter().cloned());
|
||||
} else {
|
||||
let rendered = self.render_entry(entry, width);
|
||||
all_lines.extend(rendered.iter().cloned());
|
||||
}
|
||||
}
|
||||
|
||||
// Render in-progress streaming content
|
||||
if self.stream_is_active && !self.stream_buffer.is_empty() {
|
||||
// Simple rendering for streaming — full markdown render happens on finish
|
||||
for line in self.stream_buffer.lines() {
|
||||
all_lines.push(Line::from(Span::styled(
|
||||
line.to_string(),
|
||||
Style::default().fg(Color::White),
|
||||
)));
|
||||
}
|
||||
// Show cursor at end of stream
|
||||
all_lines.push(Line::from(Span::styled(
|
||||
"▊",
|
||||
Style::default().fg(Color::Cyan),
|
||||
)));
|
||||
}
|
||||
|
||||
all_lines
|
||||
}
|
||||
```
|
||||
|
||||
### Architecture Note
|
||||
|
||||
The streaming callback approach requires the runtime to support streaming. If the runtime doesn't have a streaming callback API, implement it as:
|
||||
1. Background thread runs the turn
|
||||
2. Token sender channel sends tokens to TUI thread
|
||||
3. TUI polls the channel in its 16ms event loop
|
||||
|
||||
```rust
|
||||
// Channel-based streaming if callback API isn't available:
|
||||
let (tx, rx) = std::sync::mpsc::channel::<String>();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
// Run turn, send tokens to channel
|
||||
cli.run_turn_with_callback(&input, |token| {
|
||||
let _ = tx.send(token.to_string());
|
||||
});
|
||||
});
|
||||
|
||||
// In TUI event loop:
|
||||
while let Ok(token) = rx.try_recv() {
|
||||
app.push_stream_token(&token);
|
||||
}
|
||||
```
|
||||
|
||||
### Acceptance Criteria
|
||||
- [ ] Assistant tokens appear in conversation as they arrive
|
||||
- [ ] Blinking cursor shows at end of streaming content
|
||||
- [ ] Markdown rendering happens only after stream completes (not per-token)
|
||||
- [ ] Streaming doesn't block TUI event loop (UI stays responsive)
|
||||
- [ ] Fast streams (e.g., Haiku) don't flicker
|
||||
- [ ] Slow streams (e.g., Opus thinking) show smooth character-by-character display
|
||||
|
||||
---
|
||||
|
||||
## S2-2: Create ConversationContent enum with render cache
|
||||
|
||||
**Priority:** P1 — High
|
||||
**Estimate:** 0.25 day
|
||||
|
||||
### Description
|
||||
Replace flat `ConversationLine` with enum + per-entry render cache. This enables markdown rendering without per-frame re-parsing.
|
||||
|
||||
### Implementation
|
||||
|
||||
**File:** `src/tui.rs`
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ConversationContent {
|
||||
Plain {
|
||||
text: String,
|
||||
color: Color,
|
||||
bold: bool,
|
||||
},
|
||||
Markdown {
|
||||
source: String,
|
||||
},
|
||||
CodeDiff {
|
||||
diff: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ConversationLine {
|
||||
pub content: ConversationContent,
|
||||
/// Cached rendered output. None = needs rendering.
|
||||
/// Stored as Vec<Line> to avoid re-parsing markdown every frame.
|
||||
pub rendered_cache: Option<Vec<Line<'static>>>,
|
||||
}
|
||||
|
||||
impl ConversationLine {
|
||||
pub fn plain(text: String, color: Color, bold: bool) -> Self {
|
||||
Self {
|
||||
content: ConversationContent::Plain { text, color, bold },
|
||||
rendered_cache: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn markdown(source: String) -> Self {
|
||||
Self {
|
||||
content: ConversationContent::Markdown { source },
|
||||
rendered_cache: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn diff(diff: String) -> Self {
|
||||
Self {
|
||||
content: ConversationContent::CodeDiff { diff },
|
||||
rendered_cache: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Acceptance Criteria
|
||||
- [ ] `ConversationContent` enum compiles
|
||||
- [ ] `rendered_cache` field exists on `ConversationLine`
|
||||
- [ ] All existing push methods migrated to use enum
|
||||
- [ ] Constructor methods (`plain`, `markdown`, `diff`) work
|
||||
|
||||
---
|
||||
|
||||
## S2-3: Create MarkdownRenderer with lazy syntect init
|
||||
|
||||
**Priority:** P1 — High
|
||||
**Estimate:** 1 day
|
||||
|
||||
### Description
|
||||
Create the markdown renderer. **Fix the syntect compilation errors** identified by Opus. Use `once_cell::Lazy` for expensive syntax/theme set loading.
|
||||
|
||||
### Dependencies
|
||||
|
||||
Add to `Cargo.toml`:
|
||||
```toml
|
||||
once_cell = "1"
|
||||
```
|
||||
|
||||
### Implementation
|
||||
|
||||
**New file:** `src/markdown.rs`
|
||||
|
||||
```rust
|
||||
use once_cell::sync::Lazy;
|
||||
use pulldown_cmark::{Event, Parser, Tag, TagEnd};
|
||||
use ratatui::style::{Color, Modifier, Style};
|
||||
use ratatui::text::{Line, Span};
|
||||
use syntect::highlighting::ThemeSet;
|
||||
use syntect::parsing::SyntaxSet;
|
||||
|
||||
// Lazy-loaded — initialized once on first use (~10-50ms), then cached for process lifetime
|
||||
static SYNTAX_SET: Lazy<SyntaxSet> = Lazy::new(|| SyntaxSet::load_defaults_newlines());
|
||||
static THEME_SET: Lazy<ThemeSet> = Lazy::new(|| ThemeSet::load_defaults());
|
||||
|
||||
pub struct MarkdownRenderer {
|
||||
code_theme_name: String,
|
||||
}
|
||||
|
||||
impl MarkdownRenderer {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
code_theme_name: "base16-ocean.dark".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_code_theme(&mut self, name: &str) {
|
||||
self.code_theme_name = name.to_string();
|
||||
}
|
||||
|
||||
pub fn render(&self, markdown: &str, width: u16) -> Vec<Line<'static>> {
|
||||
let parser = Parser::new(markdown);
|
||||
let events: Vec<Event> = parser.collect();
|
||||
self.render_events(&events, width)
|
||||
}
|
||||
|
||||
fn render_events(&self, events: &[Event], width: u16) -> Vec<Line<'static>> {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
let mut current_spans: Vec<Span<'static>> = Vec::new();
|
||||
let mut style_stack: Vec<Style> = Vec::new();
|
||||
let mut in_code_block = false;
|
||||
let mut code_block_lang: Option<String> = None;
|
||||
let mut code_block_content = String::new();
|
||||
let mut list_depth: usize = 0;
|
||||
|
||||
for event in events {
|
||||
match event {
|
||||
Event::Start(tag) => {
|
||||
match tag {
|
||||
Tag::Heading { .. } => {
|
||||
flush_line(&mut lines, &mut current_spans);
|
||||
style_stack.push(Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD));
|
||||
}
|
||||
Tag::Paragraph => {
|
||||
style_stack.push(Style::default());
|
||||
}
|
||||
Tag::CodeBlock(kind) => {
|
||||
flush_line(&mut lines, &mut current_spans);
|
||||
in_code_block = true;
|
||||
code_block_content.clear();
|
||||
code_block_lang = match kind {
|
||||
pulldown_cmark::CodeBlockKind::Fenced(lang) => {
|
||||
let lang_str = lang.to_string();
|
||||
if lang_str.is_empty() { None } else { Some(lang_str) }
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
}
|
||||
Tag::List(_) => {
|
||||
list_depth += 1;
|
||||
}
|
||||
Tag::Item => {
|
||||
let indent = " ".repeat(list_depth.saturating_sub(1));
|
||||
current_spans.push(Span::raw(format!("{indent}• ")));
|
||||
}
|
||||
Tag::BlockQuote => {
|
||||
current_spans.push(Span::styled(
|
||||
"│ ",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
));
|
||||
style_stack.push(Style::default().fg(Color::DarkGray));
|
||||
}
|
||||
Tag::Emphasis => {
|
||||
style_stack.push(Style::default().add_modifier(Modifier::ITALIC));
|
||||
}
|
||||
Tag::Strong => {
|
||||
style_stack.push(Style::default().add_modifier(Modifier::BOLD));
|
||||
}
|
||||
Tag::Link { .. } => {
|
||||
style_stack.push(Style::default()
|
||||
.fg(Color::Blue)
|
||||
.add_modifier(Modifier::UNDERLINED));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Event::End(tag_end) => {
|
||||
match tag_end {
|
||||
TagEnd::Heading(_) => {
|
||||
flush_line(&mut lines, &mut current_spans);
|
||||
lines.push(Line::from(""));
|
||||
style_stack.pop();
|
||||
}
|
||||
TagEnd::Paragraph => {
|
||||
flush_line(&mut lines, &mut current_spans);
|
||||
lines.push(Line::from(""));
|
||||
style_stack.pop();
|
||||
}
|
||||
TagEnd::CodeBlock => {
|
||||
let code_lines = self.render_code_block(
|
||||
&code_block_content,
|
||||
code_block_lang.as_deref(),
|
||||
);
|
||||
lines.extend(code_lines);
|
||||
in_code_block = false;
|
||||
code_block_lang = None;
|
||||
}
|
||||
TagEnd::List(_) => {
|
||||
list_depth = list_depth.saturating_sub(1);
|
||||
}
|
||||
TagEnd::Item => {
|
||||
flush_line(&mut lines, &mut current_spans);
|
||||
}
|
||||
TagEnd::BlockQuote => {
|
||||
flush_line(&mut lines, &mut current_spans);
|
||||
style_stack.pop();
|
||||
}
|
||||
TagEnd::Emphasis | TagEnd::Strong | TagEnd::Link => {
|
||||
style_stack.pop();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Event::Text(text) => {
|
||||
if in_code_block {
|
||||
code_block_content.push_str(&text);
|
||||
} else {
|
||||
let style = style_stack.last().copied().unwrap_or_default();
|
||||
current_spans.push(Span::styled(text.to_string(), style));
|
||||
}
|
||||
}
|
||||
Event::Code(code) => {
|
||||
let style = Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.bg(Color::DarkGray);
|
||||
current_spans.push(Span::styled(
|
||||
format!(" {code} "),
|
||||
style,
|
||||
));
|
||||
}
|
||||
Event::SoftBreak | Event::HardBreak => {
|
||||
if in_code_block {
|
||||
code_block_content.push('\n');
|
||||
} else {
|
||||
flush_line(&mut lines, &mut current_spans);
|
||||
}
|
||||
}
|
||||
Event::Rule => {
|
||||
flush_line(&mut lines, &mut current_spans);
|
||||
lines.push(Line::from(Span::styled(
|
||||
"─".repeat(width as usize),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
flush_line(&mut lines, &mut current_spans);
|
||||
lines
|
||||
}
|
||||
|
||||
fn render_code_block(&self, code: &str, language: Option<&str>) -> Vec<Line<'static>> {
|
||||
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||
let syntax = language
|
||||
.and_then(|lang| SYNTAX_SET.find_syntax_by_token(lang))
|
||||
.unwrap_or_else(|| SYNTAX_SET.find_syntax_plain_text());
|
||||
|
||||
// FIX: ThemeSet::load_defaults() may not contain our theme name.
|
||||
// Use .get() with fallback instead of [] indexing (which panics).
|
||||
let theme = THEME_SET.themes
|
||||
.get(&self.code_theme_name)
|
||||
.or_else(|| THEME_SET.themes.get("base16-ocean.dark"))
|
||||
.unwrap_or_else(|| THEME_SET.themes.values().next().expect("no themes available"));
|
||||
|
||||
// Top border
|
||||
let lang_label = language.unwrap_or("text");
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!("╭─ {lang_label} ─"),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)));
|
||||
|
||||
// FIX: syntect 5.x HighlightLines::new() returns Result, not direct value.
|
||||
let mut highlighter = match syntect::easy::HighlightLines::new(syntax, theme) {
|
||||
Ok(h) => h,
|
||||
Err(_) => {
|
||||
// Fallback: render without highlighting
|
||||
for line in code.lines() {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("│ ", Style::default().fg(Color::DarkGray)),
|
||||
Span::raw(line.to_string()),
|
||||
]));
|
||||
}
|
||||
lines.push(Line::from(Span::styled("╰─", Style::default().fg(Color::DarkGray))));
|
||||
return lines;
|
||||
}
|
||||
};
|
||||
|
||||
for line in code.lines() {
|
||||
match highlighter.highlight_line(line, &SYNTAX_SET) {
|
||||
Ok(ranges) => {
|
||||
let spans: Vec<Span<'static>> = ranges
|
||||
.into_iter()
|
||||
.map(|(style, text)| {
|
||||
let fg = style.foreground;
|
||||
Span::styled(
|
||||
text.to_string(),
|
||||
Style::default().fg(Color::Rgb(fg.r, fg.g, fg.b)),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let mut line_spans = vec![
|
||||
Span::styled("│ ", Style::default().fg(Color::DarkGray)),
|
||||
];
|
||||
line_spans.extend(spans);
|
||||
lines.push(Line::from(line_spans));
|
||||
}
|
||||
Err(_) => {
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("│ ", Style::default().fg(Color::DarkGray)),
|
||||
Span::raw(line.to_string()),
|
||||
]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lines.push(Line::from(Span::styled(
|
||||
"╰─",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)));
|
||||
lines
|
||||
}
|
||||
}
|
||||
|
||||
/// Flush accumulated spans into a new line.
|
||||
fn flush_line(lines: &mut Vec<Line<'static>>, spans: &mut Vec<Span<'static>>) {
|
||||
if !spans.is_empty() {
|
||||
lines.push(Line::from(spans.clone()));
|
||||
spans.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Heuristic: does this text look like markdown?
|
||||
pub fn looks_like_markdown(text: &str) -> bool {
|
||||
let lines: Vec<&str> = text.lines().collect();
|
||||
let has_header = lines.iter().any(|l| l.starts_with('#'));
|
||||
let has_code_block = text.contains("```");
|
||||
let has_list = lines.iter().any(|l| l.starts_with("- ") || l.starts_with("* "));
|
||||
let has_bold = text.contains("**");
|
||||
let has_inline_code = text.matches('`').count() >= 2; // At least one pair
|
||||
let multi_line = lines.len() > 3;
|
||||
|
||||
has_code_block
|
||||
|| (has_header && multi_line)
|
||||
|| (has_list && multi_line)
|
||||
|| (has_bold && has_inline_code)
|
||||
}
|
||||
```
|
||||
|
||||
### Syntect Fixes Applied
|
||||
|
||||
1. **`HighlightLines::new()` returns `Result`** — now handled with `match` and fallback to plain text
|
||||
2. **Theme name may not exist** — `.get()` with fallback chain instead of `[]` indexing
|
||||
3. **Lazy initialization** — `SYNTAX_SET` and `THEME_SET` loaded once via `once_cell::Lazy`, not per-call
|
||||
|
||||
### Acceptance Criteria
|
||||
- [ ] `MarkdownRenderer::new()` initializes without panic
|
||||
- [ ] `render()` returns `Vec<Line>` for basic markdown input
|
||||
- [ ] Headers, code blocks, inline code, bold, lists, blockquotes, rules render correctly
|
||||
- [ ] Syntect compilation succeeds (no `Result` type mismatch)
|
||||
- [ ] Theme fallback works when requested theme doesn't exist
|
||||
- [ ] `looks_like_markdown()` has reasonable precision
|
||||
|
||||
---
|
||||
|
||||
## S2-4: Integrate markdown rendering with caching
|
||||
|
||||
**Priority:** P1 — High
|
||||
**Estimate:** 1 day
|
||||
|
||||
### Description
|
||||
Wire the markdown renderer into the conversation pane. **Cache rendered output per entry** so we don't re-parse markdown every frame.
|
||||
|
||||
### Implementation
|
||||
|
||||
**File:** `src/tui.rs`
|
||||
|
||||
1. Add renderer to `TuiApp`:
|
||||
```rust
|
||||
pub struct TuiApp {
|
||||
// ... existing fields
|
||||
markdown_renderer: MarkdownRenderer,
|
||||
}
|
||||
```
|
||||
|
||||
2. Add rendering method that populates cache:
|
||||
```rust
|
||||
fn ensure_rendered(&self, entry: &mut ConversationLine, width: u16) {
|
||||
if entry.rendered_cache.is_some() {
|
||||
return; // Already cached
|
||||
}
|
||||
|
||||
let rendered = match &entry.content {
|
||||
ConversationContent::Plain { text, color, bold } => {
|
||||
let wrapped = wrap_line(text, width as usize);
|
||||
let style = if *bold {
|
||||
Style::default().fg(*color).add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(*color)
|
||||
};
|
||||
wrapped.into_iter()
|
||||
.map(|line| Line::from(Span::styled(line, style)))
|
||||
.collect()
|
||||
}
|
||||
ConversationContent::Markdown { source } => {
|
||||
self.markdown_renderer.render(source, width)
|
||||
}
|
||||
ConversationContent::CodeDiff { diff } => {
|
||||
render_diff(diff)
|
||||
}
|
||||
};
|
||||
|
||||
// Note: we can't mutate through &self. See approach below.
|
||||
}
|
||||
```
|
||||
|
||||
3. **Cache invalidation strategy:**
|
||||
- Invalidate all caches on terminal resize
|
||||
- Invalidate specific entry when it's first rendered
|
||||
- Never invalidate during normal scrolling (re-render is cheap with cache hit)
|
||||
|
||||
```rust
|
||||
fn build_conversation_lines(&mut self, width: u16) -> Vec<Line<'static>> {
|
||||
let mut all_lines = Vec::new();
|
||||
|
||||
for entry in &mut self.conversation {
|
||||
if entry.rendered_cache.is_none() {
|
||||
let rendered = match &entry.content {
|
||||
ConversationContent::Plain { text, color, bold } => {
|
||||
render_plain(text, *color, *bold, width)
|
||||
}
|
||||
ConversationContent::Markdown { source } => {
|
||||
self.markdown_renderer.render(source, width)
|
||||
}
|
||||
ConversationContent::CodeDiff { diff } => {
|
||||
render_diff(diff)
|
||||
}
|
||||
};
|
||||
entry.rendered_cache = Some(rendered);
|
||||
}
|
||||
|
||||
if let Some(cached) = &entry.rendered_cache {
|
||||
all_lines.extend(cached.iter().cloned());
|
||||
}
|
||||
}
|
||||
|
||||
all_lines
|
||||
}
|
||||
```
|
||||
|
||||
4. `push_output()` routes through `looks_like_markdown()`:
|
||||
```rust
|
||||
pub fn push_output(&mut self, text: &str, is_error: bool) {
|
||||
let clean = strip_ansi(text);
|
||||
if is_error {
|
||||
self.conversation.push(ConversationLine::plain(
|
||||
clean, Color::Red, false,
|
||||
));
|
||||
} else if looks_like_markdown(&clean) {
|
||||
self.conversation.push(ConversationLine::markdown(clean));
|
||||
} else {
|
||||
for line in clean.lines() {
|
||||
self.conversation.push(ConversationLine::plain(
|
||||
line.to_string(), Color::White, false,
|
||||
));
|
||||
}
|
||||
}
|
||||
self.auto_scroll();
|
||||
}
|
||||
```
|
||||
|
||||
### Acceptance Criteria
|
||||
- [ ] Markdown content rendered once, cached per entry
|
||||
- [ ] Cache invalidated on resize
|
||||
- [ ] `looks_like_markdown()` correctly identifies assistant responses
|
||||
- [ ] Plain text responses still render correctly
|
||||
- [ ] Error messages still render in red
|
||||
- [ ] No per-frame markdown re-parsing (verify with timer/logging)
|
||||
|
||||
---
|
||||
|
||||
## S2-5: Add CodeDiff renderer
|
||||
|
||||
**Priority:** P2 — Medium
|
||||
**Estimate:** 0.25 day
|
||||
|
||||
### Description
|
||||
Render diff content with `+`/`-` coloring.
|
||||
|
||||
### Implementation
|
||||
|
||||
**File:** `src/markdown.rs`
|
||||
|
||||
```rust
|
||||
pub fn render_diff(diff: &str) -> Vec<Line<'static>> {
|
||||
diff.lines().map(|raw_line| {
|
||||
let (text, color) = if raw_line.starts_with("+++") || raw_line.starts_with("---") {
|
||||
(raw_line.to_string(), Color::White)
|
||||
} else if raw_line.starts_with("@@") {
|
||||
(raw_line.to_string(), Color::Cyan)
|
||||
} else if raw_line.starts_with('+') {
|
||||
(raw_line.to_string(), Color::Green)
|
||||
} else if raw_line.starts_with('-') {
|
||||
(raw_line.to_string(), Color::Red)
|
||||
} else {
|
||||
(raw_line.to_string(), Color::DarkGray)
|
||||
};
|
||||
Line::from(Span::styled(text, Style::default().fg(color)))
|
||||
}).collect()
|
||||
}
|
||||
```
|
||||
|
||||
### Acceptance Criteria
|
||||
- [ ] Additions in green, deletions in red
|
||||
- [ ] Hunk headers in cyan, file headers in white
|
||||
- [ ] Context lines in dark gray
|
||||
|
||||
---
|
||||
|
||||
## S2-6: Write markdown and streaming tests
|
||||
|
||||
**Priority:** P1 — High
|
||||
**Estimate:** 0.5 day
|
||||
|
||||
### Description
|
||||
Comprehensive test coverage for markdown rendering, streaming, and caching.
|
||||
|
||||
### Implementation
|
||||
|
||||
**New file:** `tests/markdown_tests.rs`
|
||||
|
||||
```rust
|
||||
use rusty_claude_cli::markdown::{MarkdownRenderer, looks_like_markdown, render_diff};
|
||||
|
||||
// --- Markdown rendering tests ---
|
||||
|
||||
#[test]
|
||||
fn test_plain_paragraph() {
|
||||
let r = MarkdownRenderer::new();
|
||||
let lines = r.render("Just a paragraph.", 80);
|
||||
assert_eq!(lines.len(), 2); // text + blank
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_h1_rendering() {
|
||||
let r = MarkdownRenderer::new();
|
||||
let lines = r.render("# Hello", 80);
|
||||
assert!(lines[0].spans.iter().any(|s| s.style.fg == Some(Color::Cyan)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_rust_code_block() {
|
||||
let r = MarkdownRenderer::new();
|
||||
let lines = r.render("```rust\nfn main() {\n println!(\"hi\");\n}\n```", 80);
|
||||
assert!(lines.len() >= 6); // top + 4 code lines + bottom
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_python_code_block() {
|
||||
let r = MarkdownRenderer::new();
|
||||
let lines = r.render("```python\ndef hello():\n pass\n```", 80);
|
||||
assert!(lines.len() >= 5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_inline_code() {
|
||||
let r = MarkdownRenderer::new();
|
||||
let lines = r.render("Use `git status` to check", 80);
|
||||
assert_eq!(lines.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_bold_and_italic() {
|
||||
let r = MarkdownRenderer::new();
|
||||
let lines = r.render("**bold** and *italic*", 80);
|
||||
assert_eq!(lines.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nested_list() {
|
||||
let r = MarkdownRenderer::new();
|
||||
let lines = r.render("- item 1\n- item 2\n - nested", 80);
|
||||
assert_eq!(lines.len(), 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blockquote() {
|
||||
let r = MarkdownRenderer::new();
|
||||
let lines = r.render("> quoted\n> text", 80);
|
||||
assert_eq!(lines.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_horizontal_rule() {
|
||||
let r = MarkdownRenderer::new();
|
||||
let lines = r.render("---", 80);
|
||||
assert_eq!(lines.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mixed_content() {
|
||||
let r = MarkdownRenderer::new();
|
||||
let md = "# Title\n\nSome text with `code`.\n\n```rust\nfn main() {}\n```\n\n- list item";
|
||||
let lines = r.render(md, 80);
|
||||
assert!(lines.len() >= 10);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_input() {
|
||||
let r = MarkdownRenderer::new();
|
||||
let lines = r.render("", 80);
|
||||
assert!(lines.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_link_rendering() {
|
||||
let r = MarkdownRenderer::new();
|
||||
let lines = r.render("[click here](https://example.com)", 80);
|
||||
assert_eq!(lines.len(), 2);
|
||||
}
|
||||
|
||||
// --- looks_like_markdown tests ---
|
||||
|
||||
#[test]
|
||||
fn test_markdown_detection_code_block() {
|
||||
assert!(looks_like_markdown("some text\n```rust\ncode\n```\nmore text"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_markdown_detection_header() {
|
||||
assert!(looks_like_markdown("# Title\n\nParagraph one.\n\nParagraph two."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_markdown_detection_list() {
|
||||
assert!(looks_like_markdown("Items:\n\n- one\n- two\n- three"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_markdown_detection_plain() {
|
||||
assert!(!looks_like_markdown("Just a simple response without markdown."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_markdown_detection_short() {
|
||||
assert!(!looks_like_markdown("Short"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_markdown_detection_inline_code_only() {
|
||||
assert!(!looks_like_markdown("Use `foo` and `bar`"));
|
||||
// Needs bold too for inline-only detection
|
||||
assert!(looks_like_markdown("Use `foo` and **bar**"));
|
||||
}
|
||||
|
||||
// --- Diff renderer tests ---
|
||||
|
||||
#[test]
|
||||
fn test_diff_additions_green() {
|
||||
let lines = render_diff("+new line");
|
||||
assert_eq!(lines.len(), 1);
|
||||
assert_eq!(lines[0].spans[0].style.fg, Some(Color::Green));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diff_deletions_red() {
|
||||
let lines = render_diff("-old line");
|
||||
assert_eq!(lines[0].spans[0].style.fg, Some(Color::Red));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diff_hunk_header() {
|
||||
let lines = render_diff("@@ -1,3 +1,4 @@");
|
||||
assert_eq!(lines[0].spans[0].style.fg, Some(Color::Cyan));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_diff_file_header() {
|
||||
let lines = render_diff("--- a/file.rs\n+++ b/file.rs");
|
||||
assert_eq!(lines[0].spans[0].style.fg, Some(Color::White));
|
||||
assert_eq!(lines[1].spans[0].style.fg, Some(Color::White));
|
||||
}
|
||||
|
||||
// --- Syntect safety tests ---
|
||||
|
||||
#[test]
|
||||
fn test_theme_fallback() {
|
||||
let mut r = MarkdownRenderer::new();
|
||||
r.set_code_theme("nonexistent_theme_name");
|
||||
// Should not panic — falls back gracefully
|
||||
let lines = r.render("```rust\nfn main() {}\n```", 80);
|
||||
assert!(lines.len() >= 3);
|
||||
}
|
||||
```
|
||||
|
||||
### Acceptance Criteria
|
||||
- [ ] All 20+ test cases pass
|
||||
- [ ] Syntect theme fallback doesn't panic
|
||||
- [ ] `looks_like_markdown()` has reasonable precision (no false positives on short text)
|
||||
- [ ] Diff renderer colors correct
|
||||
|
||||
---
|
||||
|
||||
## Sprint 2 Definition of Done
|
||||
|
||||
- [ ] All 6 stories completed
|
||||
- [ ] Streaming rendering works for assistant responses
|
||||
- [ ] Markdown rendering with per-entry caching
|
||||
- [ ] Syntect compilation errors fixed (`Result` handling, theme fallback)
|
||||
- [ ] Lazy initialization with `once_cell`
|
||||
- [ ] `cargo test -p rusty-claude-cli` passes
|
||||
- [ ] `cargo clippy -p rusty-claude-cli` has no new warnings
|
||||
- [ ] Manual test: send prompt that returns markdown, verify rich rendering appears incrementally
|
||||
|
|
@ -0,0 +1,447 @@
|
|||
# Sprint 3: Theme System
|
||||
|
||||
> **Duration:** 3 days | **Stories:** 5 | **Goal:** Built-in themes with runtime switching, matching OpenCode's ecosystem
|
||||
> **Depends on:** Sprint 0, Sprint 1, Sprint 2
|
||||
|
||||
---
|
||||
|
||||
## S3-1: Create theme module with TuiTheme struct
|
||||
|
||||
**Priority:** P1 — High
|
||||
**Estimate:** 0.5 day
|
||||
|
||||
### Description
|
||||
Define the `TuiTheme` struct with named color roles for every TUI element.
|
||||
|
||||
### Implementation
|
||||
|
||||
**New file:** `src/theme.rs`
|
||||
|
||||
```rust
|
||||
use ratatui::style::Color;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TuiTheme {
|
||||
pub name: String,
|
||||
// Conversation pane
|
||||
pub conversation_text: ColorDef,
|
||||
pub conversation_user: ColorDef,
|
||||
pub conversation_system: ColorDef,
|
||||
pub conversation_error: ColorDef,
|
||||
pub conversation_assistant: ColorDef,
|
||||
pub conversation_dim: ColorDef,
|
||||
// Code blocks
|
||||
pub code_fg: ColorDef,
|
||||
pub code_bg: ColorDef,
|
||||
pub code_border: ColorDef,
|
||||
pub code_language_label: ColorDef,
|
||||
// Dashboard
|
||||
pub dashboard_header: ColorDef,
|
||||
pub dashboard_key: ColorDef,
|
||||
pub dashboard_value: ColorDef,
|
||||
pub dashboard_separator: ColorDef,
|
||||
pub dashboard_keys_hint: ColorDef,
|
||||
// Context gauge
|
||||
pub gauge_fill_green: ColorDef,
|
||||
pub gauge_fill_yellow: ColorDef,
|
||||
pub gauge_fill_red: ColorDef,
|
||||
pub gauge_bg: ColorDef,
|
||||
pub gauge_label: ColorDef,
|
||||
// Input area
|
||||
pub input_fg: ColorDef,
|
||||
pub input_cursor_fg: ColorDef,
|
||||
pub input_cursor_bg: ColorDef,
|
||||
pub input_border: ColorDef,
|
||||
pub input_border_active: ColorDef,
|
||||
// UI chrome
|
||||
pub border: ColorDef,
|
||||
pub border_active: ColorDef,
|
||||
pub scrollbar: ColorDef,
|
||||
pub spinner: ColorDef,
|
||||
pub status_message: ColorDef,
|
||||
pub key_hint: ColorDef,
|
||||
// Completions
|
||||
pub completion_fg: ColorDef,
|
||||
pub completion_bg: ColorDef,
|
||||
pub completion_selected_fg: ColorDef,
|
||||
pub completion_selected_bg: ColorDef,
|
||||
// Agent View
|
||||
pub agent_running: ColorDef,
|
||||
pub agent_waiting: ColorDef,
|
||||
pub agent_done: ColorDef,
|
||||
pub agent_failed: ColorDef,
|
||||
pub agent_cancelled: ColorDef,
|
||||
// Syntax highlighting theme name
|
||||
pub syntax_theme: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(untagged)]
|
||||
pub enum ColorDef {
|
||||
Named(String),
|
||||
Ansi256(u8),
|
||||
Rgb { r: u8, g: u8, b: u8 },
|
||||
}
|
||||
|
||||
impl ColorDef {
|
||||
pub fn to_color(&self) -> Color {
|
||||
match self {
|
||||
ColorDef::Named(name) => match name.to_lowercase().as_str() {
|
||||
"black" => Color::Black,
|
||||
"red" => Color::Red,
|
||||
"green" => Color::Green,
|
||||
"yellow" => Color::Yellow,
|
||||
"blue" => Color::Blue,
|
||||
"magenta" | "purple" => Color::Magenta,
|
||||
"cyan" => Color::Cyan,
|
||||
"white" => Color::White,
|
||||
"gray" | "grey" | "darkgray" => Color::DarkGray,
|
||||
"lightgray" | "lightgrey" => Color::Gray,
|
||||
_ => Color::White,
|
||||
},
|
||||
ColorDef::Ansi256(n) => Color::Indexed(*n),
|
||||
ColorDef::Rgb { r, g, b } => Color::Rgb(*r, *g, *b),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl TuiTheme {
|
||||
pub fn builtin(name: &str) -> Option<Self> {
|
||||
match name {
|
||||
"default" => Some(Self::theme_default()),
|
||||
"tokyonight" => Some(Self::theme_tokyonight()),
|
||||
"catppuccin-mocha" => Some(Self::theme_catppuccin_mocha()),
|
||||
"catppuccin-latte" => Some(Self::theme_catppuccin_latte()),
|
||||
"nord" => Some(Self::theme_nord()),
|
||||
"gruvbox" => Some(Self::theme_gruvbox()),
|
||||
"dracula" => Some(Self::theme_dracula()),
|
||||
"solarized-dark" => Some(Self::theme_solarized_dark()),
|
||||
"solarized-light" => Some(Self::theme_solarized_light()),
|
||||
"monokai" => Some(Self::theme_monokai()),
|
||||
"system" => Some(Self::theme_system()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn all_builtin_names() -> Vec<&'static str> {
|
||||
vec![
|
||||
"default", "tokyonight", "catppuccin-mocha", "catppuccin-latte",
|
||||
"nord", "gruvbox", "dracula", "solarized-dark", "solarized-light",
|
||||
"monokai", "system",
|
||||
]
|
||||
}
|
||||
|
||||
/// Deserialize from JSON with validation.
|
||||
/// Returns Err if required fields are missing or invalid.
|
||||
pub fn from_json(json: &str) -> Result<Self, ThemeError> {
|
||||
let theme: Self = serde_json::from_str(json)
|
||||
.map_err(|e| ThemeError::ParseError(e.to_string()))?;
|
||||
|
||||
// Validate syntax theme exists in syntect (or warn)
|
||||
// We don't fail — just log a warning if theme is unknown
|
||||
|
||||
Ok(theme)
|
||||
}
|
||||
|
||||
pub fn to_json(&self) -> String {
|
||||
serde_json::to_string_pretty(self).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Validate that the syntax_theme name exists in syntect's ThemeSet.
|
||||
/// Returns true if found, false if will fall back to default.
|
||||
pub fn validate_syntax_theme(&self) -> bool {
|
||||
use crate::markdown::THEME_SET;
|
||||
THEME_SET.themes.contains_key(&self.syntax_theme)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ThemeError {
|
||||
ParseError(String),
|
||||
}
|
||||
```
|
||||
|
||||
### Acceptance Criteria
|
||||
- [ ] `TuiTheme` struct with all color roles (including Agent View colors)
|
||||
- [ ] `ColorDef` enum with named/256/RGB
|
||||
- [ ] `from_json()` validates without panicking
|
||||
- [ ] `validate_syntax_theme()` checks syntect availability
|
||||
- [ ] `builtin()` returns `Some` for all 11 names
|
||||
|
||||
---
|
||||
|
||||
## S3-2: Implement all 11 built-in themes
|
||||
|
||||
**Priority:** P1 — High
|
||||
**Estimate:** 1.5 days (up from 0.5 — Opus review noted this is design work, not just coding)
|
||||
|
||||
### Description
|
||||
Define color palettes for all 11 themes. Each needs ~40 color values that look good together.
|
||||
|
||||
### Implementation
|
||||
|
||||
**File:** `src/theme.rs` — implement private methods
|
||||
|
||||
```rust
|
||||
impl TuiTheme {
|
||||
fn theme_default() -> Self {
|
||||
Self {
|
||||
name: "default".into(),
|
||||
conversation_text: ColorDef::Named("white".into()),
|
||||
conversation_user: ColorDef::Named("cyan".into()),
|
||||
conversation_system: ColorDef::Named("yellow".into()),
|
||||
conversation_error: ColorDef::Named("red".into()),
|
||||
conversation_assistant: ColorDef::Named("white".into()),
|
||||
conversation_dim: ColorDef::Named("darkgray".into()),
|
||||
code_fg: ColorDef::Named("white".into()),
|
||||
code_bg: ColorDef::Named("black".into()),
|
||||
code_border: ColorDef::Named("darkgray".into()),
|
||||
code_language_label: ColorDef::Named("darkgray".into()),
|
||||
dashboard_header: ColorDef::Named("cyan".into()),
|
||||
dashboard_key: ColorDef::Named("darkgray".into()),
|
||||
dashboard_value: ColorDef::Named("white".into()),
|
||||
dashboard_separator: ColorDef::Named("darkgray".into()),
|
||||
dashboard_keys_hint: ColorDef::Named("darkgray".into()),
|
||||
gauge_fill_green: ColorDef::Named("green".into()),
|
||||
gauge_fill_yellow: ColorDef::Named("yellow".into()),
|
||||
gauge_fill_red: ColorDef::Named("red".into()),
|
||||
gauge_bg: ColorDef::Named("darkgray".into()),
|
||||
gauge_label: ColorDef::Named("white".into()),
|
||||
input_fg: ColorDef::Named("white".into()),
|
||||
input_cursor_fg: ColorDef::Named("black".into()),
|
||||
input_cursor_bg: ColorDef::Named("cyan".into()),
|
||||
input_border: ColorDef::Named("cyan".into()),
|
||||
input_border_active: ColorDef::Named("cyan".into()),
|
||||
border: ColorDef::Named("darkgray".into()),
|
||||
border_active: ColorDef::Named("cyan".into()),
|
||||
scrollbar: ColorDef::Named("darkgray".into()),
|
||||
spinner: ColorDef::Named("blue".into()),
|
||||
status_message: ColorDef::Named("blue".into()),
|
||||
key_hint: ColorDef::Named("darkgray".into()),
|
||||
completion_fg: ColorDef::Named("white".into()),
|
||||
completion_bg: ColorDef::Rgb { r: 40, g: 40, b: 40 },
|
||||
completion_selected_fg: ColorDef::Named("black".into()),
|
||||
completion_selected_bg: ColorDef::Named("cyan".into()),
|
||||
agent_running: ColorDef::Named("cyan".into()),
|
||||
agent_waiting: ColorDef::Named("yellow".into()),
|
||||
agent_done: ColorDef::Named("green".into()),
|
||||
agent_failed: ColorDef::Named("red".into()),
|
||||
agent_cancelled: ColorDef::Named("darkgray".into()),
|
||||
syntax_theme: "base16-ocean.dark".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn theme_tokyonight() -> Self {
|
||||
Self {
|
||||
name: "tokyonight".into(),
|
||||
conversation_text: ColorDef::Rgb { r: 192, g: 202, b: 245 },
|
||||
conversation_user: ColorDef::Rgb { r: 125, g: 207, b: 255 },
|
||||
conversation_system: ColorDef::Rgb { r: 224, g: 175, b: 104 },
|
||||
conversation_error: ColorDef::Rgb { r: 247, g: 118, b: 142 },
|
||||
conversation_assistant: ColorDef::Rgb { r: 192, g: 202, b: 245 },
|
||||
conversation_dim: ColorDef::Rgb { r: 86, g: 95, b: 137 },
|
||||
code_fg: ColorDef::Rgb { r: 192, g: 202, b: 245 },
|
||||
code_bg: ColorDef::Rgb { r: 26, g: 27, b: 48 },
|
||||
code_border: ColorDef::Rgb { r: 86, g: 95, b: 137 },
|
||||
code_language_label: ColorDef::Rgb { r: 86, g: 95, b: 137 },
|
||||
dashboard_header: ColorDef::Rgb { r: 125, g: 207, b: 255 },
|
||||
dashboard_key: ColorDef::Rgb { r: 86, g: 95, b: 137 },
|
||||
dashboard_value: ColorDef::Rgb { r: 192, g: 202, b: 245 },
|
||||
dashboard_separator: ColorDef::Rgb { r: 59, g: 66, b: 97 },
|
||||
dashboard_keys_hint: ColorDef::Rgb { r: 86, g: 95, b: 137 },
|
||||
gauge_fill_green: ColorDef::Rgb { r: 158, g: 206, b: 106 },
|
||||
gauge_fill_yellow: ColorDef::Rgb { r: 224, g: 175, b: 104 },
|
||||
gauge_fill_red: ColorDef::Rgb { r: 247, g: 118, b: 142 },
|
||||
gauge_bg: ColorDef::Rgb { r: 59, g: 66, b: 97 },
|
||||
gauge_label: ColorDef::Rgb { r: 192, g: 202, b: 245 },
|
||||
input_fg: ColorDef::Rgb { r: 192, g: 202, b: 245 },
|
||||
input_cursor_fg: ColorDef::Rgb { r: 26, g: 27, b: 48 },
|
||||
input_cursor_bg: ColorDef::Rgb { r: 125, g: 207, b: 255 },
|
||||
input_border: ColorDef::Rgb { r: 59, g: 66, b: 97 },
|
||||
input_border_active: ColorDef::Rgb { r: 125, g: 207, b: 255 },
|
||||
border: ColorDef::Rgb { r: 59, g: 66, b: 97 },
|
||||
border_active: ColorDef::Rgb { r: 125, g: 207, b: 255 },
|
||||
scrollbar: ColorDef::Rgb { r: 59, g: 66, b: 97 },
|
||||
spinner: ColorDef::Rgb { r: 125, g: 207, b: 255 },
|
||||
status_message: ColorDef::Rgb { r: 125, g: 207, b: 255 },
|
||||
key_hint: ColorDef::Rgb { r: 86, g: 95, b: 137 },
|
||||
completion_fg: ColorDef::Rgb { r: 192, g: 202, b: 245 },
|
||||
completion_bg: ColorDef::Rgb { r: 35, g: 40, b: 65 },
|
||||
completion_selected_fg: ColorDef::Rgb { r: 26, g: 27, b: 48 },
|
||||
completion_selected_bg: ColorDef::Rgb { r: 125, g: 207, b: 255 },
|
||||
agent_running: ColorDef::Rgb { r: 125, g: 207, b: 255 },
|
||||
agent_waiting: ColorDef::Rgb { r: 224, g: 175, b: 104 },
|
||||
agent_done: ColorDef::Rgb { r: 158, g: 206, b: 106 },
|
||||
agent_failed: ColorDef::Rgb { r: 247, g: 118, b: 142 },
|
||||
agent_cancelled: ColorDef::Rgb { r: 86, g: 95, b: 137 },
|
||||
syntax_theme: "base16-ocean.dark".into(),
|
||||
}
|
||||
}
|
||||
|
||||
// catppuccin-mocha, catppuccin-latte, nord, gruvbox, dracula,
|
||||
// solarized-dark, solarized-light, monokai follow the same pattern
|
||||
// with their respective color palettes.
|
||||
|
||||
fn theme_system() -> Self {
|
||||
// Uses only ANSI named colors — works on any terminal
|
||||
let mut theme = Self::theme_default();
|
||||
theme.name = "system".into();
|
||||
theme.syntax_theme = "InspiredGitHub".into();
|
||||
theme
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Acceptance Criteria
|
||||
- [ ] All 11 themes defined with distinct palettes
|
||||
- [ ] Each theme has all ~40 color roles filled
|
||||
- [ ] `system` theme uses only ANSI named colors
|
||||
- [ ] All themes use syntect theme names that exist in defaults
|
||||
|
||||
---
|
||||
|
||||
## S3-3: Wire theme into TuiApp — NO HARDCODED COLORS
|
||||
|
||||
**Priority:** P1 — High
|
||||
**Estimate:** 0.5 day
|
||||
|
||||
### Description
|
||||
Replace ALL hardcoded `Color::X` references with `self.theme.field.to_color()`. This is critical — Sprint 6 and 7 must also use theme colors, not introduce new hardcoded values.
|
||||
|
||||
### Implementation
|
||||
|
||||
**File:** `src/tui.rs`
|
||||
|
||||
1. Add theme field to `TuiApp`:
|
||||
```rust
|
||||
pub struct TuiApp {
|
||||
theme: TuiTheme,
|
||||
markdown_renderer: MarkdownRenderer,
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
2. Add helper to reduce boilerplate:
|
||||
```rust
|
||||
impl TuiApp {
|
||||
fn tc(&self, color: &ColorDef) -> Color {
|
||||
color.to_color()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. Replace every `Color::X` in rendering with theme lookup:
|
||||
```rust
|
||||
// BEFORE:
|
||||
Style::default().fg(Color::Cyan)
|
||||
|
||||
// AFTER:
|
||||
Style::default().fg(self.tc(&self.theme.dashboard_header))
|
||||
```
|
||||
|
||||
4. Files to update:
|
||||
- `draw_frame()` → borders, chrome
|
||||
- `draw_left_pane()` → conversation colors
|
||||
- `draw_right_pane()` → dashboard colors
|
||||
- `push_user_input()` → `self.theme.conversation_user`
|
||||
- `push_system_message()` → `self.theme.conversation_system`
|
||||
- `push_output()` → `self.theme.conversation_error` / `self.theme.conversation_assistant`
|
||||
- Input area setup → `self.theme.input_*`
|
||||
- Spinner → `self.theme.spinner`
|
||||
- Status → `self.theme.status_message`
|
||||
|
||||
### Acceptance Criteria
|
||||
- [ ] `grep -r "Color::" src/tui.rs` returns zero matches in rendering code
|
||||
- [ ] All colors come from `self.theme`
|
||||
- [ ] Changing theme at runtime updates all colors on next frame
|
||||
- [ ] Add `#[cfg(test)]` assertion that no hardcoded colors remain
|
||||
|
||||
---
|
||||
|
||||
## S3-4: Implement `/theme` slash command
|
||||
|
||||
**Priority:** P1 — High
|
||||
**Estimate:** 0.25 day
|
||||
|
||||
### Implementation
|
||||
|
||||
```rust
|
||||
// In src/tui_commands.rs:
|
||||
TuiCommand::Theme(args) => {
|
||||
if args.is_empty() {
|
||||
let names = TuiTheme::all_builtin_names();
|
||||
app.push_system_message(&format!(
|
||||
"Available themes: {}\nUsage: /theme <name>",
|
||||
names.join(", ")
|
||||
));
|
||||
} else if let Some(theme) = TuiTheme::builtin(args) {
|
||||
app.set_theme(theme);
|
||||
app.push_system_message(&format!("Theme: {args}"));
|
||||
} else {
|
||||
app.push_system_message(&format!("Unknown theme: {args}"));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Acceptance Criteria
|
||||
- [ ] `/theme` lists all 11 themes
|
||||
- [ ] `/theme tokyonight` switches immediately
|
||||
- [ ] Theme persists for the session
|
||||
|
||||
---
|
||||
|
||||
## S3-5: Add theme persistence and custom JSON loading
|
||||
|
||||
**Priority:** P2 — Medium
|
||||
**Estimate:** 0.5 day
|
||||
|
||||
### Implementation
|
||||
|
||||
1. Save selected theme name to runtime config
|
||||
2. Load on TUI startup
|
||||
3. Custom theme loading: `/theme --custom <path>`
|
||||
4. **Validation:** reject JSON with missing required fields or invalid color values
|
||||
|
||||
```rust
|
||||
TuiCommand::Theme(args) if args.starts_with("--custom ") => {
|
||||
let path = args.strip_prefix("--custom ").unwrap().trim();
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(content) => match TuiTheme::from_json(&content) {
|
||||
Ok(theme) => {
|
||||
if !theme.validate_syntax_theme() {
|
||||
app.push_system_message(&format!(
|
||||
"Warning: syntax theme '{}' not found, using fallback",
|
||||
theme.syntax_theme
|
||||
));
|
||||
}
|
||||
app.set_theme(theme);
|
||||
app.push_system_message("Custom theme loaded");
|
||||
}
|
||||
Err(e) => {
|
||||
app.push_system_message(&format!("Invalid theme: {e:?}"));
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
app.push_system_message(&format!("Cannot read {path}: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Acceptance Criteria
|
||||
- [ ] Selected theme persists in config
|
||||
- [ ] Custom JSON themes load correctly
|
||||
- [ ] Malformed JSON shows error (doesn't panic)
|
||||
- [ ] Invalid syntax theme name shows warning but loads
|
||||
|
||||
---
|
||||
|
||||
## Sprint 3 Definition of Done
|
||||
|
||||
- [ ] All 5 stories completed
|
||||
- [ ] 11 built-in themes implemented
|
||||
- [ ] `/theme` command works at runtime
|
||||
- [ ] Zero hardcoded `Color::` in rendering code
|
||||
- [ ] `cargo test -p rusty-claude-cli` passes
|
||||
- [ ] Manual test: switch between 3 themes, verify all colors change
|
||||
|
|
@ -0,0 +1,504 @@
|
|||
# Sprint 4: Keybindings & Command Palette
|
||||
|
||||
> **Duration:** 4 days (up from 3) | **Stories:** 5 | **Goal:** UX parity with Claude Code and OpenCode
|
||||
> **Depends on:** Sprint 0–3
|
||||
|
||||
---
|
||||
|
||||
## S4-1: Create Action enum and KeyMap module
|
||||
|
||||
**Priority:** P1 — High
|
||||
**Estimate:** 0.5 day
|
||||
|
||||
### Implementation
|
||||
|
||||
**New file:** `src/keybindings.rs`
|
||||
|
||||
```rust
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum Action {
|
||||
// Input
|
||||
Submit,
|
||||
Cancel,
|
||||
Newline,
|
||||
// Navigation
|
||||
ScrollUp,
|
||||
ScrollDown,
|
||||
ScrollHalfUp,
|
||||
ScrollHalfDown,
|
||||
ScrollTop,
|
||||
ScrollBottom,
|
||||
// TUI controls
|
||||
Exit,
|
||||
ProviderSwap,
|
||||
TeamToggle,
|
||||
CommandPalette,
|
||||
ToggleSidebar,
|
||||
ToggleAgentView,
|
||||
Help,
|
||||
// Session
|
||||
NewSession,
|
||||
ClearConversation,
|
||||
CopyLastOutput,
|
||||
// Focus
|
||||
FocusInput,
|
||||
FocusConversation,
|
||||
// Chat mode
|
||||
CycleChatMode,
|
||||
// Completion
|
||||
AcceptCompletion,
|
||||
NextCompletion,
|
||||
PrevCompletion,
|
||||
DismissCompletion,
|
||||
// Slash commands (for palette dispatch)
|
||||
ThemeCommand,
|
||||
DiffCommand,
|
||||
UndoCommand,
|
||||
LsCommand,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum KeyPreset {
|
||||
Emacs,
|
||||
Vim,
|
||||
Windows,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum VimMode {
|
||||
Normal,
|
||||
Insert,
|
||||
}
|
||||
|
||||
pub struct KeyMap {
|
||||
preset: KeyPreset,
|
||||
bindings: HashMap<(KeyModifiers, KeyCode), Action>,
|
||||
vim_mode: VimMode,
|
||||
}
|
||||
|
||||
impl KeyMap {
|
||||
pub fn new(preset: KeyPreset) -> Self {
|
||||
let mut map = Self {
|
||||
preset,
|
||||
bindings: HashMap::new(),
|
||||
vim_mode: VimMode::Insert,
|
||||
};
|
||||
map.load_preset(preset);
|
||||
map
|
||||
}
|
||||
|
||||
fn load_preset(&mut self, preset: KeyPreset) {
|
||||
self.bindings.clear();
|
||||
match preset {
|
||||
KeyPreset::Emacs => self.load_emacs(),
|
||||
KeyPreset::Vim => self.load_vim(),
|
||||
KeyPreset::Windows => self.load_windows(),
|
||||
}
|
||||
}
|
||||
|
||||
fn load_emacs(&mut self) {
|
||||
self.bind(KeyModifiers::NONE, KeyCode::Enter, Action::Submit);
|
||||
self.bind(KeyModifiers::SHIFT, KeyCode::Enter, Action::Newline);
|
||||
self.bind(KeyModifiers::CONTROL, KeyCode::Char('c'), Action::Cancel);
|
||||
self.bind(KeyModifiers::CONTROL, KeyCode::Char('d'), Action::Exit);
|
||||
self.bind(KeyModifiers::CONTROL, KeyCode::Char('p'), Action::ProviderSwap);
|
||||
self.bind(KeyModifiers::CONTROL, KeyCode::Char('t'), Action::TeamToggle);
|
||||
self.bind(KeyModifiers::CONTROL, KeyCode::Char('k'), Action::CommandPalette);
|
||||
self.bind(KeyModifiers::CONTROL, KeyCode::Char('b'), Action::ToggleSidebar);
|
||||
self.bind(KeyModifiers::CONTROL, KeyCode::Char('a'), Action::ToggleAgentView);
|
||||
self.bind(KeyModifiers::CONTROL, KeyCode::Char('l'), Action::ClearConversation);
|
||||
self.bind(KeyModifiers::CONTROL, KeyCode::Char('h'), Action::Help);
|
||||
self.bind(KeyModifiers::NONE, KeyCode::PageUp, Action::ScrollHalfUp);
|
||||
self.bind(KeyModifiers::NONE, KeyCode::PageDown, Action::ScrollHalfDown);
|
||||
self.bind(KeyModifiers::CONTROL, KeyCode::Home, Action::ScrollTop);
|
||||
self.bind(KeyModifiers::CONTROL, KeyCode::End, Action::ScrollBottom);
|
||||
// FIX: bind() takes 3 args — (modifiers, code, action)
|
||||
self.bind(KeyModifiers::NONE, KeyCode::F(1), Action::Help);
|
||||
}
|
||||
|
||||
fn load_vim(&mut self) {
|
||||
self.load_emacs(); // Base layer — Vim normal mode overrides in resolve()
|
||||
}
|
||||
|
||||
fn load_windows(&mut self) {
|
||||
self.bind(KeyModifiers::CONTROL, KeyCode::Enter, Action::Submit);
|
||||
self.bind(KeyModifiers::NONE, KeyCode::Enter, Action::Newline);
|
||||
self.bind(KeyModifiers::NONE, KeyCode::Esc, Action::Cancel);
|
||||
self.bind(KeyModifiers::CONTROL, KeyCode::Char('d'), Action::Exit);
|
||||
self.bind(KeyModifiers::CONTROL, KeyCode::Char('p'), Action::ProviderSwap);
|
||||
self.bind(KeyModifiers::CONTROL, KeyCode::Char('k'), Action::CommandPalette);
|
||||
self.bind(KeyModifiers::NONE, KeyCode::F(1), Action::Help);
|
||||
// ... same pattern for remaining bindings
|
||||
}
|
||||
|
||||
fn bind(&mut self, modifiers: KeyModifiers, code: KeyCode, action: Action) {
|
||||
self.bindings.insert((modifiers, code), action);
|
||||
}
|
||||
|
||||
pub fn resolve(&self, key: KeyEvent) -> Option<Action> {
|
||||
if self.preset == KeyPreset::Vim && self.vim_mode == VimMode::Normal {
|
||||
return self.resolve_vim_normal(key);
|
||||
}
|
||||
self.bindings.get(&(key.modifiers, key.code)).copied()
|
||||
}
|
||||
|
||||
fn resolve_vim_normal(&self, key: KeyEvent) -> Option<Action> {
|
||||
match (key.modifiers, key.code) {
|
||||
(KeyModifiers::NONE, KeyCode::Char('j')) => Some(Action::ScrollDown),
|
||||
(KeyModifiers::NONE, KeyCode::Char('k')) => Some(Action::ScrollUp),
|
||||
(KeyModifiers::NONE, KeyCode::Char('g')) => Some(Action::ScrollTop),
|
||||
(KeyModifiers::NONE, KeyCode::Char('G')) => Some(Action::ScrollBottom),
|
||||
(KeyModifiers::NONE, KeyCode::Char('i')) => Some(Action::FocusInput),
|
||||
(KeyModifiers::NONE, KeyCode::Char(':')) => Some(Action::CommandPalette),
|
||||
(KeyModifiers::NONE, KeyCode::Char('q')) => Some(Action::Exit),
|
||||
(KeyModifiers::NONE, KeyCode::Esc) => Some(Action::Cancel),
|
||||
_ => self.bindings.get(&(key.modifiers, key.code)).copied(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_vim_mode(&mut self, mode: VimMode) { self.vim_mode = mode; }
|
||||
pub fn vim_mode(&self) -> VimMode { self.vim_mode }
|
||||
pub fn preset(&self) -> KeyPreset { self.preset }
|
||||
pub fn set_preset(&mut self, preset: KeyPreset) {
|
||||
self.preset = preset;
|
||||
self.load_preset(preset);
|
||||
self.vim_mode = VimMode::Insert;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Acceptance Criteria
|
||||
- [ ] `Action` enum covers all actions including `ThemeCommand`, `DiffCommand`, etc.
|
||||
- [ ] `bind(KeyModifiers::NONE, KeyCode::F(1), Action::Help)` — correct 3-arg signature
|
||||
- [ ] Vim normal mode: j/k/g/G/i/:/q work
|
||||
- [ ] Esc enters Vim normal mode from insert
|
||||
|
||||
---
|
||||
|
||||
## S4-2: Replace hardcoded key handling with Action dispatch
|
||||
|
||||
**Priority:** P1 — High
|
||||
**Estimate:** 0.5 day
|
||||
|
||||
### Implementation
|
||||
|
||||
**File:** `src/tui.rs`
|
||||
|
||||
```rust
|
||||
fn handle_key(&mut self, key: KeyEvent) -> io::Result<TuiReadOutcome> {
|
||||
// Vim mode transition: Esc → Normal mode
|
||||
if self.keymap.preset() == KeyPreset::Vim && key.code == KeyCode::Esc {
|
||||
if self.keymap.vim_mode() == VimMode::Insert {
|
||||
self.keymap.set_vim_mode(VimMode::Normal);
|
||||
return Ok(TuiReadOutcome::Pending);
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve key → action
|
||||
let action = self.keymap.resolve(key);
|
||||
|
||||
// In insert mode, many keys go to the text area
|
||||
if self.input_focused && self.keymap.vim_mode() == VimMode::Insert {
|
||||
match action {
|
||||
Some(Action::Submit) => {
|
||||
let text: String = self.input.lines().join("\n");
|
||||
if text.trim().is_empty() { return Ok(TuiReadOutcome::Pending); }
|
||||
self.input = TextArea::new(vec![String::new()]);
|
||||
return Ok(TuiReadOutcome::Submit(text));
|
||||
}
|
||||
Some(Action::Cancel) => return Ok(TuiReadOutcome::Cancel),
|
||||
Some(Action::Newline) => {
|
||||
self.input.insert_newline();
|
||||
return Ok(TuiReadOutcome::Pending);
|
||||
}
|
||||
Some(Action::Exit) => {
|
||||
self.should_exit = true;
|
||||
return Ok(TuiReadOutcome::Exit);
|
||||
}
|
||||
Some(action) => return self.dispatch_action(action),
|
||||
None => {
|
||||
// Vim i key enters insert mode
|
||||
if self.keymap.preset() == KeyPreset::Vim && key.code == KeyCode::Char('i') {
|
||||
self.keymap.set_vim_mode(VimMode::Insert);
|
||||
return Ok(TuiReadOutcome::Pending);
|
||||
}
|
||||
// Unbound key → pass to text area for editing
|
||||
self.input.input(key);
|
||||
return Ok(TuiReadOutcome::Pending);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match action {
|
||||
Some(action) => self.dispatch_action(action),
|
||||
None => Ok(TuiReadOutcome::Pending),
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_action(&mut self, action: Action) -> io::Result<TuiReadOutcome> {
|
||||
match action {
|
||||
Action::ScrollUp => { self.scroll_up(1); }
|
||||
Action::ScrollDown => { self.scroll_down(1); }
|
||||
Action::ScrollHalfUp => { self.scroll_up(self.visible_lines() / 2); }
|
||||
Action::ScrollHalfDown => { self.scroll_down(self.visible_lines() / 2); }
|
||||
Action::ScrollTop => { self.scroll_to_top(); }
|
||||
Action::ScrollBottom => { self.scroll_to_bottom(); }
|
||||
Action::Exit => { self.should_exit = true; return Ok(TuiReadOutcome::Exit); }
|
||||
Action::ProviderSwap => return Ok(TuiReadOutcome::ProviderSwap),
|
||||
Action::TeamToggle => return Ok(TuiReadOutcome::TeamToggle),
|
||||
Action::CommandPalette => { self.toggle_command_palette(); }
|
||||
Action::ToggleSidebar => { self.toggle_sidebar(); }
|
||||
Action::ToggleAgentView => return Ok(TuiReadOutcome::AgentView),
|
||||
Action::Help => { self.show_help(); }
|
||||
Action::ClearConversation => {
|
||||
self.conversation.clear();
|
||||
self.render_cache.clear();
|
||||
self.conversation_scroll = 0;
|
||||
}
|
||||
Action::CycleChatMode => { self.cycle_chat_mode(); }
|
||||
Action::FocusInput => { self.input_focused = true; self.keymap.set_vim_mode(VimMode::Insert); }
|
||||
Action::FocusConversation => { self.input_focused = false; }
|
||||
// Slash command actions dispatched to TuiCommand
|
||||
Action::ThemeCommand | Action::DiffCommand | Action::UndoCommand | Action::LsCommand => {
|
||||
// These are handled via slash commands, not direct key dispatch
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(TuiReadOutcome::Pending)
|
||||
}
|
||||
```
|
||||
|
||||
### Acceptance Criteria
|
||||
- [ ] All existing keybindings still work
|
||||
- [ ] `handle_key()` dispatches through `KeyMap`
|
||||
- [ ] No `KeyCode::Char('x')` matches remain in `handle_key()`
|
||||
- [ ] Vim mode: Esc → Normal, i → Insert
|
||||
|
||||
---
|
||||
|
||||
## S4-3: Implement Command Palette (Ctrl+K)
|
||||
|
||||
**Priority:** P1 — High
|
||||
**Estimate:** 1.5 days (up from 1 — Opus noted modal overlay + fuzzy filter complexity)
|
||||
|
||||
### Implementation
|
||||
|
||||
**New file:** `src/command_palette.rs`
|
||||
|
||||
```rust
|
||||
use crate::keybindings::Action;
|
||||
|
||||
pub struct CommandPalette {
|
||||
pub active: bool,
|
||||
pub query: String,
|
||||
pub entries: Vec<PaletteEntry>,
|
||||
pub filtered: Vec<usize>,
|
||||
pub selected: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PaletteEntry {
|
||||
pub label: String,
|
||||
pub description: String,
|
||||
pub action: Action,
|
||||
pub key_hint: String,
|
||||
pub category: String,
|
||||
}
|
||||
|
||||
impl CommandPalette {
|
||||
pub fn new() -> Self {
|
||||
let entries = vec![
|
||||
PaletteEntry { label: "Submit".into(), description: "Send message".into(),
|
||||
action: Action::Submit, key_hint: "Enter".into(), category: "Input".into() },
|
||||
PaletteEntry { label: "Swap Provider".into(), description: "Change AI provider".into(),
|
||||
action: Action::ProviderSwap, key_hint: "Ctrl+P".into(), category: "Settings".into() },
|
||||
PaletteEntry { label: "Toggle Team".into(), description: "Show/hide team info".into(),
|
||||
action: Action::TeamToggle, key_hint: "Ctrl+T".into(), category: "View".into() },
|
||||
PaletteEntry { label: "Agent View".into(), description: "Multi-session dashboard".into(),
|
||||
action: Action::ToggleAgentView, key_hint: "Ctrl+A".into(), category: "View".into() },
|
||||
PaletteEntry { label: "Toggle Sidebar".into(), description: "Show/hide file sidebar".into(),
|
||||
action: Action::ToggleSidebar, key_hint: "Ctrl+B".into(), category: "View".into() },
|
||||
PaletteEntry { label: "Clear Conversation".into(), description: "Clear all messages".into(),
|
||||
action: Action::ClearConversation, key_hint: "Ctrl+L".into(), category: "Session".into() },
|
||||
PaletteEntry { label: "Help".into(), description: "Keyboard shortcuts".into(),
|
||||
action: Action::Help, key_hint: "F1".into(), category: "Help".into() },
|
||||
PaletteEntry { label: "Exit".into(), description: "Exit TUI mode".into(),
|
||||
action: Action::Exit, key_hint: "Ctrl+D".into(), category: "Session".into() },
|
||||
// FIX: Use correct Action variants for slash commands
|
||||
PaletteEntry { label: "/theme".into(), description: "Change color theme".into(),
|
||||
action: Action::ThemeCommand, key_hint: "".into(), category: "Commands".into() },
|
||||
PaletteEntry { label: "/diff".into(), description: "Show uncommitted changes".into(),
|
||||
action: Action::DiffCommand, key_hint: "".into(), category: "Commands".into() },
|
||||
PaletteEntry { label: "/undo".into(), description: "Revert last changes".into(),
|
||||
action: Action::UndoCommand, key_hint: "".into(), category: "Commands".into() },
|
||||
PaletteEntry { label: "/code".into(), description: "Code mode (full access)".into(),
|
||||
action: Action::CycleChatMode, key_hint: "Tab".into(), category: "Modes".into() },
|
||||
];
|
||||
|
||||
let filtered = (0..entries.len()).collect();
|
||||
Self { active: false, query: String::new(), entries, filtered, selected: 0 }
|
||||
}
|
||||
|
||||
pub fn open(&mut self) { self.active = true; self.query.clear(); self.filtered = (0..self.entries.len()).collect(); self.selected = 0; }
|
||||
pub fn close(&mut self) { self.active = false; self.query.clear(); }
|
||||
pub fn input(&mut self, c: char) { self.query.push(c); self.update_filter(); }
|
||||
pub fn backspace(&mut self) { self.query.pop(); self.update_filter(); }
|
||||
pub fn select_next(&mut self) { if !self.filtered.is_empty() { self.selected = (self.selected + 1) % self.filtered.len(); } }
|
||||
pub fn select_prev(&mut self) { if !self.filtered.is_empty() { self.selected = if self.selected == 0 { self.filtered.len() - 1 } else { self.selected - 1 }; } }
|
||||
pub fn selected_action(&self) -> Option<Action> { self.filtered.get(self.selected).map(|&i| self.entries[i].action) }
|
||||
|
||||
fn update_filter(&mut self) {
|
||||
if self.query.is_empty() {
|
||||
self.filtered = (0..self.entries.len()).collect();
|
||||
} else {
|
||||
let q = self.query.to_lowercase();
|
||||
self.filtered = self.entries.iter().enumerate()
|
||||
.filter(|(_, e)| e.label.to_lowercase().contains(&q) || e.description.to_lowercase().contains(&q))
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
}
|
||||
self.selected = self.selected.min(self.filtered.len().saturating_sub(1));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Rendering
|
||||
|
||||
**File:** `src/tui.rs`
|
||||
|
||||
```rust
|
||||
fn draw_command_palette(&self, f: &mut Frame, area: Rect) {
|
||||
if !self.command_palette.active { return; }
|
||||
|
||||
let popup_w = (area.width * 60 / 100).min(60);
|
||||
let popup_h = (area.height * 50 / 100).min(20);
|
||||
let popup = Rect::new(
|
||||
(area.width - popup_w) / 2,
|
||||
(area.height - popup_h) / 2,
|
||||
popup_w, popup_h,
|
||||
);
|
||||
|
||||
f.render_widget(ratatui::widgets::Clear, popup);
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled("🔍 ", Style::default().fg(self.tc(&self.theme.key_hint))),
|
||||
Span::styled(&self.command_palette.query, Style::default().fg(self.tc(&self.theme.input_fg))),
|
||||
Span::styled("█", Style::default().fg(self.tc(&self.theme.input_cursor_bg))),
|
||||
]));
|
||||
lines.push(Line::from(""));
|
||||
|
||||
for (i, &idx) in self.command_palette.filtered.iter().enumerate() {
|
||||
let entry = &self.command_palette.entries[idx];
|
||||
let is_sel = i == self.command_palette.selected;
|
||||
let (fg, bg) = if is_sel {
|
||||
(self.tc(&self.theme.completion_selected_fg), self.tc(&self.theme.completion_selected_bg))
|
||||
} else {
|
||||
(self.tc(&self.theme.completion_fg), self.tc(&self.theme.completion_bg))
|
||||
};
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!(" {} ", entry.label), Style::default().fg(fg).bg(bg)),
|
||||
Span::styled(format!(" {} ", entry.description), Style::default().fg(self.tc(&self.theme.key_hint))),
|
||||
Span::styled(&entry.key_hint, Style::default().fg(self.tc(&self.theme.key_hint))),
|
||||
]));
|
||||
}
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(self.tc(&self.theme.border_active)))
|
||||
.title(" Command Palette ");
|
||||
|
||||
f.render_widget(Paragraph::new(lines).block(block), popup);
|
||||
}
|
||||
```
|
||||
|
||||
### Acceptance Criteria
|
||||
- [ ] `Ctrl+K` opens command palette
|
||||
- [ ] Typing filters commands in real-time
|
||||
- [ ] Arrow keys navigate, Enter executes
|
||||
- [ ] Escape closes palette
|
||||
- [ ] All palette entries use correct `Action` variants (not `CycleChatMode` for everything)
|
||||
- [ ] Palette uses theme colors, not hardcoded `Color::`
|
||||
|
||||
---
|
||||
|
||||
## S4-4: Add `/keybindings` command and preset switching
|
||||
|
||||
**Priority:** P2 — Medium
|
||||
**Estimate:** 0.25 day
|
||||
|
||||
### Implementation
|
||||
|
||||
```rust
|
||||
TuiCommand::Keys(preset_name) => {
|
||||
match preset_name.as_str() {
|
||||
"emacs" => { app.set_key_preset(KeyPreset::Emacs); app.push_system_message("Keys: Emacs"); }
|
||||
"vim" => { app.set_key_preset(KeyPreset::Vim); app.push_system_message("Keys: Vim — i for insert, Esc for normal"); }
|
||||
"windows" => { app.set_key_preset(KeyPreset::Windows); app.push_system_message("Keys: Windows"); }
|
||||
"" => {
|
||||
let cur = app.key_preset_name();
|
||||
app.push_system_message(&format!("Current: {cur}\nAvailable: emacs, vim, windows\nUsage: /keys <preset>"));
|
||||
}
|
||||
_ => { app.push_system_message(&format!("Unknown: {preset_name}. Available: emacs, vim, windows")); }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Acceptance Criteria
|
||||
- [ ] `/keys vim` switches to Vim
|
||||
- [ ] `/keys emacs` switches to Emacs
|
||||
- [ ] `/keys` shows current preset and options
|
||||
|
||||
---
|
||||
|
||||
## S4-5: Implement Help overlay (F1)
|
||||
|
||||
**Priority:** P2 — Medium
|
||||
**Estimate:** 0.25 day
|
||||
|
||||
### Implementation
|
||||
|
||||
```rust
|
||||
fn show_help(&mut self) {
|
||||
let preset = self.keymap.preset();
|
||||
let mut msg = format!("Keybindings ({preset:?}):\n\n");
|
||||
msg += "Enter Submit\n";
|
||||
msg += "Shift+Enter Newline\n";
|
||||
msg += "Ctrl+C Cancel\n";
|
||||
msg += "Ctrl+D Exit TUI\n";
|
||||
msg += "Ctrl+P Swap provider\n";
|
||||
msg += "Ctrl+K Command palette\n";
|
||||
msg += "Ctrl+A Agent view\n";
|
||||
msg += "Ctrl+B Toggle sidebar\n";
|
||||
msg += "Ctrl+L Clear conversation\n";
|
||||
msg += "F1 This help\n";
|
||||
msg += "PageUp/Down Scroll\n";
|
||||
if preset == KeyPreset::Vim {
|
||||
msg += "\nVim mode:\n i Insert\n Esc Normal\n j/k Scroll\n g/G Top/Bottom\n : Command palette\n";
|
||||
}
|
||||
msg += "\nSlash commands:\n";
|
||||
msg += "/tui /theme /keys /code /ask /architect\n";
|
||||
msg += "/diff /undo /ls /help\n";
|
||||
self.push_system_message(&msg);
|
||||
}
|
||||
```
|
||||
|
||||
### Acceptance Criteria
|
||||
- [ ] F1 shows help with current preset's bindings
|
||||
- [ ] Vim-specific help only in Vim mode
|
||||
- [ ] Slash command reference included
|
||||
|
||||
---
|
||||
|
||||
## Sprint 4 Definition of Done
|
||||
|
||||
- [ ] All 5 stories completed
|
||||
- [ ] `Action` enum + `KeyMap` module with correct signatures
|
||||
- [ ] Command palette works with correct `Action` dispatch
|
||||
- [ ] 3 keybinding presets available
|
||||
- [ ] All palette entries use correct Action variants
|
||||
- [ ] All colors from theme (no hardcoded `Color::`)
|
||||
- [ ] `cargo test -p rusty-claude-cli` passes
|
||||
|
|
@ -0,0 +1,292 @@
|
|||
# Sprint 5: Chat Modes & Diff Viewer
|
||||
|
||||
> **Duration:** 2 days | **Stories:** 4 | **Goal:** Aider-style workflow features
|
||||
> **Depends on:** Sprint 0–4
|
||||
> **Revised:** Fixed `/diff --color=always` issue, added theme colors, fixed git commands
|
||||
|
||||
---
|
||||
|
||||
## S5-1: Implement ChatMode enum and mode switching
|
||||
|
||||
**Priority:** P1 — High
|
||||
**Estimate:** 0.5 day
|
||||
|
||||
### Implementation
|
||||
|
||||
**File:** `src/tui.rs`
|
||||
|
||||
```rust
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ChatMode {
|
||||
Code,
|
||||
Ask,
|
||||
Architect,
|
||||
}
|
||||
|
||||
impl ChatMode {
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
ChatMode::Code => "code",
|
||||
ChatMode::Ask => "ask",
|
||||
ChatMode::Architect => "arch",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn description(&self) -> &'static str {
|
||||
match self {
|
||||
ChatMode::Code => "Full access — edits and runs code",
|
||||
ChatMode::Ask => "Discussion only — no changes made",
|
||||
ChatMode::Architect => "Plan first, then implement",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn system_prompt_suffix(&self) -> &'static str {
|
||||
match self {
|
||||
ChatMode::Code => "",
|
||||
ChatMode::Ask => "\n\nIMPORTANT: Do NOT modify any files. Only discuss and explain.",
|
||||
ChatMode::Architect => "\n\nIMPORTANT: First create a plan. After the user approves, implement it.",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn next(self) -> Self {
|
||||
match self {
|
||||
ChatMode::Code => ChatMode::Ask,
|
||||
ChatMode::Ask => ChatMode::Architect,
|
||||
ChatMode::Architect => ChatMode::Code,
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Input prompt integration
|
||||
|
||||
```rust
|
||||
fn update_input_prompt(&mut self) {
|
||||
let mode_label = self.chat_mode.label();
|
||||
self.input.set_block(
|
||||
Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(self.tc(&self.theme.input_border)))
|
||||
.title(format!(" {mode_label} > ")),
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Dashboard integration
|
||||
|
||||
Add `chat_mode` to `DashboardState`:
|
||||
```rust
|
||||
pub chat_mode: String,
|
||||
```
|
||||
|
||||
Update `update_dashboard()` to include it.
|
||||
|
||||
### Slash commands in `tui_commands.rs`
|
||||
|
||||
```rust
|
||||
TuiCommand::Code => { app.set_chat_mode(ChatMode::Code); app.push_system_message("Mode: Code"); }
|
||||
TuiCommand::Ask => { app.set_chat_mode(ChatMode::Ask); app.push_system_message("Mode: Ask — no changes"); }
|
||||
TuiCommand::Architect => { app.set_chat_mode(ChatMode::Architect); app.push_system_message("Mode: Architect — plan first"); }
|
||||
```
|
||||
|
||||
### System prompt integration
|
||||
|
||||
When starting a turn, append the mode's suffix to the system prompt:
|
||||
```rust
|
||||
fn get_effective_system_prompt(&self) -> String {
|
||||
let base = self.base_system_prompt.clone();
|
||||
let suffix = self.chat_mode.system_prompt_suffix();
|
||||
format!("{base}{suffix}")
|
||||
}
|
||||
```
|
||||
|
||||
### Acceptance Criteria
|
||||
- [ ] Input area shows `[code] >`, `[ask] >`, or `[arch] >`
|
||||
- [ ] `/code`, `/ask`, `/architect` switch modes
|
||||
- [ ] `Tab` cycles through modes
|
||||
- [ ] Mode shown in dashboard
|
||||
- [ ] System prompt suffix applied when mode changes
|
||||
|
||||
---
|
||||
|
||||
## S5-2: Implement `/diff` command
|
||||
|
||||
**Priority:** P1 — High
|
||||
**Estimate:** 0.5 day
|
||||
|
||||
### Implementation
|
||||
|
||||
**FIX from Opus review:** Do NOT use `--color=always` — it injects ANSI codes into the diff output, which corrupts the rendering. We handle coloring ourselves via `CodeDiff` content type.
|
||||
|
||||
```rust
|
||||
TuiCommand::Diff { staged } => {
|
||||
let mut args = vec!["diff"];
|
||||
if staged {
|
||||
args.push("--staged");
|
||||
}
|
||||
// Do NOT pass --color=always — we color the diff ourselves
|
||||
|
||||
let output = std::process::Command::new("git")
|
||||
.args(&args)
|
||||
.current_dir(&cwd)
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(out) if out.status.success() => {
|
||||
let diff = String::from_utf8_lossy(&out.stdout);
|
||||
if diff.is_empty() {
|
||||
app.push_system_message(if staged {
|
||||
"No staged changes."
|
||||
} else {
|
||||
"No uncommitted changes."
|
||||
});
|
||||
} else {
|
||||
app.push_diff(&diff);
|
||||
}
|
||||
}
|
||||
Ok(out) => {
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
app.push_system_message(&format!("git diff failed: {err}"));
|
||||
}
|
||||
Err(e) => {
|
||||
app.push_system_message(&format!("Failed to run git: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `/diff --stat` variant
|
||||
|
||||
Also add a compact stat view:
|
||||
```rust
|
||||
TuiCommand::DiffStat => {
|
||||
let output = std::process::Command::new("git")
|
||||
.args(["diff", "--stat", "--color=never"]) // --color=never for --stat is fine
|
||||
.current_dir(&cwd)
|
||||
.output();
|
||||
// ... render as Plain content
|
||||
}
|
||||
```
|
||||
|
||||
### Acceptance Criteria
|
||||
- [ ] `/diff` shows unstaged changes with our own coloring (green/red)
|
||||
- [ ] `/diff --staged` shows staged changes
|
||||
- [ ] No ANSI codes from git in the output
|
||||
- [ ] Empty diff shows message
|
||||
- [ ] Error handling for non-git repos
|
||||
|
||||
---
|
||||
|
||||
## S5-3: Implement `/undo` command
|
||||
|
||||
**Priority:** P1 — High
|
||||
**Estimate:** 0.25 day
|
||||
|
||||
### Implementation
|
||||
|
||||
```rust
|
||||
TuiCommand::Undo { confirm } => {
|
||||
if confirm {
|
||||
let output = std::process::Command::new("git")
|
||||
.args(["checkout", "--", "."])
|
||||
.current_dir(&cwd)
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(out) if out.status.success() => {
|
||||
app.push_system_message("✓ Reverted all uncommitted changes.");
|
||||
}
|
||||
Ok(out) => {
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
app.push_system_message(&format!("Undo failed: {err}"));
|
||||
}
|
||||
Err(e) => {
|
||||
app.push_system_message(&format!("Failed to run git: {e}"));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Show what would be reverted
|
||||
let output = std::process::Command::new("git")
|
||||
.args(["diff", "--stat", "--color=never"])
|
||||
.current_dir(&cwd)
|
||||
.output();
|
||||
|
||||
let stat = output.ok()
|
||||
.filter(|o| o.status.success())
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
if stat.is_empty() {
|
||||
app.push_system_message("Nothing to undo — no uncommitted changes.");
|
||||
} else {
|
||||
app.push_system_message(&format!(
|
||||
"This will revert:\n{stat}\nType /undo --confirm to proceed."
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Acceptance Criteria
|
||||
- [ ] `/undo` shows what will be reverted
|
||||
- [ ] `/undo --confirm` reverts
|
||||
- [ ] No changes shows "Nothing to undo"
|
||||
|
||||
---
|
||||
|
||||
## S5-4: Implement `/ls` and `/context` commands
|
||||
|
||||
**Priority:** P2 — Medium
|
||||
**Estimate:** 0.25 day
|
||||
|
||||
### Implementation
|
||||
|
||||
```rust
|
||||
TuiCommand::Ls { path } => {
|
||||
match path {
|
||||
Some(p) => {
|
||||
match std::fs::read_to_string(&p) {
|
||||
Ok(content) => {
|
||||
app.push_system_message(&format!("── {p} ──"));
|
||||
app.push_output(&content, false);
|
||||
}
|
||||
Err(e) => app.push_system_message(&format!("Cannot read {p}: {e}")),
|
||||
}
|
||||
}
|
||||
None => {
|
||||
let files = cli.get_context_files();
|
||||
if files.is_empty() {
|
||||
app.push_system_message("No files in context.");
|
||||
} else {
|
||||
let mut msg = format!("Files in context ({}):\n", files.len());
|
||||
for f in &files {
|
||||
msg += &format!(" {f}\n");
|
||||
}
|
||||
app.push_system_message(&msg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TuiCommand::Context => {
|
||||
// Alias for /ls with no path
|
||||
// Same as Ls { path: None }
|
||||
}
|
||||
```
|
||||
|
||||
### Acceptance Criteria
|
||||
- [ ] `/ls` lists context files
|
||||
- [ ] `/ls <path>` shows file contents
|
||||
- [ ] `/context` is alias for `/ls`
|
||||
|
||||
---
|
||||
|
||||
## Sprint 5 Definition of Done
|
||||
|
||||
- [ ] All 4 stories completed
|
||||
- [ ] Chat modes with system prompt integration
|
||||
- [ ] `/diff` uses own coloring (no git --color)
|
||||
- [ ] `/undo` with confirmation
|
||||
- [ ] `/ls` shows context
|
||||
- [ ] All rendering uses theme colors
|
||||
- [ ] `cargo test -p rusty-claude-cli` passes
|
||||
|
|
@ -0,0 +1,397 @@
|
|||
# Sprint 6: Agent View & Multi-Session
|
||||
|
||||
> **Duration:** 4 days | **Stories:** 5 | **Goal:** Multi-agent monitoring dashboard
|
||||
> **Depends on:** Sprint 0–5
|
||||
> **Guardrails:** Four Laws enforced. Commit after each story.
|
||||
|
||||
---
|
||||
|
||||
## S6-1: Create AgentView data model
|
||||
|
||||
**Priority:** P1 — High
|
||||
**Estimate:** 0.5 day
|
||||
**Scope:** IN: `src/agent_view.rs` (new). OUT: `tui.rs`, `main.rs`
|
||||
|
||||
### Description
|
||||
Model for tracking multiple agent sessions — status, progress, metadata.
|
||||
|
||||
### Pre-Execution Checklist
|
||||
```
|
||||
[ ] Read src/tui.rs for existing SharedDashboardState patterns
|
||||
[ ] Read src/theme.rs for Agent View color fields
|
||||
[ ] Scope locked: only src/agent_view.rs
|
||||
[ ] Rollback: rm src/agent_view.rs
|
||||
```
|
||||
|
||||
### Implementation
|
||||
|
||||
**New file:** `src/agent_view.rs`
|
||||
|
||||
```rust
|
||||
use std::time::Instant;
|
||||
use ratatui::style::Color;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AgentStatus {
|
||||
Running,
|
||||
WaitingForInput,
|
||||
Done,
|
||||
Failed,
|
||||
Cancelled,
|
||||
}
|
||||
|
||||
impl AgentStatus {
|
||||
pub fn icon(&self) -> &'static str {
|
||||
match self {
|
||||
AgentStatus::Running => "⬢",
|
||||
AgentStatus::WaitingForInput => "◐",
|
||||
AgentStatus::Done => "✓",
|
||||
AgentStatus::Failed => "✗",
|
||||
AgentStatus::Cancelled => "⊘",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgentSession {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub status: AgentStatus,
|
||||
pub model: String,
|
||||
pub turn_count: u32,
|
||||
pub last_message: String,
|
||||
pub working_dir: String,
|
||||
pub started_at: Instant,
|
||||
pub task_subject: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SortField { Status, Name, Started, TurnCount }
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum FilterState { All, Running, Done, Failed }
|
||||
|
||||
pub struct AgentView {
|
||||
pub sessions: Vec<AgentSession>,
|
||||
pub sort_by: SortField,
|
||||
pub filter: FilterState,
|
||||
pub selected: usize,
|
||||
pub active: bool,
|
||||
}
|
||||
|
||||
impl AgentView {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sessions: Vec::new(),
|
||||
sort_by: SortField::Status,
|
||||
filter: FilterState::All,
|
||||
selected: 0,
|
||||
active: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open(&mut self) { self.active = true; self.selected = 0; }
|
||||
pub fn close(&mut self) { self.active = false; }
|
||||
|
||||
pub fn update_session(&mut self, session: AgentSession) {
|
||||
if let Some(existing) = self.sessions.iter_mut().find(|s| s.id == session.id) {
|
||||
*existing = session;
|
||||
} else {
|
||||
self.sessions.push(session);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_session(&mut self, id: &str) {
|
||||
self.sessions.retain(|s| s.id != id);
|
||||
}
|
||||
|
||||
pub fn filtered_sessions(&self) -> Vec<&AgentSession> {
|
||||
let mut sessions: Vec<&AgentSession> = self.sessions
|
||||
.iter()
|
||||
.filter(|s| match self.filter {
|
||||
FilterState::All => true,
|
||||
FilterState::Running => s.status == AgentStatus::Running,
|
||||
FilterState::Done => s.status == AgentStatus::Done,
|
||||
FilterState::Failed => s.status == AgentStatus::Failed,
|
||||
})
|
||||
.collect();
|
||||
sessions.sort_by(|a, b| match self.sort_by {
|
||||
SortField::Status => a.status.icon().cmp(b.status.icon()),
|
||||
SortField::Name => a.name.cmp(&b.name),
|
||||
SortField::Started => a.started_at.cmp(&b.started_at),
|
||||
SortField::TurnCount => a.turn_count.cmp(&b.turn_count),
|
||||
});
|
||||
sessions
|
||||
}
|
||||
|
||||
pub fn select_next(&mut self) {
|
||||
let count = self.filtered_sessions().len();
|
||||
if count > 0 { self.selected = (self.selected + 1) % count; }
|
||||
}
|
||||
|
||||
pub fn select_prev(&mut self) {
|
||||
let count = self.filtered_sessions().len();
|
||||
if count > 0 { self.selected = if self.selected == 0 { count - 1 } else { self.selected - 1 }; }
|
||||
}
|
||||
|
||||
pub fn cycle_filter(&mut self) {
|
||||
self.filter = match self.filter {
|
||||
FilterState::All => FilterState::Running,
|
||||
FilterState::Running => FilterState::Done,
|
||||
FilterState::Done => FilterState::Failed,
|
||||
FilterState::Failed => FilterState::All,
|
||||
};
|
||||
self.selected = 0;
|
||||
}
|
||||
|
||||
pub fn cycle_sort(&mut self) {
|
||||
self.sort_by = match self.sort_by {
|
||||
SortField::Status => SortField::Name,
|
||||
SortField::Name => SortField::Started,
|
||||
SortField::Started => SortField::TurnCount,
|
||||
SortField::TurnCount => SortField::Status,
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Verification
|
||||
```bash
|
||||
cargo build -p rusty-claude-cli
|
||||
```
|
||||
|
||||
### Commit
|
||||
```
|
||||
feat(tui): add AgentView data model with session tracking
|
||||
|
||||
Authored by TheArchitectit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## S6-2: Implement Agent View rendering
|
||||
|
||||
**Priority:** P1 — High
|
||||
**Estimate:** 1 day
|
||||
**Scope:** IN: `src/tui.rs` (add draw_agent_view method). OUT: theme.rs, main.rs
|
||||
|
||||
### Pre-Execution Checklist
|
||||
```
|
||||
[ ] Read src/tui.rs for draw_frame(), draw_left_pane(), draw_right_pane() patterns
|
||||
[ ] Read src/theme.rs for agent_* color fields
|
||||
[ ] Scope locked: only tui.rs draw_agent_view method + TuiApp field
|
||||
[ ] Rollback: git checkout HEAD -- src/tui.rs
|
||||
```
|
||||
|
||||
### Implementation
|
||||
|
||||
Add `AgentView` to `TuiApp`:
|
||||
```rust
|
||||
pub struct TuiApp {
|
||||
// ... existing fields
|
||||
pub agent_view: AgentView,
|
||||
}
|
||||
```
|
||||
|
||||
Add rendering method. **All colors from `self.theme`** — NO hardcoded `Color::`:
|
||||
```rust
|
||||
fn draw_agent_view(&self, f: &mut Frame, area: Rect) {
|
||||
if !self.agent_view.active { return; }
|
||||
|
||||
let chunks = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(10),
|
||||
Constraint::Length(8),
|
||||
])
|
||||
.split(area);
|
||||
|
||||
// Header
|
||||
let filter_label = format!("Filter: {:?}", self.agent_view.filter);
|
||||
let sort_label = format!("Sort: {:?}", self.agent_view.sort_by);
|
||||
let header = Paragraph::new(Line::from(vec![
|
||||
Span::styled(" Agent View ",
|
||||
Style::default().fg(self.tc(&self.theme.dashboard_header)).add_modifier(Modifier::BOLD)),
|
||||
Span::styled(format!(" [{filter_label}] [{sort_label}]"),
|
||||
Style::default().fg(self.tc(&self.theme.dashboard_key))),
|
||||
]))
|
||||
.block(Block::default().borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(self.tc(&self.theme.border_active))));
|
||||
f.render_widget(header, chunks[0]);
|
||||
|
||||
// Session list
|
||||
let sessions = self.agent_view.filtered_sessions();
|
||||
let items: Vec<ListItem> = sessions.iter().enumerate().map(|(i, s)| {
|
||||
let is_selected = i == self.agent_view.selected;
|
||||
let status_color = match s.status {
|
||||
AgentStatus::Running => self.tc(&self.theme.agent_running),
|
||||
AgentStatus::WaitingForInput => self.tc(&self.theme.agent_waiting),
|
||||
AgentStatus::Done => self.tc(&self.theme.agent_done),
|
||||
AgentStatus::Failed => self.tc(&self.theme.agent_failed),
|
||||
AgentStatus::Cancelled => self.tc(&self.theme.agent_cancelled),
|
||||
};
|
||||
let elapsed = s.started_at.elapsed().as_secs();
|
||||
let elapsed_str = if elapsed < 60 { format!("{elapsed}s") } else { format!("{}m{}s", elapsed/60, elapsed%60) };
|
||||
let line = Line::from(vec![
|
||||
Span::styled(format!(" {} ", s.status.icon()), Style::default().fg(status_color)),
|
||||
Span::styled(format!("{:<20}", s.name), Style::default().fg(self.tc(&self.theme.conversation_text))),
|
||||
Span::styled(format!("{:<16}", s.model), Style::default().fg(self.tc(&self.theme.dashboard_key))),
|
||||
Span::styled(format!("{} turns ", s.turn_count), Style::default().fg(self.tc(&self.theme.dashboard_value))),
|
||||
Span::styled(elapsed_str, Style::default().fg(self.tc(&self.theme.key_hint))),
|
||||
]);
|
||||
let style = if is_selected { Style::default().bg(self.tc(&self.theme.completion_selected_bg)) } else { Style::default() };
|
||||
ListItem::new(line).style(style)
|
||||
}).collect();
|
||||
|
||||
let list = List::new(items).block(
|
||||
Block::default().borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(self.tc(&self.theme.border)))
|
||||
.title(" Sessions "));
|
||||
f.render_widget(list, chunks[1]);
|
||||
|
||||
// Detail panel
|
||||
if let Some(session) = sessions.get(self.agent_view.selected) {
|
||||
let detail = Paragraph::new(vec![
|
||||
Line::from(vec![
|
||||
Span::styled("ID: ", Style::default().fg(self.tc(&self.theme.dashboard_key))),
|
||||
Span::styled(&session.id, Style::default().fg(self.tc(&self.theme.dashboard_value))),
|
||||
]),
|
||||
Line::from(vec![
|
||||
Span::styled("Task: ", Style::default().fg(self.tc(&self.theme.dashboard_key))),
|
||||
Span::styled(session.task_subject.as_deref().unwrap_or("(none)"),
|
||||
Style::default().fg(self.tc(&self.theme.dashboard_value))),
|
||||
]),
|
||||
])
|
||||
.block(Block::default().borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(self.tc(&self.theme.border)))
|
||||
.title(" Details "))
|
||||
.wrap(Wrap { trim: false });
|
||||
f.render_widget(detail, chunks[2]);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Verification
|
||||
```bash
|
||||
cargo build -p rusty-claude-cli
|
||||
cargo clippy -p rusty-claude-cli
|
||||
```
|
||||
|
||||
### Commit
|
||||
```
|
||||
feat(tui): render Agent View with theme colors
|
||||
|
||||
Authored by TheArchitectit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## S6-3: Wire Agent View keybindings
|
||||
|
||||
**Priority:** P1 — High
|
||||
**Estimate:** 0.25 day
|
||||
**Scope:** IN: `src/tui.rs` (dispatch_action), `src/keybindings.rs` (Action enum). OUT: main.rs
|
||||
|
||||
### Implementation
|
||||
|
||||
In `dispatch_action()`:
|
||||
```rust
|
||||
Action::ToggleAgentView => {
|
||||
if self.agent_view.active {
|
||||
self.agent_view.close();
|
||||
} else {
|
||||
self.agent_view.open();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In `handle_key()` — when agent view is active, intercept keys:
|
||||
```rust
|
||||
if self.agent_view.active {
|
||||
match key.code {
|
||||
KeyCode::Esc | KeyCode::Char('q') => { self.agent_view.close(); }
|
||||
KeyCode::Tab => { self.agent_view.cycle_filter(); }
|
||||
KeyCode::Char('s') => { self.agent_view.cycle_sort(); }
|
||||
KeyCode::Down | KeyCode::Char('j') => { self.agent_view.select_next(); }
|
||||
KeyCode::Up | KeyCode::Char('k') => { self.agent_view.select_prev(); }
|
||||
_ => {}
|
||||
}
|
||||
self.needs_redraw = true;
|
||||
return Ok(TuiReadOutcome::Pending);
|
||||
}
|
||||
```
|
||||
|
||||
### Commit
|
||||
```
|
||||
feat(tui): wire Agent View keybindings (Ctrl+A, j/k, Tab, s)
|
||||
|
||||
Authored by TheArchitectit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## S6-4: Integrate with runtime session registry
|
||||
|
||||
**Priority:** P2 — Medium
|
||||
**Estimate:** 1 day
|
||||
**Scope:** IN: `src/tui_repl.rs`, `src/tui_update.rs`. OUT: runtime crate internals
|
||||
|
||||
### Implementation
|
||||
|
||||
1. Add `SessionRegistry` shared state
|
||||
2. Hook into team/spawn lifecycle:
|
||||
- `TeamCreate` → register
|
||||
- `Agent::spawn` → register
|
||||
- Turn completion → update count
|
||||
- Shutdown → mark Done/Failed
|
||||
3. Share via `Arc<RwLock<SessionRegistry>>`
|
||||
|
||||
### Commit
|
||||
```
|
||||
feat(tui): integrate Agent View with runtime session lifecycle
|
||||
|
||||
Authored by TheArchitectit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## S6-5: Write Agent View tests
|
||||
|
||||
**Priority:** P1 — High
|
||||
**Estimate:** 0.25 day
|
||||
**Scope:** IN: `tests/agent_view_tests.rs` (new). OUT: all source files
|
||||
|
||||
### Tests
|
||||
```rust
|
||||
use rusty_claude_cli::agent_view::*;
|
||||
|
||||
#[test]
|
||||
fn test_update_session() { ... }
|
||||
#[test]
|
||||
fn test_remove_session() { ... }
|
||||
#[test]
|
||||
fn test_filter_by_status() { ... }
|
||||
#[test]
|
||||
fn test_sort_by_name() { ... }
|
||||
#[test]
|
||||
fn test_select_navigation() { ... }
|
||||
#[test]
|
||||
fn test_cycle_filter() { ... }
|
||||
```
|
||||
|
||||
### Commit
|
||||
```
|
||||
test(tui): add Agent View data model tests
|
||||
|
||||
Authored by TheArchitectit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sprint 6 Definition of Done
|
||||
- [ ] All 5 stories completed and committed
|
||||
- [ ] Agent View shows sessions with theme colors (zero hardcoded Color::)
|
||||
- [ ] `cargo test -p rusty-claude-cli` passes
|
||||
- [ ] `cargo clippy -p rusty-claude-cli` clean
|
||||
- [ ] Manual: spawn agents, open Agent View, verify live updates
|
||||
|
|
@ -0,0 +1,468 @@
|
|||
# Sprint 7: Polish & Ship
|
||||
|
||||
> **Duration:** 3 days | **Stories:** 7 | **Goal:** Mouse, CJK, sidebar, input history, search, final QA
|
||||
> **Depends on:** Sprint 0–6
|
||||
> **Guardrails:** Four Laws enforced. Commit after each story. Three Strikes per task.
|
||||
|
||||
---
|
||||
|
||||
## S7-1: Enable mouse support
|
||||
|
||||
**Priority:** P2 — Medium
|
||||
**Estimate:** 0.5 day
|
||||
**Scope:** IN: `src/tui.rs` (init/restore + event handling). OUT: all other files
|
||||
|
||||
### Pre-Execution Checklist
|
||||
```
|
||||
[ ] Read src/tui.rs init_terminal() and restore_terminal()
|
||||
[ ] Read src/tui_repl.rs event loop for Event:: handling
|
||||
[ ] Scope locked: init/restore + mouse event handler
|
||||
[ ] Rollback: git checkout HEAD -- src/tui.rs src/tui_repl.rs
|
||||
```
|
||||
|
||||
### Implementation
|
||||
|
||||
**File:** `src/tui.rs` — init:
|
||||
```rust
|
||||
use crossterm::event::{EnableMouseCapture, DisableMouseCapture};
|
||||
|
||||
pub fn init_terminal() -> io::Result<Terminal<CrosstermBackend<Stdout>>> {
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
crossterm::execute!(
|
||||
stdout,
|
||||
EnableMouseCapture,
|
||||
crossterm::terminal::EnterAlternateScreen,
|
||||
)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let terminal = Terminal::new(backend)?;
|
||||
Ok(terminal)
|
||||
}
|
||||
```
|
||||
|
||||
Restore:
|
||||
```rust
|
||||
pub fn restore_terminal(&mut self) -> io::Result<()> {
|
||||
crossterm::execute!(
|
||||
self.terminal.backend_mut(),
|
||||
DisableMouseCapture,
|
||||
crossterm::terminal::LeaveAlternateScreen,
|
||||
)?;
|
||||
disable_raw_mode()?;
|
||||
Ok(())
|
||||
}
|
||||
```
|
||||
|
||||
**File:** `src/tui_repl.rs` — event handling:
|
||||
```rust
|
||||
Event::Mouse(mouse) => {
|
||||
match mouse.kind {
|
||||
MouseEventKind::ScrollUp => { app.scroll_up(3); }
|
||||
MouseEventKind::ScrollDown => { app.scroll_down(3); }
|
||||
MouseEventKind::Down(MouseButton::Left) => {
|
||||
// Check if click is in input area bounds
|
||||
if let Some(input_area) = app.input_area_rect {
|
||||
if mouse.column >= input_area.x
|
||||
&& mouse.column < input_area.x + input_area.width
|
||||
&& mouse.row >= input_area.y
|
||||
&& mouse.row < input_area.y + input_area.height
|
||||
{
|
||||
app.input_focused = true;
|
||||
} else {
|
||||
app.input_focused = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
app.needs_redraw = true;
|
||||
}
|
||||
```
|
||||
|
||||
### Verification
|
||||
```bash
|
||||
cargo build -p rusty-claude-cli
|
||||
# Manual: launch TUI, scroll with mouse wheel, click to focus input
|
||||
```
|
||||
|
||||
### Commit
|
||||
```
|
||||
feat(tui): enable mouse support for scroll and click-to-focus
|
||||
|
||||
Authored by TheArchitectit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## S7-2: CJK-aware word wrapping
|
||||
|
||||
**Priority:** P2 — Medium
|
||||
**Estimate:** 0.25 day
|
||||
**Scope:** IN: `src/tui.rs` (wrap_line function). OUT: all other files
|
||||
|
||||
### Implementation
|
||||
|
||||
`unicode-width` already added in Sprint 0 (S0-4).
|
||||
|
||||
```rust
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
|
||||
fn wrap_line(text: &str, max_width: usize) -> Vec<String> {
|
||||
if max_width == 0 || text.is_empty() {
|
||||
return vec![text.to_string()];
|
||||
}
|
||||
|
||||
let mut result = Vec::new();
|
||||
let mut current_line = String::new();
|
||||
let mut current_width: usize = 0;
|
||||
let mut last_break_byte_pos: usize = 0;
|
||||
|
||||
for ch in text.chars() {
|
||||
let char_width = ch.width().unwrap_or(0);
|
||||
|
||||
if current_width + char_width > max_width {
|
||||
if last_break_byte_pos > 0 {
|
||||
let (keep, rest) = current_line.split_at(last_break_byte_pos);
|
||||
result.push(keep.trim_end().to_string());
|
||||
current_line = rest.to_string();
|
||||
current_width = rest.chars().map(|c| c.width().unwrap_or(0)).sum();
|
||||
last_break_byte_pos = 0;
|
||||
} else {
|
||||
result.push(current_line.clone());
|
||||
current_line.clear();
|
||||
current_width = 0;
|
||||
last_break_byte_pos = 0;
|
||||
}
|
||||
}
|
||||
|
||||
let byte_pos_before = current_line.len();
|
||||
current_line.push(ch);
|
||||
current_width += char_width;
|
||||
|
||||
if ch == ' ' || ch == '-' || ch == '_' || ch == '/' {
|
||||
last_break_byte_pos = current_line.len();
|
||||
}
|
||||
}
|
||||
|
||||
if !current_line.is_empty() {
|
||||
result.push(current_line);
|
||||
}
|
||||
|
||||
if result.is_empty() {
|
||||
result.push(String::new());
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
```
|
||||
|
||||
### Tests
|
||||
```rust
|
||||
#[test]
|
||||
fn test_wrap_cjk_double_width() {
|
||||
let wrapped = wrap_line("你好世界你好世界你好世界", 10);
|
||||
for line in &wrapped {
|
||||
let width: usize = line.chars().map(|c| c.width().unwrap_or(0)).sum();
|
||||
assert!(width <= 10, "line '{line}' has width {width}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrap_mixed_cjk_ascii() {
|
||||
let wrapped = wrap_line("Hello你好World test", 10);
|
||||
assert!(!wrapped.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrap_emoji() {
|
||||
let wrapped = wrap_line("🦀🔨🔧", 6);
|
||||
assert!(!wrapped.is_empty());
|
||||
}
|
||||
```
|
||||
|
||||
### Commit
|
||||
```
|
||||
fix(tui): CJK-aware word wrapping with unicode-width
|
||||
|
||||
Authored by TheArchitectit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## S7-3: Refactor draw_frame() into RenderContext
|
||||
|
||||
**Priority:** P2 — Medium
|
||||
**Estimate:** 0.5 day
|
||||
**Scope:** IN: `src/tui.rs` (draw_frame signature). OUT: tui_repl.rs, main.rs
|
||||
|
||||
### Pre-Execution Checklist
|
||||
```
|
||||
[ ] Read src/tui.rs draw_frame() signature (currently 10 params)
|
||||
[ ] Read all callers of draw_frame() in tui.rs and tui_repl.rs
|
||||
[ ] Scope locked: only draw_frame signature + callers
|
||||
[ ] Rollback: git checkout HEAD -- src/tui.rs
|
||||
```
|
||||
|
||||
### Implementation
|
||||
|
||||
```rust
|
||||
struct RenderContext<'a> {
|
||||
theme: &'a TuiTheme,
|
||||
dashboard: &'a DashboardState,
|
||||
conversation: &'a [ConversationLine],
|
||||
conversation_scroll: u16,
|
||||
input: &'a TextArea<'static>,
|
||||
slash_completions: &'a [String],
|
||||
completion_index: usize,
|
||||
showing_completions: bool,
|
||||
spinner_frame: usize,
|
||||
chat_mode: ChatMode,
|
||||
agent_view_active: bool,
|
||||
command_palette_active: bool,
|
||||
}
|
||||
|
||||
impl<'a> RenderContext<'a> {
|
||||
fn from_app(app: &'a TuiApp, dashboard: &'a DashboardState) -> Self {
|
||||
Self {
|
||||
theme: &app.theme,
|
||||
dashboard,
|
||||
conversation: &app.conversation,
|
||||
conversation_scroll: app.conversation_scroll,
|
||||
input: &app.input,
|
||||
slash_completions: &app.slash_completions,
|
||||
completion_index: app.completion_index,
|
||||
showing_completions: app.showing_completions,
|
||||
spinner_frame: app.spinner_frame,
|
||||
chat_mode: app.chat_mode,
|
||||
agent_view_active: app.agent_view.active,
|
||||
command_palette_active: app.command_palette.active,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Before:
|
||||
fn draw_frame(theme: &TuiTheme, dashboard: &DashboardState, conversation: &[ConversationLine],
|
||||
scroll: u16, input: &TextArea, completions: &[String], comp_idx: usize,
|
||||
showing_comp: bool, spinner: usize, chat_mode: ChatMode,
|
||||
f: &mut Frame)
|
||||
{ ... }
|
||||
|
||||
// After:
|
||||
fn draw_frame(ctx: &RenderContext, f: &mut Frame)
|
||||
{ ... }
|
||||
```
|
||||
|
||||
### Verification
|
||||
```bash
|
||||
cargo build -p rusty-claude-cli
|
||||
cargo clippy -p rusty-claude-cli # no more too_many_arguments suppression
|
||||
```
|
||||
|
||||
### Commit
|
||||
```
|
||||
refactor(tui): replace 10-param draw_frame with RenderContext struct
|
||||
|
||||
Authored by TheArchitectit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## S7-4: Add input history (Up/Down arrows)
|
||||
|
||||
**Priority:** P2 — Medium
|
||||
**Estimate:** 0.25 day
|
||||
**Scope:** IN: `src/tui.rs` (TuiApp fields + handle_key). OUT: tui_repl.rs
|
||||
|
||||
### Implementation
|
||||
|
||||
```rust
|
||||
pub struct TuiApp {
|
||||
// ... existing fields
|
||||
input_history: Vec<String>,
|
||||
history_index: Option<usize>, // None = not browsing history
|
||||
}
|
||||
|
||||
impl TuiApp {
|
||||
fn push_history(&mut self, text: &str) {
|
||||
if !text.trim().is_empty() {
|
||||
self.input_history.push(text.to_string());
|
||||
// Cap at 500 entries
|
||||
if self.input_history.len() > 500 {
|
||||
self.input_history.remove(0);
|
||||
}
|
||||
}
|
||||
self.history_index = None;
|
||||
}
|
||||
|
||||
fn history_up(&mut self) {
|
||||
if self.input_history.is_empty() { return; }
|
||||
let new_idx = match self.history_index {
|
||||
Some(i) => i.saturating_add(1).min(self.input_history.len() - 1),
|
||||
None => self.input_history.len() - 1,
|
||||
};
|
||||
self.history_index = Some(new_idx);
|
||||
let entry = &self.input_history[self.input_history.len() - 1 - new_idx];
|
||||
self.input = TextArea::new(vec![entry.clone()]);
|
||||
}
|
||||
|
||||
fn history_down(&mut self) {
|
||||
match self.history_index {
|
||||
Some(0) => {
|
||||
self.history_index = None;
|
||||
self.input = TextArea::new(vec![String::new()]);
|
||||
}
|
||||
Some(i) => {
|
||||
let new_idx = i - 1;
|
||||
self.history_index = Some(new_idx);
|
||||
let entry = &self.input_history[self.input_history.len() - 1 - new_idx];
|
||||
self.input = TextArea::new(vec![entry.clone()]);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Wire into `handle_key()` when input is focused and not showing completions:
|
||||
```rust
|
||||
KeyCode::Up if !self.showing_completions => { self.history_up(); }
|
||||
KeyCode::Down if !self.showing_completions => { self.history_down(); }
|
||||
```
|
||||
|
||||
Save history on submit in `tui_repl.rs`:
|
||||
```rust
|
||||
TuiReadOutcome::Submit(text) => {
|
||||
app.push_history(&text);
|
||||
// ... existing turn execution
|
||||
}
|
||||
```
|
||||
|
||||
### Commit
|
||||
```
|
||||
feat(tui): add input history with Up/Down arrow navigation
|
||||
|
||||
Authored by TheArchitectit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## S7-5: Add search in conversation (Ctrl+F)
|
||||
|
||||
**Priority:** P3 — Low
|
||||
**Estimate:** 0.25 day
|
||||
**Scope:** IN: `src/tui.rs` (new search state + handle_key). OUT: tui_repl.rs
|
||||
|
||||
### Implementation
|
||||
|
||||
```rust
|
||||
pub struct ConversationSearch {
|
||||
pub active: bool,
|
||||
pub query: String,
|
||||
pub matches: Vec<usize>, // indices into conversation
|
||||
pub current_match: usize,
|
||||
}
|
||||
```
|
||||
|
||||
When `Ctrl+F`:
|
||||
- Show search input at top of conversation pane
|
||||
- Type to filter — jump to matching line
|
||||
- Enter / F3 = next match, Shift+F3 = previous
|
||||
- Escape = close search
|
||||
|
||||
### Commit
|
||||
```
|
||||
feat(tui): add conversation search with Ctrl+F
|
||||
|
||||
Authored by TheArchitectit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## S7-6: Final integration testing
|
||||
|
||||
**Priority:** P1 — High
|
||||
**Estimate:** 0.5 day
|
||||
**Scope:** IN: No file changes. Manual testing only.
|
||||
|
||||
### Test Matrix
|
||||
|
||||
| Feature | Test | Pass? |
|
||||
|---------|------|-------|
|
||||
| TUI launch | `/tui` enters TUI mode | |
|
||||
| Message flow | Send message, receive streaming response | |
|
||||
| Markdown rendering | Headers, code blocks, lists render | |
|
||||
| Theme switching | `/theme tokyonight` changes all colors | |
|
||||
| Keybindings | All Emacs actions work | |
|
||||
| Vim mode | `/keys vim` + normal/insert modes | |
|
||||
| Command palette | `Ctrl+K` opens, type to filter, Enter selects | |
|
||||
| Chat modes | `/code`, `/ask`, `/architect` switch | |
|
||||
| Diff viewer | `/diff` shows changes | |
|
||||
| Undo | `/undo --confirm` reverts | |
|
||||
| Agent View | `Ctrl+A` shows sessions | |
|
||||
| Provider swap | `Ctrl+P` runs wizard, returns to TUI | |
|
||||
| Token tracking | Dashboard shows real counts | |
|
||||
| Memory bound | Long session stays bounded | |
|
||||
| CJK wrapping | Chinese text wraps at double-width | |
|
||||
| Mouse scroll | Wheel scrolls conversation | |
|
||||
| Input history | Up/Down recalls previous inputs | |
|
||||
| Search | `Ctrl+F` finds text in conversation | |
|
||||
| Help | `F1` shows keybinding help | |
|
||||
| Resize | Terminal resize re-renders correctly | |
|
||||
| Panic recovery | Induced panic restores terminal | |
|
||||
|
||||
### Verification
|
||||
```bash
|
||||
cargo test -p rusty-claude-cli
|
||||
cargo clippy -p rusty-claude-cli
|
||||
cargo build --release -p rusty-claude-cli
|
||||
```
|
||||
|
||||
### Commit (if any test fixes needed)
|
||||
```
|
||||
fix(tui): integration test fixes from QA pass
|
||||
|
||||
Authored by TheArchitectit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## S7-7: Update documentation
|
||||
|
||||
**Priority:** P2 — Medium
|
||||
**Estimate:** 0.25 day
|
||||
**Scope:** IN: README.md, help text in tui.rs. OUT: guardrails docs
|
||||
|
||||
### Deliverables
|
||||
|
||||
1. README section for TUI
|
||||
2. In-app help (F1) updated with all commands
|
||||
3. Slash command reference:
|
||||
```
|
||||
/tui Enter TUI mode
|
||||
/theme <name> Change color theme
|
||||
/keys <preset> Change keybinding preset
|
||||
/code Code mode (default)
|
||||
/ask Ask mode (no edits)
|
||||
/architect Architect mode (plan first)
|
||||
/diff Show uncommitted changes
|
||||
/undo Revert last changes
|
||||
/ls List context files
|
||||
```
|
||||
|
||||
### Commit
|
||||
```
|
||||
docs(tui): add TUI mode documentation and command reference
|
||||
|
||||
Authored by TheArchitectit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sprint 7 Definition of Done
|
||||
- [ ] All 7 stories completed and committed individually
|
||||
- [ ] All test matrix items pass
|
||||
- [ ] Zero hardcoded `Color::` in rendering code
|
||||
- [ ] `cargo test -p rusty-claude-cli` passes
|
||||
- [ ] `cargo clippy -p rusty-claude-cli` clean
|
||||
- [ ] `cargo build --release -p rusty-claude-cli` succeeds
|
||||
- [ ] Manual smoke test: full workflow end-to-end
|
||||
Loading…
Reference in New Issue