Skip to content

TUI Rendering

A coding agent’s terminal UI has a unique rendering challenge: it must display streaming LLM output (arriving token-by-token), interleaved tool results (shell output, file diffs, diagnostics), and interactive controls (input prompt, permission dialogs, status indicators) — all in a single terminal window that may be 80 columns wide and 24 rows tall.

The hard part is not drawing pixels. Terminals are character grids where each cell holds a Unicode codepoint, a foreground color, a background color, and a set of attributes (bold, italic, underline). The hard part is efficiency: re-rendering the entire screen on every token arrival causes visible flicker. The solution, universally, is differential rendering — compute what changed, emit only the ANSI escape sequences needed to update those cells. But the implementations diverge wildly in how they compute and apply those diffs.

There is also the question of architecture. A full TUI application (Codex, OpenCode) owns the terminal and manages layout, scrolling, and focus. A CLI application (Aider) prints output sequentially and uses a library’s live-update mechanism for streaming sections. Both approaches have tradeoffs: TUIs offer richer interaction but are harder to build and debug; CLIs are simpler but cannot provide persistent UI elements like status bars or scrollable history.

Pin: b9050e1d5faf8096eae7a46a9ecc05a86231384b

Aider is a CLI, not a TUI. It uses rich (v14.3.2) for output rendering and prompt-toolkit (v3.0.52) for interactive input. There is no custom rendering loop or widget system.

The InputOutput class (aider/io.py:230-1185) creates a PromptSession with:

  • Markdown syntax highlighting via PygmentsLexer(MarkdownLexer) on the input buffer
  • VI or Emacs editing mode — configurable, with ModalCursorShapeConfig() for VI mode cursor changes
  • File historyFileHistory backed by .aider.input.history for arrow-key recall
  • Autocompletion — custom AutoCompleter (line 91-228) that tokenizes in-context files with Pygments to extract variable and function names, providing code-aware completions
  • Custom key bindings — Ctrl-Z for background suspend, Ctrl-Space for literal space, Ctrl-X Ctrl-E for external editor, Enter for mode-aware submit (multiline vs. single-line)

LLM responses are rendered as Markdown. Aider extends Rich’s built-in Markdown class in aider/mdstream.py:1-90:

class NoInsetCodeBlock(CodeBlock):
def __rich_console__(self, console, options):
code = str(self.text).rstrip()
syntax = Syntax(code, self.lexer_name, theme=self.theme, word_wrap=True, padding=(1, 0))
yield syntax
class LeftHeading(Heading):
def __rich_console__(self, console, options):
text = self.text
text.justify = "left"
if self.tag == "h1":
yield Panel(text, box=box.HEAVY, style="markdown.h1.border")
else:
yield text
class NoInsetMarkdown(Markdown):
elements = {
**Markdown.elements,
"fence": NoInsetCodeBlock,
"code_block": NoInsetCodeBlock,
"heading_open": LeftHeading,
}

Code blocks use Rich’s Syntax class with the configurable code_theme (default "default"). Headings are left-justified instead of centered. Code blocks have no horizontal padding.

The MarkdownStream class (aider/mdstream.py:92-224) renders streaming LLM output progressively using rich.Live:

Frame rate: 20 FPS maximum (min_delay = 1.0/20 = 50ms). The delay is adaptive — if markdown rendering takes longer than expected, min_delay increases to min(max(render_time * 10, 50ms), 2s).

Stable/unstable split: The rendered output is divided into two regions:

  1. Stable lines — printed to the console’s scrollback buffer above the Live region. These are final and will not be re-rendered.
  2. Unstable lines — the bottom 6 lines (live_window = 6) held in the Live widget. These are re-rendered on every update because the last few lines of streaming markdown may change as more tokens arrive (e.g., a code fence closing, a list item completing).

The first call to update() creates and starts the Live instance. Each subsequent call:

  1. Renders the full accumulated text to markdown lines via NoInsetMarkdown + a scratch Console
  2. Compares against previously printed lines
  3. Prints any new stable lines above the Live region
  4. Updates the Live region with the current unstable window
  5. On final=True, prints everything, clears the Live widget, and stops it

aider/waiting.py:23-196 implements a background spinner for LLM latency:

  • Pre-rendered ASCII frames animate a scanner bouncing left-to-right
  • Unicode mode replaces ASCII with ░█ characters
  • Runs in a daemon thread (WaitingSpinner wraps Spinner with threading.Event for safe shutdown)
  • 500ms delay before showing (avoids flicker on fast responses)
  • Dynamically truncates text to console width
  • Uses \r (carriage return) for in-place updates

Nine configurable colors (user_input_color, tool_output_color, tool_error_color, tool_warning_color, assistant_output_color, and four completion menu colors). All validated against Rich’s ColorParseError at startup (io.py:374-398). Invalid colors are silently disabled rather than crashing.

Pin: 4ab44e2c5cc54ed47e47a6729dfd8aa5a3dc2476

Codex is a full TUI application built on ratatui (v0.29.0, patched fork) with a crossterm backend (v0.28.1, patched fork). The rendering architecture uses double-buffering with cell-level diffing, async event multiplexing, and frame-rate-limited redraw scheduling.

The Tui struct (codex-rs/tui/src/tui.rs:241-287) manages the terminal:

pub struct Tui {
pub terminal: CustomTerminal<CrosstermBackend<Stdout>>,
// ...
}

Initialization enables:

  • Raw mode — disables line buffering and echo
  • Bracketed paste — paste events delimited by escape sequences
  • Keyboard enhancement flagsDISAMBIGUATE_ESCAPE_CODES, REPORT_EVENT_TYPES, REPORT_ALTERNATE_KEYS for accurate key detection
  • Focus change events — terminal focus/blur detection
  • Alternate scroll — custom ANSI sequences for scroll handling

The terminal operates in inline viewport mode by default (output interleaves with terminal scrollback). Alternate-screen transitions are controlled through Tui::enter_alt_screen() / Tui::leave_alt_screen(), and the transcript overlay opened via Ctrl-T uses that path.

The main loop (app.rs:1003) uses tokio::select! to multiplex four event sources:

  1. App events (app_event_rx) — application-level events (LLM responses, tool results)
  2. Active tasks — ongoing async operations (command execution, network requests)
  3. TUI events (tui_events) — terminal input (keys, paste, draw notifications)
  4. Thread creation (thread_created_rx) — new session creation signals

On TuiEvent::Draw:

tui.draw(
self.chat_widget.desired_height(tui.terminal.size()?.width),
|frame| {
self.chat_widget.render(frame.area(), frame.buffer);
if let Some((x, y)) = self.chat_widget.cursor_pos(frame.area()) {
frame.set_cursor_position((x, y));
}
},
)?;

The FrameRateLimiter (tui/frame_rate_limiter.rs) caps rendering at 120 FPS (MIN_FRAME_INTERVAL = 8.333ms) by clamping each requested draw time with FrameRateLimiter::clamp_deadline().

The FrameRequester (tui/frame_requester.rs) implements an actor pattern:

pub struct FrameRequester {
frame_schedule_tx: mpsc::UnboundedSender<Instant>,
}
impl FrameRequester {
pub fn schedule_frame(&self) { /* send now */ }
pub fn schedule_frame_in(&self, dur: Duration) { /* send now + dur */ }
}

A FrameScheduler task coalesces multiple frame requests into a single draw notification on a broadcast channel. In its tokio::select! loop, it absorbs incoming requests, clamps and merges them into the earliest pending deadline, and only emits one draw from the sleep branch when that deadline is reached.

The Terminal struct (custom_terminal.rs:104-124) maintains two buffers:

pub struct Terminal<B: Backend + Write> {
backend: B,
buffers: [Buffer; 2], // Double buffering
current: usize, // Active buffer index
hidden_cursor: bool,
pub viewport_area: Rect,
pub last_known_screen_size: Size,
pub last_known_cursor_pos: Position,
}

On each frame:

  1. Widgets render into the current buffer
  2. flush() computes diff_buffers(previous, current) — a list of DrawCommands
  3. Only the changed cells are written to the terminal backend
  4. Buffers swap (current becomes previous for the next frame)

All drawing is wrapped in crossterm::SynchronizedUpdate (tui.rs:448-519) to prevent partial-frame display:

stdout().sync_update(|_| {
terminal.draw(|frame| { draw_fn(frame); })
})?;

The Renderable trait (render/renderable.rs:13-18) defines the widget protocol:

pub trait Renderable {
fn render(&self, area: Rect, buf: &mut Buffer);
fn desired_height(&self, width: u16) -> u16;
fn cursor_pos(&self, _area: Rect) -> Option<(u16, u16)> { None }
}

Key widgets:

  • ChatWidget (chatwidget.rs, 7,332 lines) — the main chat surface. Composes three vertical sections: transcript history (HistoryCell entries), active in-flight cell (streaming or tool output), and BottomPane (input prompt, popups)
  • HistoryCell (history_cell.rs, 3,704 lines) — individual chat entries with complex rendering for exec output, file diffs, tool calls, and code blocks
  • BottomPane (bottom_pane/mod.rs) — input area with ChatComposer (editable prompt) and a stack of transient modal views (permissions popup, confirmation dialogs)
  • StatusIndicatorWidget (status_indicator_widget.rs) — agent turn status, progress spinners

Layout composition uses ColumnRenderable (vertical stacking) and FlexRenderable (flexible proportional layout), both implementing Renderable.

The StreamController (streaming/controller.rs) manages newline-gated streaming with animation:

pub fn push(&mut self, delta: &str) -> bool {
state.collector.push_delta(delta);
if delta.contains('\n') {
let newly_completed = state.collector.commit_complete_lines();
if !newly_completed.is_empty() {
state.enqueue(newly_completed);
return true; // Schedule frame
}
}
false
}

The MarkdownStreamCollector (markdown_stream.rs:6-62) accumulates deltas, only committing completed lines (ending with \n). Committed lines are rendered with pulldown_cmark and enqueued for display. The animation tick (COMMIT_ANIMATION_TICK ≈ 8.33ms) drains one line per tick for a smooth typing effect.

markdown_render.rs uses pulldown_cmark to parse markdown into ratatui Text<'static> objects:

pub fn render_markdown_text_with_width(input: &str, width: Option<usize>) -> Text<'static> {
let mut options = Options::empty();
options.insert(Options::ENABLE_STRIKETHROUGH);
let parser = Parser::new_ext(input, options);
let mut w = Writer::new(parser, width);
w.run();
w.text
}

Styles: H1 is bold+underlined, H2 is bold, H3 is bold+italic, inline code is cyan, emphasis is italic, strong is bold, strikethrough is crossed-out.

The EventBroker (tui/event_stream.rs:51-115) mediates between crossterm’s event source and the application:

enum EventBrokerState<S: EventSource> {
Paused, // Event stream dropped
Start, // Will create new stream on next poll
Running(S), // Active event source
}

When the agent spawns an external process (e.g., vim, less), the broker transitions to Paused, fully relinquishing stdin. On resume, it transitions to Start and creates a fresh crossterm event stream. This prevents input stealing between the TUI and child processes.

Codex uses forked versions of ratatui and crossterm:

[patch.crates-io]
ratatui = { git = "https://github.com/nornagon/ratatui", branch = "nornagon-v0.29.0-patch" }
crossterm = { git = "https://github.com/nornagon/crossterm", branch = "nornagon/color-query" }

The crossterm fork adds color query support (terminal background color detection). The ratatui fork includes patches for the custom terminal and inline viewport behavior.

Pin: 7ed449974864361bad2c1f1405769fd2c2fcdf42

OpenCode uses a separate rendering engine called OpenTUI (written in Zig) with a Solid.js reconciler for the component model. The architecture is split: Zig handles raw terminal I/O and cell-level rendering; TypeScript/Solid handles component state and layout.

Pin: f4712b9 (references/opentui)

The renderer (opentui/packages/core/src/zig/renderer.zig) implements cell-based differential rendering with optional threading.

Double Buffering: Two OptimizedBuffer instances (currentRenderBuffer and nextRenderBuffer). Each buffer stores the screen as parallel arrays:

buffer.char: []u32 // Unicode codepoints
buffer.fg: []RGBA // Foreground colors (R, G, B, A)
buffer.bg: []RGBA // Background colors
buffer.attributes: []u32 // Bold, italic, underline, hyperlinks

All arrays are width * height for O(1) cell access.

Differential Updates: prepareRenderFrame() (lines 599-749) compares cells between buffers:

const charEqual = currentCell.?.char == nextCell.?.char;
const attrEqual = currentCell.?.attributes == nextCell.?.attributes;
if (charEqual and attrEqual and rgbaEqual(fg) and rgbaEqual(bg)) {
continue; // Skip unchanged cell
}

Changed cells are coalesced into runs with shared attributes to minimize ANSI escape sequences. The renderer tracks currentFg, currentBg, currentAttributes and only emits color/style codes when they change between cells. A color epsilon tolerance (COLOR_EPSILON_DEFAULT = 0.00001) handles floating-point precision in color comparisons.

Output Buffering: Preallocated 2MB output buffers avoid allocation during rendering. ANSI sequences are written directly:

  • Position: \x1b[{y};{x}H (1-indexed)
  • Colors: \x1b[38;2;R;G;Bm (foreground), \x1b[48;2;R;G;Bm (background)
  • Attributes: \x1b[1m (bold), \x1b[3m (italic), \x1b[4m (underline)
  • Hyperlinks: \x1b]8;;URL\x1b\\
  • Synchronized updates: \x1b[?2026h / \x1b[?2026l to prevent mid-frame tearing

Optional Render Thread: When useThread is enabled, the main thread prepares frames and swaps buffers, while a dedicated thread handles stdout writes via condition variable. This prevents I/O blocking the 60 FPS event loop.

Hit Grid: A screen-sized array where each cell stores a renderable ID for mouse event dispatch. Double-buffered alongside the render buffers. A scissor clipping stack handles overflow: hidden regions, preventing mouse events from reaching occluded elements.

The Renderable base class (opentui/packages/core/src/Renderable.ts) provides:

  • Yoga layout engine — Facebook’s cross-platform flexbox implementation handles all positioning. Properties include width, height, margins, padding, flex direction, alignment, and gap.

  • Render pipeline (lines 1360-1382):

    1. renderBefore() callback (custom pre-rendering)
    2. renderSelf() (component draws its content)
    3. renderAfter() callback (custom post-rendering)
    4. markClean() (clear dirty flag)
    5. Add to hit grid
  • Buffered rendering — expensive components can render to an off-screen frameBuffer. The buffer is composited into the parent via drawFrameBuffer(). Only re-renders if the component is marked dirty, avoiding redundant layout and rendering for static content.

opentui/packages/core/src/renderables/ScrollBox.ts implements scrolling with:

  • Viewport cullinggetObjectsInViewport() filters children to the visible range. Only visible children are rendered, making scroll performance independent of list size.
  • Scroll accelerationScrollAcceleration profiles (Linear, macOS) with smooth deceleration based on velocity. Accumulators track momentum for animation.
  • Sticky scroll — automatically scrolls to bottom when new content is added. Disabled if the user manually scrolls up. Re-engages when the user scrolls back to bottom.
  • Auto-scroll during selection — drag-outside-bounds triggers edge scrolling with speed zones (slow, medium, fast based on distance from threshold).

The bridge between Solid.js and OpenTUI is a custom reconciler (opentui/packages/solid/src/reconciler.ts):

  • Solid.js components are mapped to OpenTUI Renderable instances
  • DOM node operations (insertNode, removeNode) map to Renderable parent-child operations
  • Text nodes are handled specially via TextNodeRenderable
  • Reactive updates (Solid signals/effects) trigger render() on affected components

The TUI entry point (packages/opencode/src/cli/cmd/tui/app.tsx) renders the Solid component tree into OpenTUI:

render(
() => <App />,
{
targetFps: 60,
useKittyKeyboard: {},
autoFocus: false,
}
)

The provider hierarchy:

  1. SDKProvider — HTTP/SSE connection to the OpenCode server
  2. ThemeProvider — light/dark mode detection via terminal background color query
  3. SyncProvider — global state store for messages, parts, sessions
  4. DialogProvider — modal dialogs and confirmation prompts
  5. CommandProvider — command palette

The SyncProvider (packages/opencode/src/cli/cmd/tui/context/sync.tsx) handles streaming via Solid.js reactivity:

case "message.part.delta": {
setStore(
"part",
event.properties.messageID,
produce((draft) => {
const part = draft[result.index];
const field = event.properties.field as keyof typeof part;
;(part[field] as string) = (existing ?? "") + event.properties.delta;
})
);
}

Flow:

  1. SDK emits message.part.delta event with field name and delta string
  2. SyncProvider applies delta to Solid store via produce() (immer-like mutation)
  3. Solid’s fine-grained reactivity triggers re-render of only the affected component
  4. Component reads updated text from sync.data.part[messageID]
  5. OpenTUI renders the changed component to its buffer
  6. Zig renderer diffs buffers and outputs only changed cells

Full part replacements use reconcile() (Solid’s deep equality reconciler) to merge objects without losing reactivity tracking.

Memory management: message history is capped at 100 per session (lines 247-263). Oldest messages and their parts are discarded when the limit is exceeded.

Not all terminals support the same escape sequences. Codex requires keyboard enhancement flags (DISAMBIGUATE_ESCAPE_CODES) that are only available in modern terminals (kitty, WezTerm, Windows Terminal). Older terminals (Terminal.app, older xterm) may produce garbled input. OpenTUI uses the kitty keyboard protocol for similar reasons. Aider avoids the problem by using prompt-toolkit, which handles terminal compatibility internally.

Without SynchronizedUpdate (DCS ?2026h), terminals may display partial frames — the top half shows the new state while the bottom half shows the old. Both Codex and OpenTUI wrap their output in synchronized update sequences, but terminals that don’t support this protocol will silently ignore them and may still flicker.

Codex defaults to inline viewport (output interleaves with terminal scrollback). This preserves terminal history — users can scroll up to see previous output after Codex exits. Alternate screen mode (EnterAlternateScreen) provides a clean full-screen view but loses scrollback. Codex uses both: the transcript overlay opened with Ctrl-T enters alternate screen via enter_alt_screen(), then returns via leave_alt_screen(). The tradeoff: inline mode requires careful viewport management (scroll region control, history line insertion), while alternate screen is simpler but less useful for reviewing past interactions.

When the agent runs an interactive command (vim, less, fzf), the TUI must fully relinquish stdin. Codex’s EventBroker transitions to Paused, dropping the crossterm event stream entirely and restoring the terminal to cooked mode. On resume, it re-enables raw mode and creates a fresh event stream. If this handoff is not clean, keystrokes bleed between the TUI and the child process.

Unicode grapheme clusters (emoji sequences, flag combinations, ZWJ sequences) can span multiple cells. OpenTUI handles this with a grapheme pool that tracks width per cluster. Ratatui handles it via the unicode-width crate. Aider relies on Rich’s built-in Unicode handling. Getting this wrong causes column misalignment — characters overflow their cells and corrupt adjacent cells.

Aider’s adaptive throttling (min_delay = max(render_time * 10, 50ms)) is a pragmatic solution: if markdown rendering is slow, reduce update frequency. Codex’s 120 FPS cap is generous — most terminal emulators refresh at 60 Hz, so half the frames are wasted. OpenCode’s 60 FPS target matches typical terminal refresh rates.

Codex patches ratatui and crossterm with custom forks. This gains features (color query, custom terminal behavior) at the cost of maintenance burden. Any upstream security fix or feature must be manually merged into the fork. OpenTUI avoids this by implementing its own terminal abstraction in Zig, but that means reimplementing functionality that exists in maintained libraries.

Follow Codex’s approach — a full ratatui-based TUI application. The benefits (persistent status bar, scrollable history, modal dialogs, permission popups) outweigh the complexity cost for a serious coding agent.

┌─────────────────────────────────────┐
│ Application Layer │
│ (ChatWidget, InputWidget, etc.) │
├─────────────────────────────────────┤
│ Widget Trait │
│ render(&self, area, buf) │
│ desired_height(&self, width) -> u16│
├─────────────────────────────────────┤
│ ratatui Terminal │
│ Double-buffered, diff-based draw │
├─────────────────────────────────────┤
│ crossterm Backend │
│ Raw mode, keyboard, sync updates │
└─────────────────────────────────────┘

Use tokio::select! to multiplex:

  • crossterm terminal events (keys, mouse, resize)
  • LLM streaming events (from the provider layer)
  • Tool execution events (command stdout, file watcher)
  • Frame redraw notifications (from the frame scheduler)

Implement a FrameScheduler as an async task:

  • Accept redraw requests via tokio::sync::mpsc
  • Coalesce multiple requests arriving within the same frame interval
  • Cap at 60 FPS (16.67ms minimum interval) — no need for 120 FPS
  • Emit draw notifications on a tokio::sync::broadcast channel
pub trait Widget {
fn render(&self, area: Rect, buf: &mut Buffer);
fn desired_height(&self, width: u16) -> u16;
fn cursor_position(&self, area: Rect) -> Option<(u16, u16)> { None }
}

Key widgets:

  • ChatWidget — vertical composition of history cells + active cell + input pane
  • HistoryCell — rendered conversation turn (user message, assistant response, tool output)
  • InputPane — editable text input with multiline support
  • StatusBar — model name, token count, sandbox mode, session name
  • PermissionDialog — modal overlay for permission requests
  • StreamingCell — active cell with line-gated animation for streaming output

Follow Codex’s newline-gating strategy:

  1. Accumulate LLM deltas in a buffer
  2. On each \n, commit completed lines
  3. Render committed lines with pulldown_cmark to ratatui Text
  4. Animate: drain one line per frame tick for smooth appearance
  5. Incomplete (trailing) content rendered in a separate “pending” span

Use pulldown_cmark for parsing. Map markdown AST nodes to ratatui Span styles:

  • Headings: bold, with level-based styling
  • Code blocks: syntect or tree-sitter-highlight for syntax coloring
  • Inline code: distinct foreground color
  • Lists: indented with bullet/number prefix
  • Links: underlined with hyperlink escape sequence if terminal supports it

Implement a clean pause/resume protocol:

  1. Save terminal state (raw mode, alternate screen, cursor position)
  2. Restore cooked mode and disable raw features
  3. Spawn child process with inherited stdin/stdout
  4. Wait for child to exit
  5. Re-enable raw mode and restore terminal state
  6. Create fresh crossterm event stream
  • ratatui (0.29+) — terminal UI framework with double-buffered rendering
  • crossterm — cross-platform terminal manipulation
  • tokio — async runtime for event loop
  • pulldown_cmark — CommonMark-compliant markdown parser
  • syntect or tree-sitter-highlight — syntax highlighting for code blocks
  • unicode-width — correct column width calculation for Unicode
  • tui-textarea or custom — multiline text input widget