Skip to content

Transformer Math

Module 53 · AI Engineering

🖥️ Terminal UI (Ink)

It's React — but instead of DOM nodes, it writes ANSI escape codes to stdout

Status:

Claude Code's terminal UI is built with Ink — a React renderer for the terminal. Same useState, same useEffect, same component model — but instead of DOM nodes, the output is ANSI escape codes written to stdout.

  • Custom reconciler translates React component tree into virtual terminal nodes
  • Yoga layout engine provides CSS flexbox for character grids (Box = div, Text = span)
  • Rendering pipeline: Components → Reconciler → Yoga Layout → Virtual DOM → ANSI → stdout
🎮

Terminal Rendering Pipeline

What you are seeing

The complete path from a React component to colored characters on your terminal. Each stage transforms the representation: JSX becomes virtual nodes, virtual nodes get laid out by Yoga, layout positions become ANSI escape sequences.

What to try

Trace how a simple <Box flexDirection="row"> becomes terminal output. Notice the parallel to browser rendering: React tree → layout → paint → pixels, except here it is React tree → Yoga → ANSI → characters.

# Browser React vs Terminal React

Browser: JSX → React DOM → CSS Layout → Pixels on screen

Terminal: JSX → Ink Reconciler → Yoga Layout → ANSI on stdout

# Component mapping

<div> → <Box> (container, flexbox)

<span> → <Text> (text content)

CSS pixels → char cells (80×24 grid)

onClick → onKeypress (keyboard events)

cursor:ptr → useFocus() (tab navigation)

# Rendering output

\x1b[1;1H\x1b[36m┌──────────┐\x1b[0m

\x1b[2;1H\x1b[36m│ Hello │\x1b[0m

\x1b[3;1H\x1b[36m└──────────┘\x1b[0m

💡

The Intuition

Browser React vs Terminal React (Ink)

Ink maps every browser React concept to a terminal equivalent. The mental model is identical — only the output target changes.

Browser ReactTerminal React (Ink)
ReactDOM.createRoot()createRoot() in root.ts
DOM nodes (div, span)Virtual nodes (Box, Text)
CSS for layoutYoga (flexbox engine)
Browser renders pixelsANSI escape codes to stdout
requestAnimationFrameframe.ts schedules redraws
Click / mouse eventsparse-keypress.ts for keyboard

Why React in a Terminal?

Terminal UIs have the same problems as web UIs: state management, conditional rendering, component composition, event handling. Before Ink, terminal apps used imperative libraries like blessed/ncurses — manually tracking cursor positions, redrawing regions, managing focus. Ink brings React's declarative model: describe what the UI should look like given current state, and the renderer figures out the minimal ANSI updates.

The Custom Reconciler

React's core (reconciliation, fiber scheduling, hooks) is target-agnostic. The react-reconciler package lets you plug in a host config that maps React operations to any target. Ink implements ~25 methods: createInstance() creates virtual terminal nodes, commitUpdate() applies prop changes, and the tree is walked to produce ANSI output.

💡 Tip · The same pattern powers react-native (mobile), react-three-fiber (3D/WebGL), and react-pdf (PDF generation). If you understand Ink's reconciler, you understand how to build a React renderer for any target.

Yoga Layout Engine

Yoga is Facebook's cross-platform flexbox implementation in C++. In the browser, CSS layout operates on pixel dimensions. In the terminal, the unit is a character cell — a fixed-width grid (e.g., 80 columns × 24 rows). Ink's <Box> maps to a Yoga node with flexbox properties: flexDirection, justifyContent, padding — all in character units.

Frame Scheduling

Browsers use requestAnimationFrame to batch DOM updates at 60fps. Ink uses a similar mechanism: state changes are batched, Yoga recalculates layout, and the full terminal output is re-rendered in one write to stdout. This prevents flickering — partial updates would show half-drawn frames.

✨ Insight · Terminal rendering is actually simpler than browser rendering: monospace fonts mean every character is the same width, so text measurement is trivial (string length = display width, ignoring wide/emoji characters). No font loading, no sub-pixel rendering, no reflow complexity.

TTY Raw Mode — The Hidden Prerequisite

Before Ink can receive individual keystrokes, it must switch the terminal from cooked mode to raw mode via process.stdin.setRawMode(true). In cooked mode (the default), the OS buffers input until the user presses Enter — only complete lines reach the process. In raw mode, every keypress is delivered immediately as a byte sequence. This is what makes interactive TUIs possible: Ink's parse-keypress.ts translates raw byte sequences into named keys ({name: 'tab', shift: false}, {name: 'return'}) that components receive via hooks like useInput(). The tradeoff: raw mode disables OS-level editing (Ctrl+C no longer sends SIGINT automatically), so Ink must handle these signals itself — it re-raises SIGINT on Ctrl+C so the process can still be interrupted.

Alternate Screen Buffer

Rich TUIs like Claude Code's interactive prompts use the terminal's alternate screen buffer — a separate display layer activated with the ANSI escape sequence \x1b[?1049h and deactivated with \x1b[?1049l. Switching to the alternate buffer saves the current terminal contents, gives a clean canvas for the TUI, and restores everything on exit — which is why running vim or lessdoesn't clobber your shell history. Ink uses this pattern for full-screen components but falls back to inline rendering (no alternate buffer) for streaming output like code generation, which must scroll with the terminal.

Quick Check

Why use React to build a terminal UI instead of just printing strings?

📐

Key Code Patterns

Ink Reconciler

typescript
// Translates React tree to terminal output
class InkReconciler {
  createInstance(type: string, props: Record<string, unknown>): VirtualNode {
    if (type === "Box")  return new VirtualBox(props);   // like <div>
    if (type === "Text") return new VirtualText(props);  // like <span>
    throw new Error(`Unknown type: ${type}`);
  }

  commitUpdate(node: VirtualNode, newProps: Record<string, unknown>): void {
    node.update(newProps);
    scheduleRerender();
  }

  appendChild(parent: VirtualNode, child: VirtualNode): void {
    parent.children.push(child);
  }

  removeChild(parent: VirtualNode, child: VirtualNode): void {
    parent.children = parent.children.filter(c => c !== child);
  }
}

Terminal Rendering Pipeline

typescript
function renderToTerminal(rootNode: VirtualNode): void {
  // 1. Yoga calculates layout (flexbox in char cells)
  const yogaTree = calculateLayout(rootNode, { cols: 80, rows: 24 });

  // 2. Walk tree, generate ANSI escape codes
  let output = "";
  for (const node of walk(yogaTree)) {
    const { left: x, top: y } = node.layout;
    output += `\x1b[${y + 1};${x + 1}H`;          // move cursor
    output += colorize(node.content, node.style);   // apply colors
  }

  // 3. Atomic write to stdout (prevents flickering)
  process.stdout.write(output);
}

// Frame scheduling — batch state changes
const pendingRenders: boolean[] = [];
let rootNode: VirtualNode | null = null;  // set once at startup

function scheduleRerender(): void {
  if (pendingRenders.length === 0) {
    setImmediate(flushRenders);  // next tick
  }
  pendingRenders.push(true);
}

function flushRenders(): void {
  pendingRenders.length = 0;
  if (rootNode) renderToTerminal(rootNode);  // rootNode must be defined
}

Focus Management

typescript
class FocusManager {
  // Tab-based focus navigation for terminal components
  private focusable: VirtualNode[] = [];  // ordered list of focusable nodes
  private activeIndex: number = 0;

  register(node: VirtualNode): void {
    this.focusable.push(node);
  }

  handleKey(key: string): void {
    if (key === "tab") {
      this.activeIndex = (this.activeIndex + 1) % this.focusable.length;
    } else if (key === "shift+tab") {
      this.activeIndex = (this.activeIndex - 1 + this.focusable.length) % this.focusable.length;
    }
    this.focusable[this.activeIndex].focus();
    scheduleRerender();
  }
}

// In React: useFocus() hook returns { isFocused: boolean }
// Equivalent to document.activeElement in the browser
🔧

Break It — See What Happens

No Yoga layout engine
No React reconciler (imperative updates)
📊

Real-World Numbers

AspectBrowser ReactTerminal React (Ink)
Output targetDOM nodesANSI escape codes
Layout engineCSS (browser)Yoga (flexbox)
UnitsPixelsCharacter cells
EventsMouse clicksKeypresses
Frame rate60fps (rAF)On-demand (state change)
Text measurementComplex (variable-width fonts)Simple (monospace = strlen)
✨ Insight · Ink powers CLIs used by millions: Gatsby, Prisma, Parcel, and Claude Code. The React mental model transfers directly — if you can build a web app, you can build a terminal app.
🧠

Key Takeaways

What to remember for interviews

  1. 1Ink is a custom React renderer for terminals — the same useState, useEffect, and component model as browser React, but output is ANSI escape codes written to stdout instead of DOM nodes.
  2. 2The react-reconciler host config (~25 methods) is the only target-specific layer; React's diffing, fiber scheduling, and hooks are completely target-agnostic.
  3. 3Yoga (Facebook's C++ flexbox engine) handles layout in character-cell units instead of pixels — Box maps to div, Text maps to span, and monospace fonts make text measurement trivial (strlen = display width).
  4. 4State changes are batched and flushed in a single atomic stdout.write() to prevent flickering — the same role requestAnimationFrame plays in browsers.
  5. 5TTY raw mode (setRawMode(true)) delivers individual keystrokes immediately instead of waiting for Enter; Ink must re-raise signals like Ctrl+C itself since the OS no longer handles them.
📚

Further Reading

Recall

What is the correct order of Ink&apos;s rendering pipeline from React component to terminal output?

What is the correct order of Ink&apos;s rendering pipeline from React component to terminal output?
Recall

How do Yoga layout units differ from browser CSS units in a terminal context?

How do Yoga layout units differ from browser CSS units in a terminal context?
Recall

What is the minimum set of methods a custom React reconciler host config must implement to support `createInstance` and `commitUpdate`?

What is the minimum set of methods a custom React reconciler host config must implement to support `createInstance` and `commitUpdate`?
Trade-off

For a CLI tool with a real-time multi-panel dashboard (live log tail, metrics chart, input form), which TUI approach has the better tradeoff?

For a CLI tool with a real-time multi-panel dashboard (live log tail, metrics chart, input form), which TUI approach has the better tradeoff?
🎯

Interview Questions

Difficulty:
Company:

Showing 4 of 4

How would you build a custom React renderer for a non-browser target?

★★★
MetaGoogle

Compare the tradeoffs of a full React TUI vs a simpler curses-based approach.

★★☆
Anthropic

How does flexbox layout work in a terminal context?

★★☆
Meta

How would you implement progressive rendering of tool results that arrive out of order?

★★★
Google