36 KiB
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
- Streaming Text Rendering in Ratatui
- Output Isolation / stdout Capture in Rust
- Async Runtime + Ratatui Patterns
- Scroll State Management
- Permission Prompts in TUI
- 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-cmarkto parse markdown into events - Converts those events into a
ratatui::text::Textvalue - Renders the
Textwidget directly viaframe.render_widget(text, area)
Key API:
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:
// 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
Textand 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.).
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:
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:
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:
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.
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:
std::io::set_output_capture— same mechanism ascargo testuses for capturing test output. Nightly-only internal API.- Pipe-then-dup — creates a pipe, replaces the fd, reads from the pipe.
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.
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:
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)
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.
- 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 anmpscchannel. This is clean, no fd manipulation needed.
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
}
-
For library code that prints to stdout: Wrap it in
std::io::set_output_capture(nightly/unstable) or accept that you needdup2with proper synchronization. The better long-term solution is to refactor such libraries to accept aWritesink. -
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+ withCrosstermBackend::new(std::io::stderr())), which means stdout is free for capture without affecting TUI rendering.
// 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)
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)
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:
EventStreampulls infutures-coreand 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-streamfeature flag
3.3 Main Loop Pattern
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.
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:
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:
Paragraphwith.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:
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:
- On each render, check if the cursor is within the visible viewport
- If cursor is above the viewport → scroll up
- If cursor is below the viewport → scroll down
- The scroll offset is in line units, not character units
- 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:
/// 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:
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
PopupStateand 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:
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)
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:
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):
- Business logic: Pure functions / state machines with standard
#[test]unit tests - Widget rendering: Test the output of
Widget::render()to aBuffer, then assert on buffer contents - 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:
#[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:
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:
- Run
cargo test— new snapshots are written to.snap.newfiles - Run
cargo insta review— interactively accept/reject snapshots - Accepted snapshots are saved as
.snapfiles insnapshots/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
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
#[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:
# 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:
#[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
-
Render to stderr, not stdout. This solves 90% of the output isolation problem. Use
CrosstermBackend::new(std::io::stderr()). -
Use
Stdio::piped()for child processes. Don't usedup2/gag/fd capture. Read from pipes on tokio tasks. -
Poll-based event handling over
EventStream. Simpler, more reliable, no extra deps. -
Custom virtual scroll for chat history. No existing crate handles variable-height items well. Build your own with prefix-sum height lookups.
-
Custom overlay for permission dialogs.
tui-popupis good for simple popups, but permission prompts with [y/n/a] options warrant a custom widget that integrates with the app's keybinding system. -
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.
-
Insta snapshots for widget render tests. Combined with
Buffer-based rendering assertions, this gives comprehensive coverage.