claw-code/docs/tui/ARCHITECTURE.md

38 KiB

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<AssistantEvent>         │
│       │   └── 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:

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

#[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<String, String> },

    // === Permission (interactive — blocks turn worker) ===
    PermissionRequired {
        request: PermissionRequest,
        response_tx: oneshot::Sender<PermissionPromptDecision>,
    },

    // === Lifecycle ===
    TurnCompleted { summary: TurnSummary },
    TurnFailed { error: String },

    // === Progress ===
    IterationProgress { iteration: usize, max_iterations: usize },
}

3.2 AppEvent — Internal TUI Channel

#[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

// Bounded channels with backpressure
let (app_tx, app_rx) = tokio::sync::mpsc::channel::<AppEvent>(256);
let (turn_tx, turn_rx) = tokio::sync::mpsc::channel::<TuiTurnEvent>(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)

pub struct App {
    // Layout
    screen: Screen,

    // Chat state
    cells: Vec<Box<dyn MessageCell>>,  // committed cells
    active_cell: Option<Box<dyn MessageCell>>,  // 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<JoinHandle<()>>,
    turn_tx: Sender<TuiTurnEvent>,

    // Frame scheduling
    frame_requester: FrameRequester,
}

Screen states:

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)

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<Self>) -> Box<dyn MessageCell>;
}

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)

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)

pub struct Composer {
    textarea: TextArea<'static>,  // tui-textarea
    history: Vec<String>,
    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)

pub struct TurnWorker {
    runtime: ConversationRuntime,
    tx: Sender<TuiTurnEvent>,
}

impl TurnWorker {
    pub async fn run_turn(
        &mut self,
        user_input: String,
        mut runtime: ConversationRuntime,
    ) -> Result<(), Box<dyn std::error::Error>> {
        // 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<AssistantEvent>
        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<AssistantEvent>.
        //    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)

pub struct TuiPermissionPrompter {
    tx: Sender<TuiTurnEvent>,
}

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

pub async fn run_app(mut app: App, mut terminal: Terminal<CrosstermBackend<Stderr>>) -> Result<()> {
    let (app_tx, mut app_rx) = tokio::sync::mpsc::channel::<AppEvent>(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<AssistantEvent> (collected), not a live stream. We need tokens as they arrive.

Option A: Wrap ApiClient with a Streaming Interceptor

Create a TuiApiClient<C: ApiClient> that wraps the real client:

struct TuiApiClient<C: ApiClient> {
    inner: C,
    tx: Sender<TuiTurnEvent>,
}

impl<C: ApiClient> ApiClient for TuiApiClient<C> {
    fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, 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

pub trait ApiClient {
    fn stream(&mut self, request: ApiRequest)
        -> Pin<Box<dyn Stream<Item = Result<AssistantEvent, RuntimeError>> + '_>>;
}

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

// 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<Item = Result<AssistantEvent, RuntimeError>>
  2. In CLI mode, .collect::<Vec<_>>() at the call site — behavior unchanged
  3. In TUI mode, iterate the stream and forward each event via the channel before collecting

Migration path:

// New trait
pub trait ApiClient {
    fn stream_events(
        &mut self,
        request: ApiRequest,
    ) -> Pin<Box<dyn Stream<Item = Result<AssistantEvent, RuntimeError>> + Send + '_>>;

    // Convenience for CLI — backward compat
    fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, 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.

pub struct AssistantStreamingCell {
    raw_markdown: String,           // Accumulating source markdown
    cached_text: Option<Text<'static>>,  // Rendered text (dirty flag)
    cached_width: Option<u16>,     // 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<Self>) -> Box<dyn MessageCell> {
        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):

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

pub struct ChatScroll {
    /// Per-cell visual heights (recomputed on resize)
    cell_heights: Vec<u16>,
    /// Prefix sums of cell_heights (for O(log n) offset → cell lookup)
    height_offsets: Vec<usize>,
    /// 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<dyn MessageCell>], 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

// 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

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

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.):

// 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

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)

#[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)

#[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

#[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)

# 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)

  • Streaming error fixes (reasoning_content, optional delta, JSON/HTML errors)
  • 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.rsclaw 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<Option<Cache>> 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