claw-code/docs/tui/moa-ecosystem.md

1052 lines
36 KiB
Markdown

# TUI MOA Ecosystem Report: Rust TUI Patterns for claw-code
**Date:** 2026-06-12
**Purpose:** Survey the broader Rust TUI ecosystem for best-in-class solutions to claw-code's TUI architecture problems.
---
## Table of Contents
1. [Streaming Text Rendering in Ratatui](#1-streaming-text-rendering-in-ratatui)
2. [Output Isolation / stdout Capture in Rust](#2-output-isolation--stdout-capture-in-rust)
3. [Async Runtime + Ratatui Patterns](#3-async-runtime--ratatui-patterns)
4. [Scroll State Management](#4-scroll-state-management)
5. [Permission Prompts in TUI](#5-permission-prompts-in-tui)
6. [Testing TUIs in Rust](#6-testing-tuis-in-rust)
---
## 1. Streaming Text Rendering in Ratatui
### 1.1 tui-markdown: Live Markdown Rendering
**Crate:** `tui-markdown` (by Josh McKinney / joshka, ratatui core maintainer)
**Repo:** https://github.com/joshka/tui-markdown
**Status:** Experimental proof-of-concept, actively maintained
**How it works:**
- Uses `pulldown-cmark` to parse markdown into events
- Converts those events into a `ratatui::text::Text` value
- Renders the `Text` widget directly via `frame.render_widget(text, area)`
**Key API:**
```rust
use tui_markdown::from_str;
let markdown = "# Heading\n\n**bold** text with `code`";
let text = from_str(markdown);
text.render(area, &mut buf);
```
**For streaming:** There is no built-in streaming/incremental mode. The entire markdown string is parsed each time. The pattern for live-updating is:
```rust
// In your app state, accumulate streaming content
struct AppState {
stream_buffer: String,
}
// On each token arrival, append and re-render
fn on_token(&mut self, token: &str) {
self.stream_buffer.push_str(token);
// Re-convert the entire buffer on each render
let text = tui_markdown::from_str(&self.stream_buffer);
// render...
}
```
**Performance consideration:** For long conversations, full re-parse each frame is O(n). Mitigations:
- Only re-parse when new tokens arrive (set a dirty flag)
- Cache the `Text` and only re-convert on change
- For very long histories, consider truncating the visible portion
**Supported features:** Headings, bold, italic, strikethrough, ordered/unordered lists, code blocks, block quotes, task lists, metadata blocks, footnotes, links. **NOT supported:** Tables, images, HTML, math.
**Code highlighting:** Optional `highlight-code` feature uses `syntect` for syntax-colored code blocks, which then get converted via `ansi-to-tui` back to ratatui `Text`.
### 1.2 ansi-to-tui: Preserving ANSI Colors
**Crate:** `ansi-to-tui`
**Docs:** https://docs.rs/ansi-to-tui
**Purpose:** Convert ANSI escape sequences to ratatui `Text` with proper `Color` and `Style`
This is critical for displaying child process output that contains ANSI color codes (e.g., `git diff --color`, test runners, etc.).
```rust
use ansi_to_tui::IntoText;
let bytes = b"\x1b[38;2;225;192;203mcolored text\x1b[0m".to_vec();
let text = bytes.into_text()?;
frame.render_widget(text, area);
```
**Supports:** 3/4-bit named colors, 8-bit indexed, 24-bit truecolor RGB, bold/italic/underline/strikethrough modifiers. Has optional SIMD acceleration via `simdutf8` feature.
### 1.3 Virtual Scrolling / Viewport Management with Variable-Height Rows
**No dedicated crate for virtual scrolling of variable-height rows exists in the ratatui ecosystem.** This is the hardest unsolved problem for chat-style UIs.
**Available approaches:**
#### tui-scrollview (by joshka, official ratatui org)
**Crate:** `tui-scrollview`
**Docs:** https://docs.rs/tui-scrollview
**Repo:** Part of https://github.com/ratatui/tui-widgets
Provides a `ScrollView` widget that renders content to an off-screen buffer and supports scrolling. However, it requires a **fixed content size** upfront:
```rust
use tui_scrollview::{ScrollView, ScrollViewState};
let content_size = Size::new(100, 30); // Must know total size
let mut scroll_view = ScrollView::new(content_size);
scroll_view.render_widget(Paragraph::new(text), Rect::new(0, 0, 100, 30));
scroll_view.render(buf.area, buf, state);
```
**Limitation for chat UIs:** You must know the total content height in advance. For word-wrapped variable-height rows, you'd need to pre-compute heights, which requires knowing the terminal width at layout time.
#### DIY Virtual Scroll Pattern (recommended)
For claw-code's chat history with variable-height rows, implement your own:
```rust
struct VirtualScroll {
/// Each item has a computed height (in lines)
item_heights: Vec<u16>,
/// Cumulative height sums for binary search
height_offsets: Vec<usize>,
/// Current scroll offset (in total lines from top)
scroll_offset: usize,
/// Visible area height
viewport_height: u16,
}
impl VirtualScroll {
/// Find which item is at the top of the viewport
fn top_item(&self) -> usize {
self.height_offsets.partition_point(|&h| h <= self.scroll_offset)
}
/// Get all items visible in current viewport
fn visible_items(&self) -> impl Iterator<Item = (usize, u16)> {
let start = self.top_item();
let mut y = 0u16;
self.item_heights[start..].iter().enumerate().take_while(|&(_, h)| {
y += h; y <= self.viewport_height
}).map(move |(i, &h)| (start + i, h))
}
/// Recalculate heights after resize (must be called on width change)
fn recalc_heights(&mut self, items: &[ChatItem], width: u16) {
self.item_heights = items.iter()
.map(|item| item.wrap_height(width))
.collect();
self.height_offsets = std::iter::once(0)
.chain(self.item_heights.iter().scan(0, |acc, &h| {
*acc += h as usize; Some(*acc)
}))
.collect();
}
}
```
**Key insight:** Height computation must happen *after* you know the render width. On resize, recompute all heights. This is unavoidable for variable-height content.
### 1.4 Lazy/Incremental Rendering of Large Chat Histories
No existing crate. The pattern used by aichat and similar tools:
```rust
struct ChatBuffer {
/// Ring buffer of recent messages (fixed-size)
messages: VecDeque<ChatMessage>,
/// Max messages to keep in memory
max_messages: usize,
/// Scroll state
scroll: VirtualScroll,
}
impl ChatBuffer {
fn push(&mut self, msg: ChatMessage) {
if self.messages.len() >= self.max_messages {
self.messages.pop_front();
// Adjust scroll offset
}
self.messages.push_back(msg);
// Mark scroll as needing recalculation
}
}
```
**aichat's approach:** Uses a `Vec<ChatMessage>` with custom scroll tracking. Messages are rendered as `Paragraph` widgets with `.wrap(Wrap { trim: false })`. Auto-scroll is maintained by tracking whether the user was at the bottom before the last update.
---
## 2. Output Isolation / stdout Capture in Rust
This is the critical problem: when tool execution (child processes, library code) writes to stdout, it must not corrupt the TUI rendering.
### 2.1 The Problem
When ratatui draws to the terminal via crossterm's alternate screen, any `println!` or child process stdout write goes to the *same* file descriptor (fd 1). Since crossterm writes raw escape sequences to fd 1, interleaved output corrupts the display.
### 2.2 Approaches Compared
#### Approach A: `gag` crate — RAII stdout/stderr suppression
**Crate:** `gag` (by ipetkov)
**Docs:** https://docs.rs/gag
**How it works:** Uses `libc::dup` / `libc::dup2` to swap out fd 1/2 with `/dev/null`, restoring on drop.
```rust
use gag::Gag;
// Suppress stdout for the duration of this scope
let _gag = Gag::stdout().unwrap();
// Everything printed here goes to /dev/null
println!("this is suppressed");
// _gag dropped here, stdout restored
```
**Pros:** Simple RAII pattern, works for blocking the current thread.
**Cons:** Not thread-safe! If another thread writes to stdout while gagged, it's also suppressed. Global fd-level operation — affects entire process.
**Verdict:** ❌ Not suitable for TUI apps with async runtimes. The global nature conflicts with other threads.
#### Approach B: `capture-stdio` crate — pipe-then-dup
**Crate:** `capture-stdio`
**Docs:** https://docs.rs/capture-stdio
**How it works:** Two methods:
1. `std::io::set_output_capture` — same mechanism as `cargo test` uses for capturing test output. Nightly-only internal API.
2. Pipe-then-dup — creates a pipe, replaces the fd, reads from the pipe.
```rust
use capture_stdio::pipe::PipedStdout;
let mut capture = PipedStdout::capture().unwrap();
// stdout now goes to the pipe
println!("captured!");
let output = capture.read_to_string().unwrap();
capture.restore().unwrap(); // restore original stdout
```
**Pros:** Can actually capture and read the output.
**Cons:** Same global fd problem. `PipedStdout::read_to_string()` blocks until the pipe is closed. Not async-friendly.
**Verdict:** ⚠️ Better than `gag` for testing, but still globally affects process fds. Not great for production TUI.
#### Approach C: `os_pipe` — Low-level pipe primitives (now std::io::pipe)
**Crate:** `os_pipe`
**Docs:** https://docs.rs/os_pipe
**Note:** Rust 1.87 added `std::io::pipe()`, making this crate unnecessary for new code.
```rust
let (reader, writer) = os_pipe::pipe()?;
let writer_clone = writer.try_clone()?;
let mut cmd = Command::new("some-tool");
cmd.stdout(writer).stderr(writer_clone);
let mut handle = cmd.spawn()?;
drop(cmd); // Close parent's copy of writer to avoid deadlock
let mut output = String::new();
reader.read_to_string(&mut output)?;
```
**Pros:** Clean, no fd swapping. Each child process gets its own pipe.
**Cons:** Requires you to spawn the process yourself (not just call a function that happens to print).
**Verdict:****Recommended approach for child processes.** Use `Command::stdout(Stdio::piped())` or `os_pipe` for child process output capture. This is the safe, non-global approach.
#### Approach D: Per-thread stdout via thread-local override
**Not a real crate, but the pattern:**
```rust
use std::cell::RefCell;
use std::io::Write;
thread_local! {
static STDOUT_OVERRIDE: RefCell<Option<Box<dyn Write + Send>>> = RefCell::new(None);
}
// Override println-like behavior for your helpers
fn safe_println(s: &str) {
STDOUT_OVERRIDE.with(|ov| {
if let Some(ref mut w) = *ov.borrow_mut() {
let _ = w.write_all(s.as_bytes());
let _ = w.write_all(b"\n");
} else {
println!("{}", s);
}
});
}
```
**Verdict:** ⚠️ Requires modifying all output paths. Not practical for third-party code.
#### Approach E: Redirect stdout for tool threads using libc::dup2 (what claw-code likely has)
```rust
fn with_captured_stdout<F, R>(f: F) -> (R, String)
where F: FnOnce() -> R {
// Create pipe
let (read_fd, write_fd) = {
let mut fds = [0i32; 2];
unsafe { libc::pipe(fds.as_mut_ptr()) };
(fds[0], fds[1])
};
// Save original stdout
let saved = unsafe { libc::dup(1) };
// Replace stdout with pipe writer
unsafe { libc::dup2(write_fd, 1) };
unsafe { libc::close(write_fd) };
// Run the function
let result = f();
// Restore original stdout
unsafe { libc::dup2(saved, 1) };
unsafe { libc::close(saved) };
// Read captured output
let mut output = Vec::new();
let mut buf = [0u8; 4096];
loop {
let n = unsafe { libc::read(read_fd, buf.as_mut_ptr() as *mut libc::c_void, 4096) };
if n <= 0 { break; }
output.extend_from_slice(&buf[..n as usize]);
}
unsafe { libc::close(read_fd) };
(result, String::from_utf8_lossy(&output).to_string())
}
```
**Verdict:** ⚠️ Works but has race conditions with other threads. The dup2 syscall is process-wide.
### 2.3 Recommended Architecture for claw-code
**The winning pattern is: don't fight fd 1 at all.**
1. **For child process execution:** Use `Command::stdout(Stdio::piped())` + `Command::stderr(Stdio::piped())`. Read from the pipes on a tokio task and forward output to the TUI via an `mpsc` channel. This is clean, no fd manipulation needed.
```rust
async fn run_tool(cmd: &mut Command, tx: &mpsc::Sender<AppEvent>) -> Result<ExitStatus> {
cmd.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = cmd.spawn()?;
let stdout = child.stdout.take().unwrap();
let stderr = child.stderr.take().unwrap();
// Read in parallel using tokio
let stdout_reader = tokio::io::BufReader::new(stdout);
let stderr_reader = tokio::io::BufReader::new(stderr);
// Spawn tasks to read lines and forward to TUI
let tx_clone = tx.clone();
tokio::spawn(async move {
use tokio::io::AsyncBufReadExt;
let mut lines = stdout_reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
let _ = tx_clone.send(AppEvent::ToolOutput { stream: "stdout", line }).await;
}
});
// Similarly for stderr...
child.wait().await
}
```
2. **For library code that prints to stdout:** Wrap it in `std::io::set_output_capture` (nightly/unstable) or accept that you need `dup2` with proper synchronization. The better long-term solution is to refactor such libraries to accept a `Write` sink.
3. **For the TUI itself:** Always write to the alternate screen via crossterm. The crossterm backend writes directly to `std::io::stderr()` by default (since ratatui 0.26+ with `CrosstermBackend::new(std::io::stderr())`), which means stdout is free for capture without affecting TUI rendering.
```rust
// Key insight: use stderr for the TUI backend, not stdout
let backend = CrosstermBackend::new(std::io::stderr());
let mut terminal = Terminal::new(backend)?;
```
This is the **#1 most important fix**: if ratatui renders to stderr, then stdout capture for tools becomes trivial because they operate on different file descriptors.
---
## 3. Async Runtime + Ratatui Patterns
### 3.1 The Right Architecture
Based on the ratatui FAQ and the official `async-template` (now `ratatui/templates`), the recommended architecture for a tokio + ratatui app is:
```
┌─────────────────────────────────────────────────────────┐
│ Tokio Runtime │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌───────────────┐ │
│ │ Event Task │ │ App Logic │ │ Render Task │ │
│ │ │ │ Tasks │ │ (optional) │ │
│ │ crossterm │ │ │ │ │ │
│ │ events → │──│→ mpsc ──│──│→ terminal.draw │ │
│ │ tx │ │ channel │ │ │ │
│ └─────────────┘ └──────────────┘ └───────────────┘ │
└─────────────────────────────────────────────────────────┘
```
### 3.2 Event Handling: Poll vs EventStream
**Option A: `crossterm::event::poll()` + `read()` (recommended)**
```rust
fn spawn_event_reader(tx: mpsc::Sender<AppEvent>) -> JoinHandle<()> {
tokio::spawn(async move {
loop {
// Poll with timeout to allow checking for shutdown
if crossterm::event::poll(Duration::from_millis(100)).unwrap() {
let event = crossterm::event::read().unwrap();
if tx.send(AppEvent::Crossterm(event)).await.is_err() {
break; // Receiver dropped, shutdown
}
}
}
})
}
```
**Option B: `crossterm::EventStream` (async-stream)**
```rust
use crossterm::event::EventStream;
use futures::StreamExt;
fn spawn_event_stream(tx: mpsc::Sender<AppEvent>) -> JoinHandle<()> {
tokio::spawn(async move {
let mut reader = EventStream::new();
while let Some(event) = reader.next().await {
match event {
Ok(event) => {
if tx.send(AppEvent::Crossterm(event)).await.is_err() {
break;
}
}
Err(e) => eprintln!("Error: {e}"),
}
}
})
}
```
**Recommendation:** Use **Option A (poll-based)**. Here's why:
- `EventStream` pulls in `futures-core` and has had quirky edge cases around terminal teardown
- Poll-based is simpler and more reliable across platforms
- The 100ms poll timeout doubles as both event sampling and an idle timer
- Works identically on all platforms without the `event-stream` feature flag
### 3.3 Main Loop Pattern
```rust
async fn run_app(mut terminal: Terminal<CrosstermBackend<Stderr>>, mut app: App) -> Result<()> {
let (tx, mut rx) = mpsc::channel::<AppEvent>(100);
// Spawn event reader
let event_task = spawn_event_reader(tx.clone());
// Spawn LLM streaming task
let llm_task = spawn_llm_handler(tx.clone());
// Tick rate: how often we get "tick" events for animations/updates
let tick_rate = Duration::from_millis(250);
let mut last_tick = Instant::now();
loop {
// Draw the UI
terminal.draw(|f| app.draw(f))?;
// Wait for the next event, with a tick timeout
let timeout = tick_rate.saturating_sub(last_tick.elapsed());
let event = tokio::select! {
event = rx.recv() => event,
_ = tokio::time::sleep(timeout) => Some(AppEvent::Tick),
};
match event {
Some(AppEvent::Crossterm(event)) => app.handle_event(event)?,
Some(AppEvent::Tick) => {
last_tick = Instant::now();
app.on_tick();
}
Some(AppEvent::LlmToken(token)) => app.on_token(token),
Some(AppEvent::ToolOutput { stream, line }) => app.on_tool_output(stream, line),
None => break, // Channel closed = shutdown
}
}
Ok(())
}
```
### 3.4 Tick Rate / Frame Rate Strategy
**Key insight from the ratatui FAQ and async-template:**
- **Tick rate** (app logic updates): 4-10 Hz (100-250ms). Controls how often animations, progress bars, and streaming text updates are pushed.
- **Frame rate** is effectively "render on every event + tick." Since ratatui uses double-buffering and only renders diffs, even 60fps rendering is cheap.
- **For streaming chat:** Don't render on every single token. Batch tokens and render on ticks. This reduces CPU usage with no visible degradation.
```rust
struct App {
/// Accumulated but not-yet-rendered tokens
pending_tokens: String,
/// Whether we need to re-render
dirty: bool,
}
impl App {
fn on_token(&mut self, token: &str) {
self.pending_tokens.push_str(token);
self.dirty = true;
// Don't render here — let the tick/render loop handle it
}
fn on_tick(&mut self) {
if self.dirty {
// Flush pending tokens to the display buffer
self.flush_tokens();
self.dirty = false;
}
}
}
```
### 3.5 Windows Key Event Gotcha
From ratatui FAQ — on Windows, key events fire twice (Press + Release). Filter:
```rust
CrosstermEvent::Key(key) => {
if key.kind == KeyEventKind::Press {
tx.send(AppEvent::Key(key)).await.unwrap();
}
}
```
---
## 4. Scroll State Management
### 4.1 The Core Problem
Word-wrapped content in ratatui produces variable-height rows. `Paragraph::wrap()` handles rendering, but scroll state must be managed externally because:
- `Paragraph` with `.scroll((offset, 0))` scrolls by *lines*, not by *items*
- With variable-height items, line-based scroll doesn't map to item-based scroll
- The total number of lines is unknown until you render at a given width
### 4.2 How tui-textarea Handles Scroll-to-Cursor
**Crate:** `tui-textarea` (by rhysd)
**Repo:** https://github.com/rhysd/tui-textarea
`tui-textarea` maintains its own scroll state with a sophisticated model:
```rust
pub struct TextArea<'a> {
// Lines of text (each line is a Spans)
lines: Vec<Line<'a>>,
// Cursor position (row, col in the logical text)
cursor: (usize, usize),
// Scroll offset (row, col in the viewport)
scroll: (u16, u16),
// Viewport size (updated on each render)
size: (u16, u16),
}
```
**Scroll-to-cursor logic:**
1. On each render, check if the cursor is within the visible viewport
2. If cursor is above the viewport → scroll up
3. If cursor is below the viewport → scroll down
4. The scroll offset is in *line* units, not character units
5. Soft wrapping is handled by breaking lines into visual lines and tracking visual line count
**Key takeaway:** The textarea keeps a flat array of visual lines after wrapping, and scroll operates on visual-line indices. This is the model claw-code should adopt for chat history.
### 4.3 Custom Virtual List / Lazy Rendering
No existing crate provides this for variable-height content. Here's the recommended data structure:
```rust
/// A chat history with efficient scrolling over variable-height items.
pub struct ChatHistory {
/// All messages in the conversation
messages: Vec<ChatMessage>,
/// Pre-computed visual line heights for each message at the current width
/// (message_index → number of visual lines)
visual_heights: Vec<u16>,
/// Prefix sums of visual_heights for O(log n) item→line and line→item lookups
line_offsets: Vec<usize>,
/// Current scroll position (visual line index from top)
scroll_line: usize,
/// Viewport height in lines
viewport_height: u16,
/// Last width used for height computation (to detect resize)
cached_width: u16,
/// Whether to auto-scroll to bottom on new content
auto_scroll: bool,
}
impl ChatHistory {
/// Recompute visual heights after width change or new message
pub fn recompute(&mut self, width: u16) {
if width == self.cached_width && self.visual_heights.len() == self.messages.len() {
return; // Nothing changed
}
self.cached_width = width;
self.visual_heights = self.messages.iter()
.map(|msg| msg.visual_line_count(width))
.collect();
self.line_offsets = std::iter::once(0usize)
.chain(self.visual_heights.iter().scan(0, |acc, &h| {
*acc += h as usize;
Some(*acc)
}))
.collect();
}
/// Find the message index at a given visual line
pub fn message_at_line(&self, line: usize) -> usize {
self.line_offsets.partition_point(|&offset| offset <= line).saturating_sub(1)
}
/// Get the range of messages visible in the current viewport
pub fn visible_range(&self) -> Range<usize> {
let start = self.message_at_line(self.scroll_line);
let end_line = self.scroll_line + self.viewport_height as usize;
let end = self.message_at_line(end_line.min(self.total_lines() - 1)) + 1;
start..end.min(self.messages.len())
}
/// Total visual lines
pub fn total_lines(&self) -> usize {
self.line_offsets.last().copied().unwrap_or(0)
}
/// Add a message and update scroll
pub fn push(&mut self, msg: ChatMessage, width: u16) {
self.messages.push(msg);
self.recompute(width);
if self.auto_scroll {
self.scroll_to_bottom();
}
}
pub fn scroll_to_bottom(&mut self) {
let total = self.total_lines();
self.scroll_line = total.saturating_sub(self.viewport_height as usize);
}
}
```
### 4.4 ratatui's Built-in Scroll
`Paragraph` supports `.scroll((y_offset, x_offset))` where `y_offset` is in visual lines. For a single-paragraph chat view (flattening all messages into one `Text`), this works but loses per-message metadata.
`List` widget supports `ListState` with `.scroll()` but operates on item indices, not visual lines — no help with word-wrapped items.
---
## 5. Permission Prompts in TUI
### 5.1 tui-popup: Modal Overlay Widget
**Crate:** `tui-popup` (by joshka, official ratatui org)
**Docs:** https://docs.rs/tui-popup
**Repo:** Part of https://github.com/ratatui/tui-widgets
The most mature popup/overlay widget for ratatui. Supports:
```rust
use tui_popup::Popup;
fn render_permission_popup(frame: &mut Frame) {
let popup = Popup::new("Allow file write to /etc/hosts?\n\n[y] Yes [n] No [a] Always allow")
.title("Permission Required")
.style(Style::new().white().on_red());
frame.render_widget(popup, frame.area());
}
```
**Features:**
- Auto-centers on the terminal area
- Auto-sizes to content
- Movable (via `PopupState` and arrow keys)
- Draggable by mouse (via `PopupState::mouse_down/up/drag()`)
- Supports wrapping arbitrary widgets (via `KnownSizeWrapper`) for scrollable popups
- Border style customization
**Stateful usage for interactive popups:**
```rust
use tui_popup::{Popup, PopupState};
fn render_stateful(frame: &mut Frame, state: &mut PopupState) {
let popup = Popup::new("Allow this action?")
.title("Permission")
.style(Style::new().white().on_blue());
frame.render_stateful_widget(popup, frame.area(), state);
}
```
### 5.2 tui-prompts: Input/Prompt Widgets
**Crate:** `tui-prompts` (by joshka, official ratatui org)
**Docs:** https://docs.rs/tui-prompts
Note: This is for text input prompts (like readline), not permission dialogs. But its `Confirm` prompt type (planned) could be useful.
Currently supports:
- `TextPrompt` — text input with emacs-style keybindings
- Password and invisible render styles
- Multi-line input with soft wrapping
- Prompt completion flow (Enter → complete, Escape → abort)
```rust
use tui_prompts::{Prompt, TextPrompt, TextState};
struct App<'a> {
state: TextState<'a>,
}
// In render:
TextPrompt::from("Allow? (y/n)").draw(frame, area, &mut app.state);
// In event handler:
if app.state.is_completed() {
let answer = app.state.value();
// Process answer
}
```
### 5.3 Custom Overlay Pattern (recommended for claw-code)
For a permission prompt with [y/n/a] options, neither tui-popup nor tui-prompts is a perfect fit. The recommended approach is a custom overlay:
```rust
struct PermissionDialog {
title: String,
message: String,
options: Vec<PermissionOption>,
selected: usize,
// Position for dragging
position: (u16, u16),
}
enum PermissionOption {
Allow,
Deny,
AlwaysAllow,
AllowThisSession,
}
impl Widget for &PermissionDialog {
fn render(self, area: Rect, buf: &mut Buffer) {
// 1. Dim the background
Block::default().style(Style::default().bg(Color::DarkGray)).render(area, buf);
// 2. Calculate popup area (centered, sized to content)
let popup_area = centered_rect(area, 60, 30);
// 3. Draw the popup with border
let block = Block::bordered()
.title(self.title.as_str())
.style(Style::default().fg(Color::Yellow));
let inner = block.inner(popup_area);
block.render(popup_area, buf);
// 4. Render the message and options
let text = Text::from(vec![
Line::from(self.message.as_str()),
Line::from(""),
Line::from(self.options.iter().enumerate().map(|(i, opt)| {
let label = match opt {
PermissionOption::Allow => "[y] Yes",
PermissionOption::Deny => "[n] No",
PermissionOption::AlwaysAllow => "[a] Always",
PermissionOption::AllowThisSession => "[s] Session",
};
if i == self.selected {
Span::styled(label, Style::default().fg(Color::Cyan).bold())
} else {
Span::raw(label)
}
}).collect::<Vec<_>>()),
]);
Paragraph::new(text).render(inner, buf);
}
}
/// Utility: calculate centered rect
fn centered_rect(area: Rect, percent_x: u16, percent_y: u16) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(area);
Layout::default()
.direction(Direction::Horizontal)
.constraints([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(popup_layout[1])[1]
}
```
This is exactly the pattern used in ratatui's own examples (`popup.rs`) and in codex-rs.
### 5.4 Inline Permission Prompt (alternative)
Instead of a modal overlay, some TUIs (like aichat) use an inline prompt at the bottom of the screen:
```
┌─ Chat ──────────────────────────────────────────────┐
│ Assistant: I'll create the file config.toml ... │
│ │
├─ Permission ────────────────────────────────────────┤
│ Allow write to config.toml? [y=Yes, n=No, a=Always] │
└───────────────────────────────────────────────────────┘
```
This is simpler (no overlay logic) and more accessible. Implementation: modify the layout to split off a bottom panel when a permission prompt is active.
---
## 6. Testing TUIs in Rust
### 6.1 How codex-rs Tests Their TUI
codex-rs uses a layered approach (based on public source analysis):
1. **Business logic**: Pure functions / state machines with standard `#[test]` unit tests
2. **Widget rendering**: Test the output of `Widget::render()` to a `Buffer`, then assert on buffer contents
3. **No snapshot testing of TUI output** (no insta for buffer comparison found in their public repo)
### 6.2 Widget Unit Testing Pattern (recommended)
The core pattern for testing ratatui widgets:
```rust
#[test]
fn test_chat_message_render() {
let message = ChatMessage::assistant("Hello, world!");
// Create a buffer to render into
let area = Rect::new(0, 0, 40, 3);
let mut buf = Buffer::empty(area);
// Render the widget
message.render(area, &mut buf);
// Assert on buffer contents
assert_eq!(buf.cell((0, 0)).unwrap().symbol(), "A");
assert!(buf.cell((2, 0)).unwrap().symbol().starts_with("Hello"));
// Or check a whole line
let line_content: String = (0..40)
.map(|x| buf.cell((x, 0)).unwrap().symbol())
.collect();
assert!(line_content.contains("Hello, world!"));
}
```
### 6.3 Insta Snapshot Testing of Rendered Output
**Crate:** `insta`
**Docs:** https://insta.rs
Insta can be used to snapshot-test ratatui `Buffer` contents:
```rust
use insta::assert_snapshot;
#[test]
fn test_permission_dialog() {
let dialog = PermissionDialog::new("Allow write?", vec![
PermissionOption::Allow,
PermissionOption::Deny,
]);
let area = Rect::new(0, 0, 40, 5);
let mut buf = Buffer::empty(area);
dialog.render(area, &mut buf);
// Snapshot the buffer as a string
let rendered = buffer_to_string(&buf, area);
assert_snapshot!(rendered);
}
/// Convert a buffer to a human-readable string representation
fn buffer_to_string(buf: &Buffer, area: Rect) -> String {
let mut out = String::new();
for y in area.top..area.bottom {
for x in area.left..area.right {
out.push_str(buf.cell((x, y)).unwrap().symbol());
}
out.push('\n');
}
out
}
```
**Workflow:**
1. Run `cargo test` — new snapshots are written to `.snap.new` files
2. Run `cargo insta review` — interactively accept/reject snapshots
3. Accepted snapshots are saved as `.snap` files in `snapshots/` directory
### 6.4 Mock Crossterm Events for Testing
No dedicated crate exists for mocking crossterm events. The recommended patterns:
#### Pattern A: Abstract the event source
```rust
trait EventSource {
fn next_event(&mut self) -> Option<AppEvent>;
}
struct CrosstermEventSource;
impl EventSource for CrosstermEventSource {
fn next_event(&mut self) -> Option<AppEvent> {
if crossterm::event::poll(Duration::from_millis(100)).ok()? {
let ev = crossterm::event::read().ok()?;
Some(AppEvent::from(ev))
} else {
None
}
}
}
struct MockEventSource {
events: Vec<AppEvent>,
index: usize,
}
impl EventSource for MockEventSource {
fn next_event(&mut self) -> Option<AppEvent> {
if self.index < self.events.len() {
let ev = self.events[self.index].clone();
self.index += 1;
Some(ev)
} else {
None
}
}
}
```
#### Pattern B: Channel-based event injection
```rust
#[cfg(test)]
fn test_app_with_events(events: Vec<AppEvent>) {
let (tx, rx) = mpsc::channel();
for event in events {
tx.send(event).unwrap();
}
drop(tx); // Close channel to end test
let mut app = App::new();
while let Ok(event) = rx.recv() {
app.handle_event(event);
}
// Assert on final app state
}
```
#### Pattern C: VHS for integration testing
**Tool:** VHS (by charmbracelet)
**Repo:** https://github.com/charmbracelet/vhs
VHS lets you record terminal sessions from a script. Useful for end-to-end visual testing:
```bash
# Record a demo / test
Output tui_demo.gif
Type "hello world"
Enter
Sleep 2s
```
This is more for documentation than CI testing, but can be automated.
### 6.5 Testing Async TUIs
For testing the integration between tokio and ratatui:
```rust
#[tokio::test]
async fn test_streaming_display() {
let (tx, rx) = mpsc::channel(100);
// Simulate streaming tokens
let tx_clone = tx.clone();
tokio::spawn(async move {
for word in ["Hello", ", ", "world", "!"] {
tx_clone.send(AppEvent::LlmToken(word.to_string())).await.unwrap();
}
});
// Run app logic without actual terminal
let mut app = App::new();
while let Ok(event) = rx.recv().await {
app.handle_event(event);
}
assert_eq!(app.chat_buffer, "Hello, world!");
}
```
---
## Summary: Recommended Crate Stack for claw-code TUI
| Problem | Recommended Solution | Crate |
|---------|---------------------|-------|
| Markdown rendering | `tui-markdown` | `tui-markdown = "0.x"` |
| ANSI color passthrough | `ansi-to-tui` | `ansi-to-tui = "4.x"` |
| Scrollable viewport | `tui-scrollview` | `tui-scrollview = "0.x"` |
| Popup/modal dialogs | `tui-popup` + custom overlay | `tui-popup = "0.x"` |
| Text input prompts | `tui-prompts` | `tui-prompts = "0.x"` |
| Child process output capture | `Stdio::piped()` + tokio async read | std + tokio |
| Event handling | `crossterm::event::poll()` | (crossterm, no event-stream feature) |
| Snapshot testing | `insta` | `insta = "1.x"` |
| TUI rendering backend | `CrosstermBackend::new(std::io::stderr())` | ratatui + crossterm |
| Logging inside TUI | `tui-logger` (optional) | `tui-logger = "0.x"` |
| Virtual scroll / chat history | Custom implementation | (no crate — see §4.3) |
### Critical Architecture Decisions
1. **Render to stderr, not stdout.** This solves 90% of the output isolation problem. Use `CrosstermBackend::new(std::io::stderr())`.
2. **Use `Stdio::piped()` for child processes.** Don't use `dup2`/`gag`/fd capture. Read from pipes on tokio tasks.
3. **Poll-based event handling** over `EventStream`. Simpler, more reliable, no extra deps.
4. **Custom virtual scroll** for chat history. No existing crate handles variable-height items well. Build your own with prefix-sum height lookups.
5. **Custom overlay for permission dialogs.** `tui-popup` is good for simple popups, but permission prompts with [y/n/a] options warrant a custom widget that integrates with the app's keybinding system.
6. **Batch streaming tokens on tick.** Don't re-render on every token. Flush pending tokens on tick (4-10 Hz), which is imperceptibly different from per-token rendering but much more efficient.
7. **Insta snapshots** for widget render tests. Combined with `Buffer`-based rendering assertions, this gives comprehensive coverage.