Streaming & TUI Rendering
How agents stream LLM output and render terminal/editor interfaces.
Framework Overview
| Agent | Framework | Language | Architecture |
|---|---|---|---|
| Grok Build | ratatui + crossterm | Rust | Elm-style event loop, out-of-process agent via ACP protocol |
| Codex | ratatui + crossterm | Rust | tokio event loop, ChatWidget with InterruptManager |
| Qwen Code | ink (React for CLI) | TypeScript | AsyncGenerator stream → React state → ink re-render |
| OpenCode | @opentui/solid (SolidJS) | TypeScript | SDK events → Solid signals → fine-grained reactive rendering |
| Pi | Custom (pi-tui, differential rendering) | TypeScript | AgentSessionEvent → component tree → diff render |
| Goose | Plain styled stdout (console + bat + indicatif) | Rust | tokio stream → markdown buffer → bat/print |
| Cline | VS Code webview (React) + postMessage | TypeScript | gRPC-style streaming → webview React state |
Grok Build — Ratatui + ACP Protocol
Core files: repos/grok-build/crates/codegen/xai-grok-pager/src/app/event_loop.rs, acp_handler/, scrollback/
Architecture: Out-of-Process Agent
The TUI (“pager”) and the LLM-driving agent are separate processes, communicating via Agent Client Protocol (ACP) — a JSON-RPC-like protocol. This is the most decoupled design of any agent studied.
Agent process (LLM calls, tool execution)
↕ ACP (JSON-RPC)
Pager process (ratatui TUI, user input)
Event Loop
Textbook Elm-style (actions.rs):
- Action — produced by input handling, consumed by dispatch (sync)
- Effect — produced by dispatch, consumed by event loop (async)
- TaskResult — produced by spawned tasks, fed back into dispatch
Main loop: biased tokio::select! with arms for ACP messages, spawned-task JoinSet results, progress channels, and terminal input. Throttles repaints; drains up to ACP_DRAIN_BATCH_MAX messages per iteration to prevent token firehose starving keyboard input.
Streaming Token Rendering
AcpUpdateTracker (acp/tracker.rs) handles:
AgentMessageChunk/AgentThoughtChunk→ text appendToolCall/ToolCallUpdate→ structured block update- Partial JSON args merge for streaming tool-call arguments
Tool Call Display
ToolCallBlock variants: Execute, Edit, Read, Search, WebFetch, etc. Each has custom rendering. ExecuteToolCallBlock::push_output handles incremental stdout. Spinner: braille frames ⠋⠙⠹⠸⠼⠴⠦⠧.
Multi-Pane Layout
AgentViewLayout::compute(): vertical stack of status bar → optional panes → scrollback → side panels → turn status → banner → prompt → shortcuts. ScrollbackState tracks scroll_offset + follow_mode (auto-scroll). Dashboard/tab view manages multiple concurrent sessions.
Markdown Rendering
xai-grok-markdown wraps syntect::easy::HighlightLines + two_face (bat’s 250+-language syntax set). ANSI-16 fallback for basic terminals.
Cancellation
CancelTurn → Action → Effect → ACP CancelNotification to agent process. This is a protocol message, not an in-process token — reflecting the process separation.
Codex — Ratatui + In-Process Agent
Core files: repos/codex/codex-rs/tui/src/lib.rs, chatwidget.rs, chatwidget/interrupts.rs
Architecture
Single-process: the TUI and agent share a tokio runtime. CustomTerminal wraps ratatui with custom resize-reflow logic.
Streaming
tokio event loop feeds ChatWidget which owns an InterruptManager. Deferred UI events queue during active write cycles. Ratatui redraws frames on each event via Terminal::draw.
Cancellation
turn/interruptRPC method (active_turn_interrupt_race)- Double-press Ctrl+C/Ctrl+D quit shortcut
InterruptManagerresolves/queues interrupt prompts mid-stream
Qwen Code — Ink (React for CLI)
Core files: repos/qwen-code/packages/cli/src/ui/App.tsx, hooks/useGeminiStream.ts, startInteractiveUI.tsx
Architecture
Standard ink app: React components rendered to the terminal via ink’s reconciler. AppContainer.tsx provides context; App.tsx is the main component tree.
Streaming
useGeminiStream.ts consumes an AsyncGenerator:
for await (const event of stream) { ... }
Dispatches ServerGeminiStreamEvents into React state. Buffered event flushing (flushBufferedStreamEventsRef) smooths partial-token updates.
Cancellation
AbortController per turn (abortControllerRef), triggered on Escape key or new-turn start. Abort signal propagates into the stream loop.
OpenCode — OpenTUI (SolidJS Terminal Renderer)
Core files: repos/opencode/packages/tui/src/app.tsx, packages/tui/package.json
Architecture
Uses @opentui/solid — a SolidJS binding for @opentui/core, a custom terminal renderer. Not ink, not bubbletea, not blessed.
import { createCliRenderer } from "@opentui/core"
import { render } from "@opentui/solid"
Streaming
SolidJS reactive signals/effects (createSignal, createEffect) driven by SDK events via @opencode-ai/sdk. OpenTUI does fine-grained reactive re-rendering — only the DOM nodes whose signals changed are redrawn (more efficient than ink’s full React reconciliation).
Cancellation
Solid onCleanup/context-based abort providers (ExitProvider/context/exit.tsx).
Pi — Custom Differential Rendering
Core files: repos/pi/packages/tui/, packages/coding-agent/src/modes/interactive/interactive-mode.ts
Architecture
@earendil-works/pi-tui — custom library with differential rendering. No ink/React/Solid dependency. Minimal deps: marked, get-east-asian-width, dev-only @xterm/headless, chalk.
Component Model
Imperative component tree (not React). interactive-mode.ts imports TUI, Container, Text, Markdown, ProcessTerminal directly from pi-tui.
Streaming
AgentSession/AgentSessionEvent emits events consumed in interactive-mode.ts, updating Text/Markdown components. Pi-tui diff-renders to terminal — avoids full redraws during streaming. Test edit-tool-no-full-redraw.test.ts explicitly verifies partial redraw behavior.
Cancellation
Component-level key bindings (matchesKey, setKeybindings) route interrupt keys into the session.
Goose — Styled stdout (No TUI)
Core files: repos/goose/crates/goose-cli/src/session/output.rs, session/streaming_buffer.rs
Architecture
Not a full TUI. Plain styled stdout using: console (styling), bat (markdown/syntax), cliclack + indicatif (spinners/progress), rustyline (line editor), comfy-table (tables).
A tui subcommand exists but shells out to a separate Node.js package (@aaif/goose via goose-tui).
Streaming Buffer
MarkdownBuffer::push — hand-written parser (ParseState) tracking open markdown constructs (code fences, bold/italic, links, tables):
- Plain prose streams almost immediately
- Content inside unclosed constructs buffers until closed, then renders through bat
- Large code blocks truncate at
GOOSE_MAX_CODE_BLOCK_LINES(default 50), spilling full content to temp file
Tool Call Rendering
render_tool_request dispatches per tool name (shell, text-editor, execute-code, delegate, todo) with ▸ marker. Results: dimmed/indented, truncated to 20 lines unless GOOSE_SHOW_FULL_OUTPUT.
Spinners
ThinkingIndicator wraps cliclack::spinner(). McpSpinners wraps indicatif::MultiProgress for MCP tool progress.
Cancellation
rustyline::Editor with custom CtrlCHandler — Ctrl+C clears line if non-empty, arms “press again to exit” if empty. Mid-stream: spawned task awaits ctrl_c() and cancels token raced in tokio::select!.
Desktop App
Electron + React 19 at ui/desktop/. Spawns Rust binary as HTTP/ACP server sidecar, renderer connects over WebSocket.
Cline — VS Code Webview + gRPC-Style Protocol
Core files: repos/cline/apps/vscode/src/core/controller/grpc-handler.ts, ui/subscribeToPartialMessage.ts
Architecture
Extension host (Node.js) + React webview communicating via vscode.postMessage/window.postMessage. A gRPC-style streaming protocol over postMessage.
Streaming Protocol
grpc-handler.ts defines StreamingResponseHandler with is_streaming flag. Extension host pushes ClineMessage partials through responseStream(...) → postMessageToWebview → webview’s message listener re-renders React state per chunk.
subscribeToPartialMessage.ts: webview subscribes via gRPC-style stream, updating on each partial.
Cancellation
handleGrpcRequestCancel sends cancel message keyed by requestId through the same postMessage channel, deregistering the streaming subscription.
Design Patterns
1. Three Rendering Philosophies
| Philosophy | Agents | Tradeoff |
|---|---|---|
| Full TUI (alt-screen, widgets, scroll) | Grok Build, Codex | Rich UX, complex implementation |
| Reactive CLI (component tree, diff render) | Qwen Code (ink), OpenCode (opentui), Pi (custom) | Moderate complexity, good streaming UX |
| Styled stdout (print + spinners) | Goose | Simplest, least interactive |
2. Process Architecture Matters
- Out-of-process (Grok Build): TUI and agent are separate processes via ACP. Most robust — TUI crash doesn’t lose agent state, and vice versa.
- In-process (Codex, Qwen Code, OpenCode, Pi): Simpler but coupled — a rendering panic can take down the agent.
- Extension host (Cline): VS Code provides the process boundary for free.
3. Streaming Buffer Strategies
- Immediate flush (Codex, Pi): every token redraws immediately
- Construct-aware buffering (Goose): holds content until markdown constructs close (avoids broken rendering mid-code-fence)
- Batched flush (Qwen Code, Grok Build): drain multiple tokens per render cycle to avoid starving input handling
4. Cancellation Requires Protocol
Every agent has a different mechanism, but the pattern is universal: the user’s keypress must propagate through whatever boundary separates the input handler from the LLM call:
- Same process:
AbortController/CancellationToken(Qwen Code, Pi) - Same process, tokio: race
ctrl_c()inselect!(Goose, Codex) - Cross-process: ACP
CancelNotification(Grok Build) - Cross-context: postMessage cancel by requestId (Cline)
5. Markdown in Terminal is Surprisingly Hard
Three approaches:
- bat/syntect (Grok Build, Goose): full syntax highlighting using bat’s language packs
- Custom parser (Goose’s
MarkdownBuffer): hand-rolled to enable streaming (bat can’t render partial constructs) - marked + chalk (Pi): lightweight markdown-to-ANSI conversion