# Claw-Code TUI Architecture Plan **Status:** MoA-synthesized design — 5 expert reports consolidated **Target:** `origin/feat/tui` branch → upstream PR **Precedent:** codex-rs (OpenAI), aichat (sigoden) --- ## 0. Executive Summary The three previous TUI attempts all failed for the same root reason: **the runtime was built to print to stdout, and the TUI was grafted on top.** Every output-bleeding fix (suspend/resume, buffer capture, libc::dup, gag::BufferRedirect, leave-for-turn) was a workaround for this architectural mismatch. Every scroll bug stemmed from an inverted offset model that fought ratatui's `Paragraph::scroll()` semantics. The correct architecture — proven by codex-rs — is: 1. **The runtime emits structured events, not stdout bytes** 2. **The TUI consumes events via a channel and renders them as cells** 3. **Scroll operates on cells with forward offsets (0 = top)** 4. **The TUI renders to stderr, keeping stdout free for child process capture** --- ## 1. Architecture Overview ``` ┌─────────────────────────────────────────────────────────────┐ │ Main Event Loop │ │ tokio::select! { ... } │ │ │ │ ┌───────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────┐ │ │ │ TuiEvents │ │AppEvents │ │TurnEvents│ │PermissionRx │ │ │ │(crossterm)│ │(bounded) │ │(bounded) │ │(oneshot resp)│ │ │ └─────┬─────┘ └────┬─────┘ └────┬─────┘ └──────┬───────┘ │ │ │ │ │ │ │ │ └────────────┴────────────┴───────────────┘ │ │ │ │ │ App::handle() │ │ │ │ │ ┌────────────────┼────────────────┐ │ │ ▼ ▼ ▼ │ │ ChatWidget BottomPane PermissionOverlay │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ │ │ │Committed │ │Composer │ │[Y/N/A] │ │ │ │Cells[] │ │(textarea)│ │prompt │ │ │ │+Active │ │+History │ └──────────┘ │ │ │ Cell │ └──────────┘ │ │ └──────────┘ │ │ │ │ │ ▼ │ │ FrameRequester → rate-limited draw │ │ │ │ │ ▼ │ │ ratatui render → crossterm → stderr │ └─────────────────────────────────────────────────────────────┘ │ ▲ │ │ TuiTurnEvent channel │ │ ┌───────────────────▼─┴──────────────────────────────────────┐ │ TurnWorker (tokio::spawn) │ │ │ │ ConversationRuntime::run_turn() │ │ │ │ │ ├── ApiClient::stream() → Vec │ │ │ └── Forward each event via tx.send() │ │ │ before collecting for build_assistant_message │ │ │ │ │ ├── PermissionPrompter::decide() │ │ │ └── Send PermissionRequired via channel │ │ │ with oneshot::Sender for response │ │ │ │ │ └── TurnProgressReporter::on_tool_result() │ │ └── Forward as TuiTurnEvent::ToolResultReady │ └─────────────────────────────────────────────────────────────┘ ``` --- ## 2. Crate Stack | Crate | Version | Purpose | |-------|---------|---------| | `ratatui` | 0.29+ | Framework — features: `unstable-rendered-line-info`, `unstable-widget-ref` | | `crossterm` | 0.28+ | Terminal backend — features: `bracketed-paste`, `event-stream` | | `tokio` | 1 | Async runtime — features: `rt-multi-thread`, `macros`, `sync`, `time`, `process`, `signal` | | `tui-textarea` | 0.7+ | Input composer (proven in codex-rs) | | `pulldown-cmark` | 0.12+ | Markdown → ratatui `Text` rendering | | `syntect` | 5+ | Syntax highlighting for code blocks | | `textwrap` | 0.18+ | Word-wrap with CJK support | | `unicode-width` | 0.2+ | Character width calculation (CJK = 2) | | `insta` | 1+ (dev) | Snapshot testing for widget rendering | | `ansi-to-tui` | 0.5+ (opt) | Child process ANSI output → ratatui Text | **Critical:** Render the TUI to **stderr**, not stdout: ```rust let backend = CrosstermBackend::new(std::io::stderr()); let mut terminal = Terminal::new(backend)?; ``` This frees stdout for child process capture AND prevents output bleeding by design. --- ## 3. Event Architecture ### 3.1 TuiTurnEvent — Runtime → TUI Channel ```rust #[derive(Debug)] pub enum TuiTurnEvent { // === Streaming === TurnStarted { user_input: String }, ThinkingDelta { text: String }, TextDelta { text: String }, ToolUseStarted { id: String, name: String, input_preview: String }, ToolResultReady { tool_name: String, result: Result }, // === Permission (interactive — blocks turn worker) === PermissionRequired { request: PermissionRequest, response_tx: oneshot::Sender, }, // === Lifecycle === TurnCompleted { summary: TurnSummary }, TurnFailed { error: String }, // === Progress === IterationProgress { iteration: usize, max_iterations: usize }, } ``` ### 3.2 AppEvent — Internal TUI Channel ```rust #[derive(Debug)] pub enum AppEvent { // From turn worker TurnEvent(TuiTurnEvent), // From crossterm Key(KeyEvent), Paste(String), Resize { width: u16, height: u16 }, // Frame scheduling ScheduleFrame, // Streaming animation tick (~4Hz for UI updates) StreamTick, } ``` ### 3.3 Channel Setup ```rust // Bounded channels with backpressure let (app_tx, app_rx) = tokio::sync::mpsc::channel::(256); let (turn_tx, turn_rx) = tokio::sync::mpsc::channel::(256); // Turn worker receives turn_tx, sends events // Main loop multiplexes turn_rx → app_tx, crossterm events → app_tx ``` --- ## 4. Key Components ### 4.1 App (State Machine) ```rust pub struct App { // Layout screen: Screen, // Chat state cells: Vec>, // committed cells active_cell: Option>, // streaming cell // Input composer: Composer, // Scroll scroll_offset: usize, // FORWARD: 0 = top, max = bottom user_scrolled: bool, // true = user scrolled up, disable auto-scroll // Turn worker turn_handle: Option>, turn_tx: Sender, // Frame scheduling frame_requester: FrameRequester, } ``` Screen states: ```rust pub enum Screen { Chat, // Normal chat mode PermissionPrompt, // Modal overlay for [Y/N/A] Help, // Keybinding help overlay } ``` ### 4.2 MessageCell Trait (Codex-rs Pattern) ```rust pub trait MessageCell: Send { /// Push streaming content into this cell fn push_delta(&mut self, delta: &str); /// Compute desired height at given width fn desired_height(&self, width: u16) -> u16; /// Render into ratatui area fn render(&self, area: Rect, buf: &mut Buffer); /// Whether this cell is still streaming fn is_streaming(&self) -> bool; /// Finalize — consolidate from fragmented stream state to /// a single re-renderable markdown cell (post-stream consolidation) fn consolidate(self: Box) -> Box; } ``` Cell types: - `UserMessageCell` — user input + timestamp - `AssistantMarkdownCell` — completed markdown (re-renderable from source on resize) - `AssistantStreamingCell` — active streaming cell (mutable, flush on completion) - `ToolResultCell` — tool name + collapsed/expanded output - `ThinkingCell` — collapsible thinking/reasoning block - `SystemCell` — status messages, errors ### 4.3 Scroll State (Forward Offset) ```rust pub struct ScrollState { /// Forward offset: 0 = top of conversation, max = pinned to bottom offset: usize, /// Maximum valid offset (recomputed on content change) max_offset: usize, /// Whether user has manually scrolled (disables auto-scroll) user_scrolled: bool, } impl ScrollState { /// Auto-scroll to bottom (on new content while not user-scrolled) pub fn auto_scroll_to_bottom(&mut self, total_lines: usize, viewport_height: usize) { if !self.user_scrolled { self.offset = total_lines.saturating_sub(viewport_height); self.max_offset = self.offset; } else { self.max_offset = total_lines.saturating_sub(viewport_height); self.offset = self.offset.min(self.max_offset); } } pub fn scroll_up(&mut self, amount: usize) { self.offset = self.offset.saturating_sub(amount); self.user_scrolled = true; } pub fn scroll_down(&mut self, amount: usize) { self.offset = (self.offset + amount).min(self.max_offset); if self.offset >= self.max_offset { self.user_scrolled = false; // Reached bottom, re-enable auto-scroll } } } ``` **Key rule:** `PageUp` = `scroll_up` (decrease offset = see older content). `PageDown` = `scroll_down` (increase offset = see newer content). This is **forward offset** — matches `Paragraph::scroll()` and user intuition. No more inverted model. ### 4.4 Composer (Input Area) ```rust pub struct Composer { textarea: TextArea<'static>, // tui-textarea history: Vec, history_index: usize, submitting: bool, } ``` - `tui-textarea` for multi-line editing with proper CJK/selection support - `Enter` = submit (configurable: ctrl+enter for newline) - `Up/Down` when at top/bottom of textarea = history navigation - `Tab` = autocomplete (slash commands, file paths) - `Ctrl+C` when empty = interrupt turn ### 4.5 TurnWorker (Runtime Bridge) ```rust pub struct TurnWorker { runtime: ConversationRuntime, tx: Sender, } impl TurnWorker { pub async fn run_turn( &mut self, user_input: String, mut runtime: ConversationRuntime, ) -> Result<(), Box> { // 1. Notify TUI that turn started self.tx.send(TuiTurnEvent::TurnStarted { user_input: user_input.clone() }).await?; // 2. Wrap the API client to intercept streaming events // BEFORE they're collected into Vec let streaming_tx = self.tx.clone(); // 3. Create a TUI-aware PermissionPrompter let prompter = TuiPermissionPrompter { tx: self.tx.clone(), }; // 4. Run the turn // PROBLEM: run_turn() is synchronous and internally calls // api_client.stream() which returns Vec. // We need to intercept events as they arrive. // Option A: Wrap ApiClient with a streaming interceptor // Option B: Modify ApiClient::stream() to accept a callback // Option C: Run on a separate thread with captured stdout (see §6) let result = runtime.run_turn(user_input, Some(&mut prompter)); match result { Ok(summary) => { self.tx.send(TuiTurnEvent::TurnCompleted { summary }).await?; } Err(e) => { self.tx.send(TuiTurnEvent::TurnFailed { error: e.to_string() }).await?; } } Ok(()) } } ``` ### 4.6 TuiPermissionPrompter (Channel Bridge) ```rust pub struct TuiPermissionPrompter { tx: Sender, } impl PermissionPrompter for TuiPermissionPrompter { fn decide(&mut self, request: &PermissionRequest) -> PermissionPromptDecision { let (response_tx, response_rx) = std::sync::mpsc::channel(); // Send permission request to TUI event loop let event = TuiTurnEvent::PermissionRequired { request: request.clone(), response_tx, }; // Use try_send or blocking send (we're on the worker thread) if self.tx.blocking_send(event).is_err() { return PermissionPromptDecision::Deny { reason: "TUI disconnected".into(), }; } // Block until TUI responds (this pauses the turn worker) response_rx.recv().unwrap_or(PermissionPromptDecision::Deny { reason: "No response".into(), }) } } ``` --- ## 5. Event Loop ```rust pub async fn run_app(mut app: App, mut terminal: Terminal>) -> Result<()> { let (app_tx, mut app_rx) = tokio::sync::mpsc::channel::(256); // Spawn crossterm event reader let crossterm_tx = app_tx.clone(); tokio::spawn(async move { loop { if crossterm::event::poll(Duration::from_millis(100)).unwrap_or(false) { if let Ok(event) = crossterm::event::read() { match event { CrosstermEvent::Key(k) => { let _ = crossterm_tx.send(AppEvent::Key(k)).await; } CrosstermEvent::Paste(s) => { let _ = crossterm_tx.send(AppEvent::Paste(s)).await; } CrosstermEvent::Resize(w, h) => { let _ = crossterm_tx.send(AppEvent::Resize { width: w, height: h }).await; } _ => {} } } } } }); // Streaming tick (4Hz — smooth enough, not wasteful) let tick_tx = app_tx.clone(); let tick_interval = tokio::time::interval(Duration::from_millis(250)); tokio::spawn(async move { let mut interval = tick_interval; loop { interval.tick().await; let _ = tick_tx.send(AppEvent::StreamTick).await; } }); // Frame rate limiter (cap at 30 FPS for terminal) let mut last_frame = Instant::now(); const MIN_FRAME_INTERVAL: Duration = Duration::from_millis(33); loop { let event = app_rx.recv().await; match event { Some(AppEvent::Key(key)) => app.handle_key(key), Some(AppEvent::Paste(text)) => app.handle_paste(text), Some(AppEvent::Resize { width, height }) => { terminal.resize(Rect::new(0, 0, width, height))?; app.request_frame(); } Some(AppEvent::TurnEvent(te)) => { app.handle_turn_event(te); app.request_frame(); } Some(AppEvent::StreamTick) => { if app.is_streaming() { app.request_frame(); } } Some(AppEvent::ScheduleFrame) => { let now = Instant::now(); if now.duration_since(last_frame) >= MIN_FRAME_INTERVAL { terminal.draw(|f| app.render(f))?; last_frame = now; } } None => break, } } Ok(()) } ``` **Why poll-based crossterm instead of EventStream:** - `EventStream` requires `crossterm/event-stream` feature + `tokio-stream` - Poll-based is simpler, no lifetime issues, works on all platforms - 100ms poll interval means ≤100ms latency for keypresses — imperceptible **Why 4Hz stream tick instead of 120Hz:** - codex-rs uses 120Hz for smooth typing animation — luxury for a terminal - 4Hz (250ms) is sufficient for visible streaming progress and MUCH cheaper - Can increase to 8-10Hz if users want smoother animation - Tokens arrive from API at ~5-50 tokens/sec anyway — 4Hz catches most chunks --- ## 6. The Streaming Problem: How to Get Live Tokens into the TUI This is the **hardest architectural decision**. `ApiClient::stream()` returns `Vec` (collected), not a live stream. We need tokens as they arrive. ### Option A: Wrap `ApiClient` with a Streaming Interceptor Create a `TuiApiClient` that wraps the real client: ```rust struct TuiApiClient { inner: C, tx: Sender, } impl ApiClient for TuiApiClient { fn stream(&mut self, request: ApiRequest) -> Result, RuntimeError> { // Call the real stream() — but we can't intercept mid-call // because it returns Vec, not an iterator // UNLESS we modify the trait to accept a callback: let events = self.inner.stream(request)?; // This arrives as a complete Vec — too late for live rendering Ok(events) } } ``` **Problem:** We can't intercept events mid-stream because `stream()` is synchronous and returns a collected `Vec`. The events are already all arrived by the time we get the return value. We'd need to modify the `ApiClient` trait. ### Option B: Modify `ApiClient::stream()` to Return an Async Stream ```rust pub trait ApiClient { fn stream(&mut self, request: ApiRequest) -> Pin> + '_>>; } ``` Then the `TurnWorker` can iterate events as they arrive and forward each one to the TUI before collecting for `build_assistant_message()`. **Pros:** Clean, correct, future-proof **Cons:** Requires modifying the `ApiClient` trait — more invasive, affects CLI mode too ### Option C: Run `ConversationRuntime::run_turn()` on a Thread with Captured stdout ```rust // In TurnWorker: let runtime = self.runtime.take().unwrap(); let tx = self.tx.clone(); std::thread::spawn(move || { // Capture stdout at the OS level let _guard = gag::BufferRedirect::stdout().unwrap(); // Run the turn — runtime prints to captured stdout let result = runtime.run_turn(user_input, Some(&mut prompter)); // Read captured output and send to TUI let mut output = String::new(); _guard.into_inner().read_to_string(&mut output).unwrap(); // Parse output into cells... }); ``` **Problem:** We get the output as a blob after the turn, not streaming. Same fundamental issue. ### Decision: Option B — Modify `ApiClient::stream()` to Return an Async Stream This is the only approach that gives us live streaming. The modification is: 1. Change the `ApiClient` trait to return `impl Stream>` 2. In CLI mode, `.collect::>()` at the call site — behavior unchanged 3. In TUI mode, iterate the stream and forward each event via the channel before collecting **Migration path:** ```rust // New trait pub trait ApiClient { fn stream_events( &mut self, request: ApiRequest, ) -> Pin> + Send + '_>>; // Convenience for CLI — backward compat fn stream(&mut self, request: ApiRequest) -> Result, RuntimeError> { let stream = self.stream_events(request); // Block on collection futures::executor::block_on(async { stream.try_collect().await }) } } ``` This is a breaking change to the trait, but it's contained to the `runtime` crate. The CLI call site already handles the collected `Vec`, so adding `stream_events()` as the primary method with `stream()` as a backward-compat wrapper minimizes disruption. **Alternative if trait change is rejected:** Run `run_turn()` on a blocking task in `tokio::spawn_blocking()`. Accept that we won't see streaming tokens — the TUI will show a spinner during thinking and update cells after each tool result (via `TurnProgressReporter`). Text rendering updates only after the full response is collected. This is acceptable for v1 but means no live typing animation. --- ## 7. Streaming Markdown Rendering ### 7.1 Active Cell + Committed Cells (Codex-rs Pattern) During streaming, there is one **mutable active cell** (`AssistantStreamingCell`) that receives `push_delta()` calls. When streaming ends, the active cell is **consolidated** into an `AssistantMarkdownCell` and moved to the committed cells list. ```rust pub struct AssistantStreamingCell { raw_markdown: String, // Accumulating source markdown cached_text: Option>, // Rendered text (dirty flag) cached_width: Option, // Width at which cache was built dirty: bool, } impl MessageCell for AssistantStreamingCell { fn push_delta(&mut self, delta: &str) { self.raw_markdown.push_str(delta); self.dirty = true; } fn desired_height(&self, width: u16) -> u16 { let text = self.get_or_rebuild_text(width); text.height() as u16 } fn render(&self, area: Rect, buf: &mut Buffer) { let text = self.get_or_rebuild_text(area.width); Paragraph::new(text).render(area, buf); } fn is_streaming(&self) -> bool { true } fn consolidate(self: Box) -> Box { Box::new(AssistantMarkdownCell { raw_markdown: self.raw_markdown, cached_text: None, cached_width: None, }) } } ``` ### 7.2 Markdown → ratatui Text Conversion Use `pulldown-cmark` → custom `Text` builder (same approach as `tui-markdown` but with syntect highlighting): ```rust pub fn markdown_to_text(md: &str, theme: &ColorTheme) -> Text<'static> { let mut lines = vec![]; let mut in_code_block = false; let mut code_lang = None; for event in pulldown_cmark::Parser::new_ext(md, Options::all()) { match event { Event::Start(Tag::Heading(level)) => { /* push styled heading line */ } Event::Start(Tag::CodeBlock(lang)) => { in_code_block = true; code_lang = Some(lang.to_string()); } Event::End(Tag::CodeBlock(_)) => { in_code_block = false; } Event::Text(text) => { if in_code_block { // syntect highlight → ansi-to-tui → Line } else { // Style with bold/italic/code spans } } _ => {} } } Text::from(lines) } ``` ### 7.3 Adaptive Chunking (From Codex-rs) During streaming, batch tokens on a 250ms tick (4Hz). On each tick: - If `< 3 new lines` → smooth mode: render 1 line (typing animation) - If `≥ 3 new lines` → catchup mode: render all pending lines immediately - On stream end: consolidate active cell → committed cell --- ## 8. Scroll Implementation ### 8.1 Cell-Based Scroll with Prefix-Sum Heights ```rust pub struct ChatScroll { /// Per-cell visual heights (recomputed on resize) cell_heights: Vec, /// Prefix sums of cell_heights (for O(log n) offset → cell lookup) height_offsets: Vec, /// Forward offset from top (0 = top, max = pinned to bottom) offset: usize, /// Whether user has scrolled (disables auto-scroll) user_scrolled: bool, } impl ChatScroll { /// Recompute when cells change or terminal resizes pub fn rebuild(&mut self, cells: &[Box], width: u16) { self.cell_heights = cells.iter().map(|c| c.desired_height(width)).collect(); self.height_offsets = Vec::with_capacity(self.cell_heights.len() + 1); self.height_offsets.push(0); let mut acc = 0usize; for &h in &self.cell_heights { acc += h as usize; self.height_offsets.push(acc); } // Clamp offset let total = *self.height_offsets.last().unwrap_or(&0); let viewport = ???; // needs viewport height self.offset = self.offset.min(total.saturating_sub(viewport)); } /// Find which cell is at the top of the viewport pub fn top_cell_index(&self) -> usize { self.height_offsets.partition_point(|&h| h <= self.offset) - 1 } } ``` ### 8.2 Auto-Scroll Logic ```rust // On new content (streaming delta, tool result, etc.) fn on_content_changed(&mut self) { if !self.scroll.user_scrolled { // Auto-scroll to bottom let total = self.scroll.height_offsets.last().copied().unwrap_or(0); self.scroll.offset = total.saturating_sub(self.viewport_height); } // Re-render self.request_frame(); } // On user keypress fn handle_scroll_key(&mut self, key: KeyEvent) { match key.code { KeyCode::PageUp => { self.scroll.user_scrolled = true; self.scroll.offset = self.scroll.offset.saturating_sub(self.viewport_height / 2); } KeyCode::PageDown => { self.scroll.offset += self.viewport_height / 2; let max = self.scroll.max_offset(self.viewport_height); if self.scroll.offset >= max { self.scroll.offset = max; self.scroll.user_scrolled = false; // Reached bottom } } KeyCode::Up if key.modifiers.contains(KeyModifiers::SHIFT) => { self.scroll.user_scrolled = true; self.scroll.offset = self.scroll.offset.saturating_sub(1); } KeyCode::Down if key.modifiers.contains(KeyModifiers::SHIFT) => { self.scroll.offset += 1; // Auto-reenable at bottom } _ => {} } } ``` --- ## 9. Permission Prompts ### 9.1 Inline Overlay (Recommended for v1) When `PermissionRequired` event arrives: 1. Pause the turn worker (it's blocked on the `oneshot::Receiver`) 2. Render a styled panel at the bottom of the chat area: ``` ┌─ Permission Required ─────────────────────────┐ │ Tool: bash │ │ Input: rm -rf /tmp/test │ │ │ │ [Y] Allow [N] Deny [A] Allow for session │ └─────────────────────────────────────────────────┘ ``` 3. On `[Y/N/A]` keypress → send response via `oneshot::Sender` 4. Turn worker unblocks and continues ### 9.2 Mouse Support Not for v1. Focus on keyboard-only interaction. --- ## 10. Layout ``` ┌──────────────────────────────────────────────────────────┐ │ ╔════════════════════════════════════════════════════╗ │ │ ║ [Chat Area — scrolls] ║ │ │ ║ ║ │ │ ║ User: What does this function do? ║ │ │ ║ ║ │ │ ║ Assistant: This function processes...█ ║ │ ← active cell (streaming) │ ║ ║ │ │ ╚════════════════════════════════════════════════════╝ │ │ │ │ ┌─ Input ─────────────────────────────────────────────┐ │ │ │ > Ask me anything... │ │ ← tui-textarea │ └──────────────────────────────────────────────────────┘ │ │ │ Model: glm-5 │ Tokens: 1.2k/3.4k │ Status: streaming│ ← status bar └──────────────────────────────────────────────────────────┘ ``` Constraint-based layout: - Chat area: flex=1 (takes remaining space) - Input area: natural height (1-5 lines based on textarea content) - Status bar: fixed 1 line --- ## 11. Output Bleeding Prevention (The Complete Solution) All three layers must work together: ### Layer 1: Render to stderr ```rust let backend = CrosstermBackend::new(std::io::stderr()); ``` Stdout is now free — child processes can write to it without corrupting the TUI. The TUI never reads from fd 1 for rendering. ### Layer 2: Capture child process stdout When executing tools that spawn processes (bash, git, etc.): ```rust // In tool executor: let output = Command::new("bash") .arg("-c") .arg(command) .stdout(Stdio::piped()) // Capture, don't inherit .stderr(Stdio::piped()) .output() .await?; ``` Tool output goes to `ToolResultCell` via the channel, not to the terminal. ### Layer 3: Suppress internal runtime prints The runtime's `TerminalRenderer` / `LiveCli` prints go through the `Write` trait. In TUI mode: - Replace `io::stdout()` with a no-op writer for the turn duration, OR - Better: implement `TurnProgressReporter` that forwards events instead of printing ### Layer 4: Alternate screen + synchronized updates ```rust execute!(stderr, EnterAlternateScreen)?; enable_raw_mode()?; // ... TUI loop ... execute!(stderr, LeaveAlternateScreen)?; disable_raw_mode()?; ``` Use `crossterm`'s `SynchronizedUpdate` to batch terminal writes and prevent partial frames. ### Layer 5: TerminalStderrGuard (macOS) On macOS, system frameworks may write to fd 2. Redirect fd 2 to `/dev/null` during TUI ownership (codex-rs pattern). On Linux, this is typically not needed. --- ## 12. Testing Strategy ### 12.1 Widget Unit Tests (ratatui TestBackend) ```rust #[test] fn renders_user_message() { let cell = UserMessageCell::new("Hello world"); let area = Rect::new(0, 0, 80, 5); let mut buf = Buffer::empty(area); cell.render(area, &mut buf); // Assert on buffer contents assert_eq!(buf.cell((0, 0)).unwrap().symbol(), "H"); } ``` ### 12.2 Snapshot Tests (insta) ```rust #[test] fn snapshot_chat_widget() { let mut app = App::new_test(); app.add_user_message("Hello"); app.add_assistant_message("Hi there!"); let area = Rect::new(0, 0, 80, 24); let mut buf = Buffer::empty(area); app.render_chat(area, &mut buf); insta::assert_snapshot!(format!("{:?}", buf)); } ``` ### 12.3 Event-Based Integration Tests ```rust #[tokio::test] async fn test_scroll_after_streaming() { let (app_tx, app_rx) = mpsc::channel(256); let mut app = App::new_with_channel(app_tx.clone()); // Simulate streaming events app_tx.send(AppEvent::TurnEvent(TuiTurnEvent::TurnStarted { user_input: "test".into() })).await.unwrap(); app_tx.send(AppEvent::TurnEvent(TuiTurnEvent::TextDelta { text: "Hello world".into() })).await.unwrap(); app_tx.send(AppEvent::TurnEvent(TuiTurnEvent::TurnCompleted { summary: /* ... */ })).await.unwrap(); // Process events while let Ok(event) = app_rx.try_recv() { app.handle_event(event); } assert_eq!(app.cells.len(), 2); // user + assistant assert!(!app.scroll_state.user_scrolled); } ``` ### 12.4 OpenClaw Integration (Future PR) ```bash # clawcode --tui-stdio mode # Spawns TUI, exposes JSON-over-stdio protocol: # - Input: {"type":"key","key":"Enter"} or {"type":"prompt","text":"hello"} # - Output: {"type":"frame","cells":[...]} or {"type":"state","scroll":0,"cells":2} # OpenClaw can drive the TUI like a user, verify screen state, catch regressions ``` --- ## 13. File Structure ``` rust/crates/rusty-claude-cli/src/tui/ ├── mod.rs // TUI module entry + `claw tui` command ├── app.rs // App state machine + event loop ├── event.rs // AppEvent, TuiTurnEvent enums ├── frame.rs // FrameRequester + rate limiter ├── scroll.rs // ScrollState + ChatScroll with prefix sums ├── run.rs // TurnWorker + TuiPermissionPrompter ├── render/ │ ├── mod.rs // markdown → Text conversion │ ├── cells.rs // MessageCell trait + all cell types │ └── theme.rs // ColorTheme ported from render.rs ├── components/ │ ├── mod.rs │ ├── chat.rs // ChatWidget (renders cells + scroll) │ ├── composer.rs // Input composer wrapping tui-textarea │ ├── status_bar.rs // Model, tokens, status display │ └── permission.rs // Permission prompt overlay └── test_utils.rs // Test helpers, mock event sources ``` --- ## 14. Implementation Phases ### Phase 0: Prerequisites (1-2 days) ✅ - [x] Streaming error fixes (reasoning_content, optional delta, JSON/HTML errors) - [x] CLI module extraction (agent.rs, team.rs, permission.rs, etc.) - [ ] Modify `ApiClient::stream()` to return async stream (Option B from §6) - [ ] Add `TuiTurnEvent` enum to runtime crate ### Phase 1: Skeleton (3-4 days) - [ ] `tui/mod.rs` — `claw tui` subcommand entry point - [ ] `app.rs` — App struct, event loop, stderr backend - [ ] `event.rs` — All event types - [ ] `frame.rs` — FrameRequester + 30 FPS cap - [ ] Alternate screen enter/exit with cleanup on panic - [ ] Basic layout: chat area + input area + status bar - [ ] Composer with tui-textarea - [ ] Exit on Ctrl+C / `/exit` **Milestone:** Can type a prompt, see it echoed, and exit cleanly. ### Phase 2: Live Streaming (5-7 days) — THE CRITICAL PHASE - [ ] `run.rs` — TurnWorker with channel bridge - [ ] `render/cells.rs` — MessageCell trait + AssistantStreamingCell - [ ] `render/mod.rs` — Markdown → Text with syntect highlighting - [ ] `render/theme.rs` — ColorTheme from render.rs - [ ] Streaming event handling: TextDelta → push_delta → render on tick - [ ] ThinkingCell with collapsible display (default: collapsed) - [ ] ToolResultCell with collapsed/expanded toggle - [ ] Post-stream consolidation (streaming → markdown cell) - [ ] Permission prompts with oneshot channel bridge **Milestone:** Can submit a prompt, see the response stream live in the TUI, handle tool calls and permissions. ### Phase 3: Scroll & Polish (3-4 days) - [ ] `scroll.rs` — Forward-offset ScrollState with prefix sums - [ ] `components/chat.rs` — ChatWidget with cell-based scroll - [ ] Auto-scroll with user-scroll detection - [ ] Mouse wheel scroll (crossterm ScrollUp/ScrollDown events) - [ ] Shift+Up/Down for fine scroll - [ ] Status bar with model name, token count, spinner **Milestone:** Smooth scrolling, auto-scroll during streaming, manual scroll to review history. ### Phase 4: Polish & Testing (2-3 days) - [ ] Resize handling (rebuild cell heights, clamp scroll) - [ ] CJK rendering test cases - [ ] Widget unit tests + insta snapshots - [ ] Event-based integration tests - [ ] Help overlay (`?` key) - [ ] Chat history persistence across sessions - [ ] Slash command dispatch - [ ] `cargo clippy` + `cargo test` green **Milestone:** PR-ready. All tests passing, no warnings. ### Phase 5: OpenClaw Integration (Separate PR) - [ ] `--tui-stdio` mode for programmatic driving - [ ] OpenClaw `clawcode` tool extension - [ ] Automated TUI regression tests in CI --- ## 15. Risk Register | Risk | Likelihood | Impact | Mitigation | |------|-----------|--------|------------| | `ApiClient` trait change rejected by upstream | Medium | High | Fallback: v1 without live streaming, spinner-only during thinking | | `tui-textarea` conflicts with crossterm event handling | Low | Medium | Vendored copy (codex-rs does this) with patches | | Terminal emulators with broken alternate screen | Low | Medium | Test on: kitty, alacritty, iterm2, windows terminal, foot | | `gag::BufferRedirect` not available on Windows | Medium | Low | Feature-gate; Windows uses `Stdio::piped()` for child processes | | Token streaming too fast for 4Hz tick | Low | Low | Adaptive chunking: catchup mode drains all pending on tick | | `RefCell` borrow conflicts in render | Medium | Medium | Use interior mutability via `Cell>` instead of `RefCell` | --- ## 16. Key Decisions Summary | Decision | Choice | Reason | |----------|--------|--------| | Render to stdout or stderr? | **stderr** | Frees stdout for child capture, prevents bleeding | | Event loop: poll or EventStream? | **Poll** | Simpler, no lifetime issues, cross-platform | | Frame rate | **30 FPS cap** | Terminal doesn't need 120Hz; saves CPU | | Stream tick rate | **4 Hz (250ms)** | Smooth enough for streaming text; can increase | | Scroll model | **Forward offset (0=top)** | Matches Paragraph::scroll(), matches user intuition | | Scroll unit | **Cells, not lines** | Stable on resize; each cell handles its own wrapping | | Live streaming | **Modify ApiClient trait** | Only way to get live tokens; fallback: spinner-only v1 | | Input widget | **tui-textarea** | Proven in codex-rs; CJK, selection, multi-line | | Markdown rendering | **Custom pulldown-cmark → Text** | tui-markdown is experimental; we need syntect | | Permission prompts | **Inline overlay** | Simpler than popup; blocks turn worker via oneshot | | Testing | **insta snapshots + event injection** | Proven pattern; no real terminal needed |