# 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, /// Cumulative height sums for binary search height_offsets: Vec, /// 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 { 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, /// 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` 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>> = 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: 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) -> Result { 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) -> 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) -> 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>, mut app: App) -> Result<()> { let (tx, mut rx) = mpsc::channel::(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>, // 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, /// Pre-computed visual line heights for each message at the current width /// (message_index → number of visual lines) visual_heights: Vec, /// Prefix sums of visual_heights for O(log n) item→line and line→item lookups line_offsets: Vec, /// 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 { 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, 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::>()), ]); 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; } struct CrosstermEventSource; impl EventSource for CrosstermEventSource { fn next_event(&mut self) -> Option { 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, index: usize, } impl EventSource for MockEventSource { fn next_event(&mut self) -> Option { 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) { 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.