# TUI Architecture Analysis: aichat (sigoden/aichat) > **Date:** 2025-06-12 > **Version analyzed:** 0.30.0 (main branch, commit 82976d3) > **Purpose:** Extract streaming markdown rendering patterns for claw-code's TUI --- ## 1. Executive Summary aichat is a Rust LLM CLI that implements a **line-oriented REPL** (via `reedline`) with **incremental terminal rendering** (via `crossterm` raw mode). It is **not a full TUI** — there is no event loop, no ratatui, no widget tree. Instead, it takes over stdout in raw mode during streaming to perform surgical "erase-and-redraw" of the current rendering buffer. This approach is remarkably effective: it provides flicker-free streaming markdown with syntax highlighting, word-wrap, and CJK support at ~20 FPS while keeping the architecture simple. --- ## 2. Architecture Overview ### 2.1 REPL vs Streaming Boundary ``` ┌─────────────────────────────────────────────────────────┐ │ Repl (reedline-based) │ │ - Read line via reedline (with completer, highlighter) │ │ - Dispatch to run_repl_command() / ask() │ │ - After ask() returns, reedline resumes │ │ │ │ During streaming: │ │ ┌──────────────────────────────────────────────┐ │ │ │ raw mode ON → crossterm direct writes │ │ │ │ MarkdownRender + stream.rs markdown_stream │ │ │ │ raw mode OFF → back to reedline │ │ │ └──────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────┘ ``` **Key insight:** aichat does NOT use a persistent TUI framework. The REPL (reedline) owns the terminal normally. When streaming starts, it enters `crossterm::raw_mode`, takes over stdout, and when done, exits raw mode and returns control to reedline. This is a **modal design**: REPL mode ↔ streaming mode. ### 2.2 Call Graph for Streaming ``` ask() → call_chat_completions_streaming(input, client, abort_signal) → tokio::join!( client.chat_completions_streaming(input, &mut handler), // SSE producer render_stream(rx, config, abort_signal) // Render consumer ) render_stream() → if IS_STDOUT_TERMINAL && config.highlight: MarkdownRender::init(render_options) markdown_stream(rx, &mut render, abort_signal) // ★ Main rendering pipeline → else: raw_stream(rx, abort_signal) // Plain text passthrough ``` The `tokio::join!` means SSE parsing/rendering runs concurrently — the render side processes buffered events on a 50ms tick. --- ## 3. Streaming Markdown Rendering Pipeline (Deep Dive) ### 3.1 The `markdown_stream_inner` Function This is the core ~80-line function in `src/render/stream.rs`. Here's the algorithm: ``` 1. Enable raw mode 2. Read terminal columns 3. Initialize: buffer = "", buffer_rows = 1 4. Show spinner until first event 5. Loop: a. gather_events() — collect all pending events with 50ms timeout b. For each Text event: i. Replace tabs with 4 spaces ii. Get cursor position (with retry, up to 3 attempts) iii. Fix kitty dup-line bug (if col==0 && row>0 && buffer fills columns, row--) iv. Reposition cursor: - If cursor row+1 >= buffer_rows → MoveTo(0, row+1-buffer_rows) - If cursor behind → ScrollUp + MoveTo(0,0) v. Clear from cursor down (terminal::Clear::FromCursorDown) vi. Append text to buffer vii. If text contains newlines: - Split buffer at last newline → (head, tail) - render(head) → print_block (complete lines) - buffer = tail viii. Else: buffer = buffer + text ix. render_line(&buffer) → render current incomplete line x. If rendered output contains newlines (from wrapping): - print_block the head portion - Print the tail portion - Recalculate buffer_rows xi. Else: Print the rendered output, calculate buffer_rows xii. Flush stdout c. For Done event: break d. Poll abort signal (Ctrl+C / Ctrl+D with 25ms timeout) 6. Disable raw mode ``` ### 3.2 Event Batching: `gather_events()` ```rust async fn gather_events(rx: &mut UnboundedReceiver) -> Vec { let mut texts = vec![]; let mut done = false; tokio::select! { _ = async { while let Some(reply_event) = rx.recv().await { match reply_event { SseEvent::Text(v) => texts.push(v), SseEvent::Done => { done = true; break; } } } } => {} _ = tokio::time::sleep(Duration::from_millis(50)) => {} }; // Merge all text chunks into one, preserving order if !texts.is_empty() { events.push(SseEvent::Text(texts.join(""))) } ... } ``` **Design:** Collect all pending text events, join them into a single string, and process in one render pass. The 50ms timeout prevents blocking indefinitely. This means the render loop runs at approximately **20 FPS max** (1000/50), which is smooth enough for terminal output. **Why this matters:** Batching reduces the number of full re-render cycles. Instead of re-rendering for every SSE chunk (which can arrive every few ms), it batches them every 50ms. This is crucial for avoiding visual glitching. ### 3.3 The "Erase and Redraw" Strategy This is the most important pattern. Instead of trying to do incremental diffs on the terminal, aichat: 1. **Tracks how many rows the current buffer occupies** (`buffer_rows`) 2. **Moves cursor back to the start of the buffer** (using the stored row position) 3. **Clears everything below cursor** (`Clear::FromCursorDown`) 4. **Re-renders the full buffer** through MarkdownRender 5. **Prints and tracks** the new row count This is simple but effective because: - Terminal writes are fast (ANSI escape sequences) - The buffer is typically small (a few KB of text) - `Clear::FromCursorDown` is a single escape sequence — very fast ### 3.4 Buffer Management ``` buffer: String // The raw, un-rendered text of the current "paragraph" being streamed buffer_rows: u16 // How many terminal rows the rendered buffer currently occupies After each text event: - Newlines that appear → split into "complete lines" (rendered+printed) and "tail" (new buffer) - The buffer always holds the incomplete last line - render_line() is called on the buffer for the in-progress line ``` **The split pattern:** ```rust if text.contains('\n') { let text = format!("{buffer}{text}"); let (head, tail) = split_line_tail(&text); let output = render.render(head); // render complete lines print_block(writer, &output, columns)?; buffer = tail.to_string(); // keep incomplete line as buffer } else { buffer = format!("{buffer}{text}"); } ``` `split_line_tail` splits at the last newline — everything before is "committed" lines, everything after is the incomplete line. ### 3.5 Cursor Position Recovery ```rust let mut attempts = 0; let (col, mut row) = loop { match cursor::position() { Ok(pos) => break pos, Err(_) if attempts < 3 => attempts += 1, Err(e) => return Err(e.into()), } }; // Fix kitty terminal duplicate line bug if col == 0 && row > 0 && display_width(&buffer) == columns as usize { row -= 1; } ``` **Defensive:** Cursor position queries can fail — retries up to 3 times. The kitty fix handles a specific terminal quirk where the auto-wrap creates an extra row. ### 3.6 Scroll Handling ```rust if row + 1 >= buffer_rows { // Normal case: cursor is at or below buffer start queue!(writer, cursor::MoveTo(0, row + 1 - buffer_rows))?; } else { // Edge case: cursor is above buffer start (scroll happened) let scroll_rows = buffer_rows - row - 1; queue!( writer, terminal::ScrollUp(scroll_rows), cursor::MoveTo(0, 0), )?; } ``` **No user-scroll distinction.** aichat always auto-scrolls. There's no scrollback buffer or user-scroll detection. This is a limitation of the line-oriented approach — once text scrolls off-screen, it's gone from the render buffer (though still in the terminal scrollback). --- ## 4. MarkdownRender — Syntax Highlighting & Wrapping ### 4.1 State Machine: LineType ```rust #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum LineType { Normal, // Outside code block CodeBegin, // ```lang line CodeInner, // Inside code block CodeEnd, // Closing ``` line } ``` The render state machine tracks whether each line is inside a code block or not. This is critical because: - Code blocks get special syntax highlighting - Code blocks have different wrapping rules - The code language (from ` ```lang `) determines which syntect syntax to use ### 4.2 Per-Line Rendering ```rust fn render_line_mut(&mut self, line: &str) -> String { let (line_type, code_syntax, is_code) = self.check_line(line); let output = if is_code { self.highlight_code_line(line, &code_syntax) } else { self.highlight_line(line, &self.md_syntax, false) }; self.prev_line_type = line_type; self.code_syntax = code_syntax; output } ``` **Two render modes:** 1. **`render(&mut self, text)`** — Stateful, tracks LineType across lines. Used for full re-renders. 2. **`render_line(&self, line)`** — Stateless snapshot, used for the in-progress buffer line during streaming. ### 4.3 Syntax Highlighting (syntect) ```rust fn highlight_line(&self, line: &str, syntax: &SyntaxReference, is_code: bool) -> String { // Strip leading whitespace, highlight trimmed part, re-add whitespace let ws: String = line.chars().take_while(|c| c.is_whitespace()).collect(); let trimmed_line: &str = &line[ws.len()..]; if let Some(theme) = &self.options.theme { let mut highlighter = HighlightLines::new(syntax, theme); if let Ok(ranges) = highlighter.highlight_line(trimmed_line, &self.syntax_set) { return format!("{ws}{}", as_terminal_escaped(&ranges, self.options.truecolor)); } } // Fallback: no theme → plain text self.wrap_line(line.into(), is_code) } ``` **Key pattern:** aichat uses syntect's `HighlightLines` which is designed for streaming — you create a highlighter per line and it maintains state across lines (for multi-line strings, comments, etc.). The `as_terminal_escaped` function converts syntect style ranges to ANSI escape codes. ### 4.4 Truecolor vs 256-Color ```rust fn convert_color(c: SyntectColor, truecolor: bool) -> Color { if truecolor { Color::Rgb { r: c.r, g: c.g, b: c.b } } else { let value = (c.r, c.g, c.b).to_ansi256(); let value = match value { 7 | 15 | 231 | 252..=255 => 252, // Lower contrast for readability _ => value, }; Color::AnsiValue(value) } } ``` Checks `COLORTERM=truecolor` env var. If not available, falls back to 256-color with contrast adjustments for readability. ### 4.5 Alpha Blending for Foreground ```rust fn blend_fg_color(fg: SyntectColor, bg: SyntectColor) -> SyntectColor { if fg.a == 0xff { return fg; } let ratio = u32::from(fg.a); let r = (u32::from(fg.r) * ratio + u32::from(bg.r) * (255 - ratio)) / 255; // ... same for g, b SyntectColor { r, g, b, a: 255 } } ``` Handles syntect themes that specify semi-transparent foreground colors by alpha-blending with the theme background. This prevents washed-out colors on dark backgrounds. ### 4.6 Word Wrapping ```rust fn wrap_line(&self, line: String, is_code: bool) -> String { if let Some(width) = self.wrap_width { if is_code && !self.options.wrap_code { return line; // Don't wrap code unless explicitly configured } wrap(&line, width as usize) } else { line } } fn wrap(text: &str, width: usize) -> String { let indent: usize = text.chars().take_while(|c| *c == ' ').count(); let wrap_options = textwrap::Options::new(width) .wrap_algorithm(textwrap::WrapAlgorithm::FirstFit) .initial_indent(&text[0..indent]); textwrap::wrap(&text[indent..], wrap_options).join("\n") } ``` **Uses `textwrap` crate** with `FirstFit` algorithm (fast, greedy). Preserves leading whitespace as initial indent. The `wrap_width` is computed at init time from `terminal::size()` (or config value). **CJK support:** `textwrap` handles CJK characters correctly via `unicode-width` (a dependency of textwrap) — CJK characters are width 2, which is respected in wrapping calculations. ### 4.7 Row Calculation ```rust fn need_rows(text: &str, columns: u16) -> u16 { let buffer_width = display_width(text).max(1) as u16; buffer_width.div_ceil(columns) } ``` Uses `textwrap::core::display_width` for Unicode-aware width calculation, then divides by columns to get row count. This correctly handles CJK (2-width chars), emojis (2-width), and combining characters. --- ## 5. SSE Stream Pipeline ### 5.1 SseHandler (Producer) ```rust pub struct SseHandler { sender: UnboundedSender, abort_signal: AbortSignal, buffer: String, // Full text accumulated tool_calls: Vec, } ``` The handler receives SSE events from the HTTP client and: 1. Appends text to both `self.buffer` (full accumulation) and sends `SseEvent::Text` to the render channel 2. Sends `SseEvent::Done` when the stream completes 3. Accumulates tool calls separately **Dual purpose of `buffer`:** The `SseHandler.buffer` accumulates the **complete** response text (for session storage/tool-call context), while `SseEvent::Text` sends just the **incremental** chunk to the renderer. This separation is important — the render pipeline only needs the delta. ### 5.2 Two Stream Protocols ```rust // SSE (Server-Sent Events) — OpenAI-compatible pub async fn sse_stream(builder: RequestBuilder, mut handle: F) -> Result<()> // JSON stream — AWS Bedrock, etc. pub async fn json_stream(mut stream: S, mut handle: F) -> Result<()> ``` Both funnel through the same `SseHandler` → `UnboundedReceiver` pipeline. ### 5.3 JSON Stream Parser The `JsonStreamParser` is a hand-written incremental JSON parser that: 1. Buffers incoming characters 2. Tracks `{`/`}` and `[`/`]` balance 3. Respects string quoting (with escape handling) 4. Extracts complete JSON objects as they're balanced 5. Handles both NDJSON and JSON array formats 6. Handles partial UTF-8 at chunk boundaries This is notably robust — it doesn't depend on newlines for framing, and handles arbitrary chunk splits from the HTTP layer. --- ## 6. Error Recovery & Abort Handling ### 6.1 AbortSignal ```rust pub struct AbortSignalInner { ctrlc: AtomicBool, ctrld: AtomicBool, } ``` Shared via `Arc`, using `AtomicBool` with `SeqCst` ordering. Both the REPL and the streaming loop check for abort: - **REPL:** Sets `ctrlc`/`ctrld` on keypress via reedline's Signal handling - **Streaming:** Checks via `poll_abort_signal` which uses `crossterm::event::poll` with 25ms timeout ### 6.2 Error During Stream ```rust // In call_chat_completions_streaming: let (send_ret, render_ret) = tokio::join!( client.chat_completions_streaming(input, &mut handler), render_stream(rx, client.global_config(), abort_signal.clone()), ); // After join: if handler.abort().aborted() { bail!("Aborted."); } render_ret?; // Propagate render errors match send_ret { Ok(_) => { if !text.is_empty() && !text.ends_with('\n') { println!(); // Ensure trailing newline on clean completion } Ok((text, eval_tool_calls(client.global_config(), tool_calls)?)) } Err(err) => { if !text.is_empty() { println!(); // Ensure separation before error display } Err(err) } } ``` **Partial output preserved:** If the stream errors, the accumulated `buffer` from `SseHandler.take()` still contains whatever was received. The error is reported, but partial text is not thrown away. The `markdown_stream_inner` also ensures raw mode is disabled on error path via the `disable_raw_mode()` call after `markdown_stream_inner` returns. ### 6.3 Connection Drops The SSE stream handler (`sse_stream`) maps `EventSourceError` variants: - `StreamEnded` → Normal completion, no error - `InvalidStatusCode` → Parse error response body and surface the error message - `InvalidContentType` → Surface the unexpected content type - Other errors → Generic error propagation There's no automatic retry. Connection drops result in partial output + error message. --- ## 7. Spinner ```rust pub struct SpinnerInner { index: usize, message: String, } ``` Runs as a separate tokio task, stepping at 50ms intervals. Uses braille pattern animation (⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏). Hides cursor while active, restores on stop. Communicates via `mpsc::UnboundedSender`. **Key detail:** When the first `SseEvent::Text` arrives during streaming, the spinner is explicitly stopped before rendering begins. This prevents the spinner from interfering with the render output. --- ## 8. Dependency Stack | Crate | Purpose | |-------|---------| | `crossterm` 0.28 | Raw mode, cursor control, terminal clearing, color output | | `syntect` 5.0 | Syntax highlighting (regex-onig backend, parsing + plist-load) | | `textwrap` 0.16 | Unicode-aware word wrapping (CJK-width aware) | | `unicode-width` 0.2 | Character width calculation (CJK=2, emoji=2) | | `ansi_colours` 1.2 | Truecolor → 256-color conversion | | `reedline` 0.40 | REPL line editing (not used during streaming) | | `nu-ansi-term` 0.50 | ANSI color/styling output | | `terminal_colorsaurus` 0.4 | Detect terminal light/dark color scheme | | `reqwest-eventsource` 0.6 | SSE event parsing | --- ## 9. Patterns to Adopt for claw-code's TUI ### 9.1 ✅ ADOPT: The Erase-and-Redraw Pattern aichat's approach of "move cursor to buffer start → clear below → re-render buffer" is the simplest correct solution for terminal streaming. It avoids the complexity of diffing rendered output while being fast enough for typical LLM output rates. **For claw-code:** If using ratatui, this maps to a `LineArea` widget that knows its row count and can be fully re-rendered. If not using ratatui, directly adopt the crossterm queue pattern. ### 9.2 ✅ ADOPT: Event Batching with 50ms Tick The `gather_events` pattern is critical for smooth rendering. Don't render on every SSE chunk — batch for 50ms. This gives: - Reduced render cycles (from hundreds per second to ~20) - Better visual consistency (no partial-word renders) - Lower CPU usage **For claw-code:** Use the same `tokio::select!` + `sleep(50ms)` pattern for event batching. ### 9.3 ✅ ADOPT: Buffer Tracking (buffer + buffer_rows) The `buffer`/`buffer_rows` pattern for tracking the in-progress rendering state is clean and correct. The `buffer_rows` variable is essential for cursor repositioning after the clear. **For claw-code:** Track the current rendering extent (rows) so you know where to start the next clear-and-redraw cycle. ### 9.4 ✅ ADOPT: State Machine for Code Block Detection The `LineType` state machine (Normal → CodeBegin → CodeInner → CodeEnd) is the right way to handle streaming code blocks. You can't do this with a single pass parser because the closing ````` hasn't arrived yet during streaming. **For claw-code:** Must implement a similar state machine. During streaming, you're rendering a partial markdown document — the code block status of the current line depends on whether an opening ````` was seen but no closing one. ### 9.5 ✅ ADOPT: Split-at-Last-Newline Buffer Pattern Rendering complete lines (those ended by `\n`) immediately and keeping only the incomplete last line in the buffer reduces the amount of text that needs to be re-rendered each cycle. **For claw-code:** Same pattern — commit rendered output for complete lines, only re-render the in-progress line. ### 9.6 ✅ ADOPT: Defensive Cursor Position Queries The retry pattern for `cursor::position()` and the kitty terminal workaround are essential for production reliability. **For claw-code:** Always retry cursor position queries (terminals can return errors during resize, etc.). ### 9.7 ✅ ADOPT: Truecolor Fallback Chain `COLORTERM=truecolor` → RGB colors, else → 256-color with contrast adjustments. The contrast adjustment (replacing near-white values with 252) prevents readability issues on dark backgrounds. **For claw-code:** Implement the same color depth detection and contrast adjustment. ### 9.8 ✅ ADOPT: textwrap for Word Wrapping `textwrap` with `FirstFit` + `unicode-width` is battle-tested for CJK and emoji. Don't roll your own wrapping. **For claw-code:** Use `textwrap` directly, with the same indent-preserving pattern. ### 9.9 ⚠️ CONSIDER: Full TUI vs Line-Oriented Approach aichat's line-oriented approach works because it's a **chat interface** — input at the bottom, output scrolling up. For claw-code, which likely needs: - Split pane (conversation + code preview) - Interactive elements (file tree, diff view) - Scroll position management (preserve scroll while streaming) ...a full TUI framework (ratatui) would be more appropriate. However, the **rendering principles** from aichat should be preserved: - Event batching (don't re-render on every chunk) - Buffer tracking (know your rendered extent) - Erase-and-redraw within the streaming area ### 9.10 ⚠️ IMPROVE: Auto-Scroll vs User-Scroll aichat has **NO user-scroll detection**. Once text scrolls off the visible area, it's gone from the render buffer. For claw-code: - Detect user scroll (e.g., via mouse wheel or Page Up) - Pause auto-scroll when user has scrolled up - Resume auto-scroll when user scrolls to bottom - This is only feasible with a TUI framework that manages a scrollable view ### 9.11 ⚠️ IMPROVE: Terminal Resize Handling aichat captures `columns` once at the start of streaming. If the terminal is resized mid-stream, the wrap width will be wrong and rendering will break. For claw-code: - Listen for SIGWINCH / crossterm resize events - Recalculate wrap width on resize - Re-render the full buffer with new width ### 9.12 ⚠️ IMPROVE: Incremental Highlighting aichat re-highlights the entire current line on each render. For very long lines, this could be slow. For claw-code: - Consider caching the syntect highlighter state per line - Only re-highlight from the change point forward (though this is hard with markdown) ### 9.13 ❌ DON'T ADOPT: Raw Mode Takeover aichat's approach of entering raw mode and doing direct cursor manipulation works for its REPL model but would conflict with a TUI framework like ratatui, which needs to own the terminal state. If using ratatui: - Use ratatui's terminal abstraction - Apply the **logical patterns** (erase-and-redraw, buffering, batching) but through the framework's widget lifecycle --- ## 10. Summary: Key Architectural Patterns | Pattern | aichat's Implementation | Applicability to claw-code | |---------|------------------------|---------------------------| | Streaming render | Erase-and-redraw in raw mode | Adopt logic, adapt to ratatui widget | | Event batching | 50ms `gather_events` | ✅ Direct adopt | | Buffer state | `buffer` + `buffer_rows` tracking | ✅ Direct adopt | | Code block state machine | `LineType` enum | ✅ Direct adopt | | Syntax highlighting | syntect `HighlightLines` per line | ✅ Direct adopt | | Word wrapping | textwrap + unicode-width | ✅ Direct adopt | | Color depth | truecolor → 256 fallback | ✅ Direct adopt | | Scroll management | None (auto-only) | ❌ Must improve for claw-code | | Resize handling | None (one-time size) | ❌ Must improve for claw-code | | Error recovery | Partial output preserved | ✅ Adopt the pattern | | Cursor recovery | Retry + kitty workaround | ✅ Direct adopt | | REPL boundary | Modal (reedline ↔ raw mode) | Adapt to TUI event loop | --- ## 11. Code References All source from: `https://github.com/sigoden/aichat` (main branch) | File | Purpose | |------|---------| | `src/render/mod.rs` | Render module entry, `render_stream()` dispatcher | | `src/render/markdown.rs` | `MarkdownRender` — stateful markdown→ANSI renderer | | `src/render/stream.rs` | `markdown_stream` — core streaming render loop ★ | | `src/client/stream.rs` | SSE/JSON stream parsing, `SseHandler`, `SseEvent` | | `src/client/common.rs` | `call_chat_completions_streaming()` — tokio::join of send+render | | `src/repl/mod.rs` | REPL loop, `ask()` function | | `src/config/mod.rs` | `render_options()`, `print_markdown()`, theme loading | | `src/utils/abort_signal.rs` | `AbortSignal` — atomic Ctrl+C/D tracking | | `src/utils/spinner.rs` | Spinner animation during generation wait |