Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Streaming & TUI Rendering

How agents stream LLM output and render terminal/editor interfaces.

Framework Overview

AgentFrameworkLanguageArchitecture
Grok Buildratatui + crosstermRustElm-style event loop, out-of-process agent via ACP protocol
Codexratatui + crosstermRusttokio event loop, ChatWidget with InterruptManager
Qwen Codeink (React for CLI)TypeScriptAsyncGenerator stream → React state → ink re-render
OpenCode@opentui/solid (SolidJS)TypeScriptSDK events → Solid signals → fine-grained reactive rendering
PiCustom (pi-tui, differential rendering)TypeScriptAgentSessionEvent → component tree → diff render
GoosePlain styled stdout (console + bat + indicatif)Rusttokio stream → markdown buffer → bat/print
ClineVS Code webview (React) + postMessageTypeScriptgRPC-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 append
  • ToolCall / 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/interrupt RPC method (active_turn_interrupt_race)
  • Double-press Ctrl+C/Ctrl+D quit shortcut
  • InterruptManager resolves/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

PhilosophyAgentsTradeoff
Full TUI (alt-screen, widgets, scroll)Grok Build, CodexRich 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)GooseSimplest, 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() in select! (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