# React in the Terminal: Deep Deconstruction of the Ink Fork

This chapter deconstructs how Claude Code runs a complete React application inside a plain-text terminal—by deeply forking the Ink framework across 96 files, translating the virtual DOM into ANSI escape sequences to achieve streaming rendering, permission dialogs, and complex layouts.

> **🌍 Industry Context**: Terminal UI frameworks occupy a small but active niche. In the Node.js ecosystem, there's Ink (a React-for-CLI framework created by Vadim Demedes) and the older Blessed/blessed-contrib (feature-rich but unmaintained); the Go ecosystem has Bubbletea (from the Charm team, with a declarative architecture) and the Charm series of CLI beautification tools; the Python ecosystem has Textual (a full-featured TUI framework built on the Rich library). However, the vast majority of AI coding tools don't build custom terminal UIs at all—Cursor and Windsurf use VS Code's Webview/Extension API, which is essentially browser rendering; Aider uses plain-text output with no TUI framework whatsoever; CodeX (OpenAI) has been rewritten in Rust at the core (95.6%), using its own terminal interface; **OpenCode** uses an advanced TUI built with Go+Zig, switching between Build/Plan modes via the Tab key. Claude Code's choice to fork Ink and build a 19,842-line custom framework is a clear outlier in the industry—the driving force behind this decision is the need to achieve React-level component composition capabilities (state management, event bubbling, virtual scrolling) inside the terminal. This is analogous to why a game engine builds its own renderer instead of using the browser DOM—when the rendering target and interaction model are fundamentally different from the Web, the cost of reusing an existing framework actually exceeds that of building one from scratch.

---

## Prologue: Running Photoshop in Notepad

Imagine someone running full Photoshop inside a Windows Notepad window—with layers panel, toolbar, color picker, and preview window. You think it's a text interface, but it's actually a complete graphics application.

That's what Claude Code does. A terminal can essentially only display text characters and ANSI color codes—no buttons, no dialogs, no dropdown menus. But Claude Code runs a complete **React application** in the terminal, with a component tree, state management, event handling, and lifecycle hooks.

How is this possible? The answer is a **complete fork of the Ink framework**. Not a simple dependency reference, but a deep rewrite of 96 files and 19,842 lines of code.

> **City Analogy:** If Claude Code is a city, Ink is its **display and billboard system**. Everything that happens in the city—traffic volume changes (streaming AI output), traffic light switches (permission dialogs), road sign updates (state changes)—must ultimately be translated by this display system into information visible to the human eye. And this system itself makes no business decisions; it has only one job: **translating the React virtual DOM into ANSI escape sequences that the terminal can display**.
>
> 💡 **Plain English**: Terminal UI is like a **movie subtitle system**—the actor's lines = AI output content, the subtitle team = Ink renderer (responsible for turning content into visible pictures on screen), the screen = your terminal window, and real-time translation = streaming rendering (the AI says one word, the screen displays one word, rather than waiting for the whole thing to finish before appearing all at once).

---

## 1. Why Fork Ink?

### 1.1 The Problem: What Couldn't Community Ink Provide?

Community Ink (created by Vadim Demedes) is a lightweight framework designed to let developers write simple CLI tools with React—progress bars, tables, interactive menus. But Claude Code's UI complexity far exceeds that of a "simple CLI":

| Requirement | Community Ink | What Claude Code Needs |
|-------------|---------------|------------------------|
| Layout Engine | Yoga WASM (requires loading .wasm files) | Pure TypeScript Yoga (zero WASM overhead) |
| Scrolling | Not supported | Virtual scrolling + DECSTBM hardware acceleration |
| Mouse Events | Not supported | Full click, drag, and hover support |
| Text Selection | Not supported | Multi-line selection, double-click word select, drag selection |
| Search Highlighting | Not supported | Full-screen search, navigate match-by-match |
| Focus Management | Rudimentary Tab switching | Complete focus stack, auto-focus, click-to-focus |
| Bidirectional Text | Not supported | Unicode Bidi algorithm |
| Event System | None | Full capture/bubble DOM event model |
| Performance Optimization | Full-screen redraw | Blit dirty-region updates, Screen double-buffering |
| React Version | React 18 | React 19 (Concurrent Root) |

This isn't a gap that can be closed with "a few patches." The Anthropic team made a pragmatic engineering decision: **complete fork, deep rewrite**.

### 1.2 By the Numbers: Scale of the Fork

| Metric | Value | Meaning |
|--------|-------|---------|
| Total files in `ink/` directory | 96 | Nearly the scale of an independent framework |
| Total lines of code | 19,842 | More than the core code of many open-source UI frameworks |
| Lines in single file `ink.tsx` | 1,722 | The "brain" of the framework—all coordination logic concentrated here |
| `screen.ts` | 1,486 | Screen buffer implementation—the Canvas of the terminal interface |
| `render-node-to-output.ts` | 1,462 | Core rendering pipeline—DOM tree to screen buffer |
| `selection.ts` | 917 | Text selection system—more complex than the selection logic in most editors |
| `output.ts` | 797 | Operation queue—collects rendering instructions and applies them to Screen |
| `log-update.ts` | 773 | Diff computation—Screen buffer to terminal ANSI sequences |
| `styles.ts` | 771 | CSS Flexbox property mapping—40+ style properties |
| `reconciler.ts` | 512 | React Reconciler adapter—connecting React 19 Fiber |
| `layout/` subdirectory (4 files) | ~560 | Pure TS Yoga layout engine adapter layer |
| `components/` subdirectory (18 files) | ~1,200 | Basic UI component library |
| `events/` subdirectory (10 files) | ~500 | DOM-style event system |
| `hooks/` subdirectory (12 files) | ~400 | React Hooks collection |
| `termio/` subdirectory (9 files) | ~800 | Terminal I/O primitives (CSI, OSC, DEC sequences) |

**What do 19,842 lines of code mean?** For reference, community Ink's core code is roughly 3,000–4,000 lines. Claude Code's fork is **more than 5× the size** of the original. This isn't a simple fork—the incremental portion (about 16,000 lines) is equivalent to rebuilding a framework from scratch. But it's worth noting that this "fork + deep customization" pattern isn't uncommon in engineering practice—Linux kernel vendor forks (like the Android kernel), Chrome's Blink engine (forked from WebKit) are all similar cases. Claude Code's unique aspect is that it invests UI engineering effort typically reserved for GUI applications into a **CLI tool**.

### 1.3 Full Technical Stack

```
React 19 (declarative UI logic + Concurrent Root)
  ↓ createReconciler() — 512-line custom Reconciler
ink-root / ink-box / ink-text (custom Host elements)
  ↓ dom.ts — 484-line virtual DOM operations
Yoga Layout Engine (pure TypeScript port, non-WASM)
  ↓ layout/ — CSS Flexbox computation
render-node-to-output.ts (DOM tree → screen operation queue)
  ↓ 1,462 lines of recursive traversal + blit optimization
output.ts → screen.ts (screen buffer writes)
  ↓ double-buffering, char pool, style pool
log-update.ts (screen buffer diff → ANSI patch sequences)
  ↓ optimizer.ts — 7 optimization rules
Terminal stdout (final ANSI escape sequence output)
```

---

## 2. Pure TypeScript Yoga Layout Engine

### 2.1 Why Not WASM Yoga?

**Q: Community Ink uses yoga-wasm-web or the WASM build of yoga-layout. Why did Claude Code switch to pure TypeScript?**

The answer is in the comment in `layout/yoga.ts`:

> "The TS yoga-layout port is synchronous — no WASM loading, no linear memory growth, so no preload/swap/reset machinery is needed."

Three pain points of WASM Yoga:

1. **Startup latency**: WASM modules need asynchronous loading and compilation. For a CLI tool, adding tens of milliseconds to every startup means degraded user experience.
2. **Memory management**: WASM's linear memory can grow but never shrink. Long-running Claude Code sessions would see WASM memory continuously climb.
3. **Concurrency safety**: WASM accesses nodes through pointers. If React's Concurrent Mode recycles a node during layout computation, subsequent access hits freed WASM memory—a classic use-after-free vulnerability.

The pure TypeScript implementation (imported via `src/native-ts/yoga-layout/`) turns layout computation into ordinary JavaScript object operations—**synchronous, predictable, and GC-friendly**.

> 📚 **Course Connection**: The Yoga layout engine implements a subset of the CSS Flexbox specification—basic content in frontend development courses. But in a terminal environment, the "rendering target" changes from browser pixels to ANSI character cells, with completely different constraints: each character occupies fixed width (CJK characters occupy two), no sub-pixel rendering, no float layouts. Understanding how Flexbox works on a character grid is the foundation for understanding this UI system.

### 2.2 Layout Adapter Architecture

The `layout/` subdirectory contains only 4 files, but it's a carefully designed abstraction layer:

```
layout/
├── node.ts      — LayoutNode interface definition (152 lines, 50+ method signatures)
├── yoga.ts      — YogaLayoutNode adapter (308 lines, mapping to concrete Yoga implementation)
├── engine.ts    — Factory function (7 lines, creation entrypoint)
└── geometry.ts  — Geometry primitives (98 lines, Point/Size/Rectangle/Edges)
```

**`node.ts`** defines the `LayoutNode` interface—this is a classic application of the **Bridge Pattern**. The interface declares 50+ methods: tree operations (`insertChild`, `removeChild`), layout computation (`calculateLayout`, `markDirty`), measurement (`setMeasureFunc`), layout result reading (`getComputedLeft/Top/Width/Height`), and a complete set of CSS Flexbox style setters (`setFlexDirection`, `setAlignItems`, `setJustifyContent`, etc.).

The interface includes 8 alignment mappings:

| LayoutNode Enum | CSS Equivalent | Use Case |
|-----------------|----------------|----------|
| `LayoutFlexDirection.Column` | `flex-direction: column` | Default vertical arrangement (terminal content flows top-to-bottom) |
| `LayoutFlexDirection.Row` | `flex-direction: row` | Horizontal arrangement (e.g., button rows) |
| `LayoutAlign.Center` | `align-items: center` | Center alignment |
| `LayoutAlign.FlexStart` | `align-items: flex-start` | Top/left alignment |
| `LayoutJustify.SpaceBetween` | `justify-content: space-between` | Even distribution |
| `LayoutPositionType.Absolute` | `position: absolute` | Absolute positioning (overlay layers) |
| `LayoutOverflow.Scroll` | `overflow: scroll` | Scrollable regions |
| `LayoutDisplay.None` | `display: none` | Hidden nodes |

**`yoga.ts`** is the concrete adapter—the `YogaLayoutNode` class wraps the pure TS Yoga instance, providing Edge/Gutter mapping and MeasureMode conversion. The core factory function is just one line:

```typescript
export function createYogaLayoutNode(): LayoutNode {
  return new YogaLayoutNode(Yoga.Node.create())
}
```

Why add this abstraction instead of using Yoga directly? Because it gives the team **freedom to swap the underlying engine**. If the pure TS Yoga's performance ever becomes insufficient, they can switch back to the WASM version—just write a new `LayoutNode` implementation, and the upper-layer code (dom.ts, reconciler.ts, render-node-to-output.ts) won't need a single line changed.

### 2.3 Text Measurement: The Hardest Part of Terminal Layout

The layout engine needs to know the dimensions of each node. For `ink-box`, dimensions are determined by CSS Flexbox rules. But for `ink-text`, dimensions depend on **the width and height of the text content itself**—and text width calculation in the terminal is far more complex than in the browser.

The `measureTextNode` function in `dom.ts` (lines 332-374) implements the text measurement logic:

1. **Extract text**: `squashTextNodes` flattens nested `<Text>` trees into plain strings
2. **Tab expansion**: `expandTabs(rawText)` expands tabs to spaces (worst case 8 spaces per tab)
3. **Initial measurement**: `measureText(text, width)` computes natural width and height
4. **Wrapping**: If text exceeds width, calls `wrapText` according to `textWrap` mode (wrap/truncate/truncate-end, etc.)
5. **MeasureMode awareness**: In `Undefined` mode, multi-line text uses natural width; in `Exactly`/`AtMost` mode, it must respect constraints

There's a subtle detail here: when `widthMode === LayoutMeasureMode.Undefined` and the text contains newlines, the function **does not** re-wrap according to a constrained width. Why? Because Yoga uses `Undefined` mode to probe minimum/maximum dimensions—if the text were re-wrapped to a narrow width at this point, it would cause **height inflation**, making the layout engine think the node needs more height than it actually does.

---

## 3. Reconciler Pipeline: The Complete Path from React to Terminal

### 3.1 Pipeline Overview

```
User interaction/API data → React setState
                         ↓
            React Fiber scheduling (Concurrent Root)
                         ↓
            Reconciler create/update/delete DOMElement
                         ↓ resetAfterCommit()
            Yoga calculateLayout (pure TS, synchronous)
                         ↓ onComputeLayout()
            renderNodeToOutput (DOM → Output operation queue)
                         ↓
            output.get() → Screen buffer
                         ↓
            LogUpdate diff (Screen vs prevScreen → Diff patches)
                         ↓ optimizer.optimize()
            writeDiffToTerminal → stdout.write(ANSI sequences)
```

> 💡 **Plain English**: The entire rendering pipeline is like a **print shop assembly line**—the editor gives layout instructions (React setState) → typesetters adjust the layout (Reconciler updates DOM) → measurers determine each character's position with a ruler (Yoga layout) → plate-makers engrave content onto the printing plate (render-node-to-output writes to Screen buffer) → proofreaders compare old and new versions marking only differences (LogUpdate diff) → finally only the changed parts are modified (ANSI patch output to terminal). Most of the time only the last line changes, so "printing just one line" is enough.

### 3.2 Step 1: React Reconciler (reconciler.ts, 512 lines)

`reconciler.ts` calls `createReconciler<>()` to create a React 19-compatible custom host environment. This is React's **host adapter pattern**—just as `react-dom` is React's host for browser DOM, this reconciler is React's host for the terminal.

Seven host element types (defined in `dom.ts` lines 19-27):

| Element Name | Purpose | Browser Equivalent |
|--------------|---------|-------------------|
| `ink-root` | Root node | `<html>` |
| `ink-box` | Layout container | `<div style="display:flex">` |
| `ink-text` | Text container | `<span>` |
| `ink-virtual-text` | Nested text | Nested `<span>` |
| `ink-link` | Hyperlink | `<a>` |
| `ink-progress` | Progress bar | `<progress>` |
| `ink-raw-ansi` | Pre-rendered ANSI content | `<canvas>` prerendering mode |

> 📚 **Course Connection**: React Reconciler is an advanced topic in React architecture courses. React's core concepts—declarative UI, virtual DOM diff, Fiber scheduling—are independent of rendering target. `react-dom` renders to the browser, `react-native` renders to mobile, and this custom Reconciler renders to terminal ANSI sequences. Understanding the Reconciler's host adaptation mechanism is understanding the architectural essence of React's "learn once, write anywhere" philosophy.

Key decisions in the Reconciler:

**Strict nesting validation** (lines 338-339): A `<Box>` cannot be nested inside a `<Text>`. Attempting this will `throw new Error`. This is a hard constraint on Ink semantics—in a terminal, a layout container cannot appear inside a text flow.

**Text must be wrapped in `<Text>`** (lines 365-368): A bare string `"hello"` appearing outside `<Text>` will also throw. This ensures all text has a traceable style context.

**React 19's new commitUpdate signature** (lines 427-458): React 19 no longer uses `updatePayload` (the return value of `prepareUpdate` in React 18), but passes `oldProps` and `newProps` directly. The reconciler uses a custom `diff()` function to compare property changes, applying only the differences.

**Performance instrumentation** (lines 189-222): When the `COMMIT_LOG` environment variable is enabled, the reconciler records the duration of each commit, Yoga node stats (visited/measured/cacheHits/live), and slow frames exceeding 30ms intervals or 20ms reconcile time. This is production-grade performance monitoring.

### 3.3 Step 2: Virtual DOM (dom.ts, 484 lines)

`dom.ts` implements a minimal virtual DOM. The `DOMElement` type defines 30+ fields (lines 31-91), most of which are scroll-related:

- `scrollTop`, `pendingScrollDelta`, `scrollClampMin/Max`, `scrollHeight`, `scrollViewportHeight`—these fields make `DOMElement` serve as both a **DOM node** and a **scroll container**
- `dirty` flag for dirty-region tracking: only dirty subtrees need re-rendering
- `_eventHandlers` stored independently from `attributes`—this is a key optimization: event handler function identities change on every React render, and storing them in attributes would trigger `markDirty`, breaking blit optimization

The `markDirty` function (lines 393-413) is the core of the dirty-region tracking system: it bubbles `dirty = true` up the `parentNode` chain, while only calling `yogaNode.markDirty()` for leaf text nodes (`ink-text`, `ink-raw-ansi`) to trigger Yoga remeasurement. This guarantees **upward dirty propagation**—the renderer starts from the root and traverses down dirty paths, skipping all clean subtrees.

### 3.4 Step 3: Renderer Pipeline (renderer.ts → render-node-to-output.ts → output.ts → screen.ts)

**renderer.ts** (178 lines) is the rendering entry point. `createRenderer()` returns a closure called once per frame. Key designs:

- **Output object reuse**: `let output: Output | undefined` persists across frames, so `charCache` (text line grapheme clustering cache) doesn't need to be rebuilt every frame—meaning for unchanged text lines, parsing cost is zero
- **Double buffering**: `frontFrame` and `backFrame` alternate; current frame writes to backFrame.screen, then they swap
- **Alt-screen height clamping** (lines 97-104): When Yoga-computed height exceeds terminal rows, it's forcibly clamped to `terminalRows`, preventing overflow writes that would corrupt terminal cursor position

**render-node-to-output.ts** (1,462 lines) is the most complex single file. It recursively traverses the DOM tree, performing for each node:

1. **Dirty check + blit optimization**: If the node is not dirty and prevScreen has a cache, copy pixels directly from the previous frame (`blitRegion`)—O(1) instead of O(node content)
2. **Scroll handling**: Compute the visible area of `overflow: scroll` containers, apply `scrollTop` offset, handle per-frame draining of `pendingScrollDelta`
3. **DECSTBM hardware scroll hints**: When a ScrollBox's scrollTop changes, record a `ScrollHint` to tell log-update that terminal hardware scroll instructions (CSI S/CSI T) can be used instead of rewriting line by line
4. **Absolute positioning special handling**: Absolutely positioned nodes may draw across sibling subtrees; when removed, blit full-frame redraw must be disabled

The **per-frame drain mechanism** for scrolling (lines 106-157) deserves special attention. Fast scrolling accumulates large `pendingScrollDelta` values; applying them all at once would cause visual jumps. The code implements two drain strategies:

- **Native terminals** (iTerm2/Ghostty): Proportional drain—consume 3/4 of the remaining amount per frame, logarithmically converging (log4 frames to catch up)
- **xterm.js** (VS Code): Adaptive drain—low pending (<=5 lines) consumed entirely in one frame (slow scrolling should be immediate), high pending drained at a fixed 2-3 lines/frame (fast scrolling maintains smooth animation)

**screen.ts** (1,486 lines) implements the core data structure `Screen`—the **frame buffer** of the terminal interface. Three memory pools ensure efficient string deduplication:

| Pool | Type | Purpose | Why Needed |
|------|------|---------|------------|
| `CharPool` | String pool | Maps grapheme cluster to integer ID | Comparing two cells' characters only requires comparing ints, not strings |
| `StylePool` | ANSI style pool | Maps AnsiCode[] to integer ID | During diff, only compare styleId, not individual SGR parameters |
| `HyperlinkPool` | Hyperlink pool | Maps OSC 8 hyperlink URL to integer ID | Resets every 5 minutes to prevent memory leaks in long sessions |

The ASCII fast path in `CharPool` (lines 29-40) is a micro-optimization classic: ASCII characters (charCode < 128) use direct array indexing `this.ascii[code]`, bypassing `Map.get` hash overhead. For terminal UIs where 95%+ of content is ASCII, this matters.

### 3.5 Step 4: Diff Computation and Terminal Output (log-update.ts, 773 lines)

The `LogUpdate` class computes Screen differences between two frames into a set of **patch instructions (Diff)**:

```typescript
type Diff = Array<
  | { type: 'stdout'; content: string }
  | { type: 'cursorMove'; x: number; y: number }
  | { type: 'cursorTo'; col: number; row: number }
  | { type: 'styleStr'; str: string }
  | { type: 'hyperlink'; uri: string | undefined }
  | { type: 'cursorShow' }
  | { type: 'cursorHide' }
  | { type: 'clear'; count: number }
  | { type: 'carriageReturn' }
>
```

The patch sequence is written to the terminal after passing through **optimizer.ts** (93 lines) with 7 optimization rules:

1. **Remove empty stdout patches**: `content === ''` is skipped
2. **Merge consecutive cursorMove**: Two moves combined into one (reduces CSI sequence count)
3. **Collapse consecutive cursorTo**: Only the last positioning is kept
4. **Concatenate adjacent style patches**: Two SGR sequences merged into one string write
5. **Deduplicate consecutive hyperlinks**: Same URL OSC 8 not sent repeatedly
6. **Cancel cursor hide/show pairs**: One hide immediately followed by show = no-op
7. **Remove empty clear patches**: `count === 0` is skipped

> 📚 **Course Connection**: This "update only what changed" strategy is essentially **dirty rectangle rendering** from computer graphics courses—game engines don't redraw the entire screen every frame, only the regions that changed. Claude Code's four-layer filtering (React dirty → node dirty → screen cell diff → patch optimize) is isomorphic to a game engine's rendering pipeline (scene graph traversal → visibility culling → dirty region collection → GPU submission).

These optimizations may seem simple, but they're critical for terminal performance. Every ANSI sequence is a `stdout.write` call, and Node.js's `write()` involves the libuv I/O layer, system calls, and terminal emulator parsing overhead. Reducing patch count by 20-30% directly translates to 20-30% lower frame latency.

---

## 4. Custom Component Library

### 4.1 18 Base Components

The `components/` directory contains 18 files—the **atomic component library** of the Ink fork. All 389 upper-layer business components are built from these atoms.

| Component | File | Role |
|-----------|------|------|
| `Box` | `Box.tsx` | Layout container, equivalent to `<div style="display:flex">` |
| `Text` | `Text.tsx` | Text rendering, supports color, bold, underline, etc. |
| `ScrollBox` | `ScrollBox.tsx` | Scrollable container, supports virtual scrolling and hardware acceleration |
| `App` | `App.tsx` | Application root component, manages event dispatch and context |
| `AlternateScreen` | `AlternateScreen.tsx` | Alternate screen mode (fullscreen UI) |
| `Button` | `Button.tsx` | Clickable button (a GUI button in the terminal) |
| `Link` | `Link.tsx` | OSC 8 hyperlink |
| `RawAnsi` | `RawAnsi.tsx` | Pre-rendered ANSI content passthrough |
| `Spacer` | `Spacer.tsx` | Elastic space filler |
| `Newline` | `Newline.tsx` | Newline placeholder |
| `NoSelect` | `NoSelect.tsx` | Selection-prohibited area (like line number bars) |
| `ErrorOverview` | `ErrorOverview.tsx` | Error display |

Plus 6 context Providers: `AppContext`, `StdinContext`, `ClockContext`, `TerminalSizeContext`, `TerminalFocusContext`, `CursorDeclarationContext`.

### 4.2 Box: The Flexbox div in the Terminal

The `Box` component (`Box.tsx`) is the most frequently used component. Its accepted props directly correspond to CSS Flexbox properties:

```typescript
type Props = Except<Styles, 'textWrap'> & {
  tabIndex?: number;
  autoFocus?: boolean;
  onClick?: (event: ClickEvent) => void;
  onFocus?: (event: FocusEvent) => void;
  onBlur?: (event: FocusEvent) => void;
  onKeyDown?: (event: KeyboardEvent) => void;
  onMouseEnter?: () => void;
  onMouseLeave?: () => void;
};
```

Note `onClick`, `onMouseEnter`/`onMouseLeave`, `onKeyDown`—these are **browser DOM-style events**, implemented in the terminal. Box in the terminal is not just a layout container, but an interaction container.

### 4.3 ScrollBox: The Most Complex Component in the Terminal

`ScrollBox.tsx` implements scrollable regions in the terminal—a feature browser users take for granted but is extremely difficult to implement in a terminal. Its `ScrollBoxHandle` interface exposes 12 imperative methods:

- `scrollTo(y)`/`scrollBy(dy)`/`scrollToBottom()` — scroll control
- `scrollToElement(el, offset)` — scroll to a specified DOM element (reads Yoga coordinates at render time to avoid stale data)
- `getScrollTop()`/`getScrollHeight()`/`getViewportHeight()` — state queries
- `getFreshScrollHeight()` — bypass cache to read Yoga directly (used after `useLayoutEffect` when the freshest value is needed)
- `isSticky()` — whether following the bottom (auto-scroll to latest content)
- `setScrollClamp(min, max)` — viewport clamping for virtual scrolling

**Why is `scrollToElement` better than `scrollTo(number)`?** Because `scrollTo`'s argument is a number determined at call time, but React rendering is asynchronous—after calling `scrollTo(500)`, there might be a 16ms throttled delay before render executes, during which layout has changed and 500 is no longer the correct position. `scrollToElement` passes a DOM element reference, and reads `el.yogaNode.getComputedTop()` during the same-frame Yoga layout—guaranteeing accuracy.

---

## 5. Event System and Focus Management

### 5.1 DOM-Style Event Model

The `events/` directory (10 files) implements a complete **capture/bubble event system**—an extremely rare design in terminal UIs.

- `dispatcher.ts` — Event dispatcher, manages priorities and `discreteUpdates` (connecting to React's update scheduling)
- `keyboard-event.ts` — Keyboard events (`onKeyDown`/`onKeyDownCapture`)
- `focus-event.ts` — Focus events (`onFocus`/`onBlur` + capture variants)
- `click-event.ts` — Click events (col/row + localCol/localRow coordinates)
- `input-event.ts` — Input events

Event handlers are stored in `DOMElement._eventHandlers` (separated from `attributes`), and the reconciler's `applyProp` function (lines 122-143) checks `EVENT_HANDLER_PROPS.has(key)` to decide which path to take.

### 5.2 Click Hit Testing (hit-test.ts, 130 lines)

The `hitTest()` function implements classic **leaf-to-root collision detection**:

1. Read the node's screen coordinate rectangle from `nodeCache` (WeakMap)
2. Determine if (col, row) is inside the rectangle
3. Traverse children in **reverse order** (later-drawn layers on top, hit-tested first)
4. Return the deepest hit node

`dispatchClick()` does two things after a hit:

- **Click-to-focus**: Walk up `parentNode` to find the first ancestor with `tabIndex >= 0`, call `FocusManager.handleClickFocus`
- **Event bubbling**: Walk up `parentNode` from the hit node, trigger events for each node with an `onClick` handler, supporting `stopImmediatePropagation()` to terminate bubbling

`dispatchHover()` (lines 102-130) implements `mouseenter`/`mouseleave` semantics—they don't bubble, just like browser DOM: moving between child nodes doesn't re-trigger on the parent.

### 5.3 Focus Manager (focus.ts, 181 lines)

The `FocusManager` class is a complete DOM-style focus manager:

- **Focus stack** (max 32 levels): Records focus history; when a node is removed, focus is restored from the stack
- **Tab/Shift+Tab cycling**: `collectTabbable(root)` gathers all nodes with `tabIndex >= 0`, `moveFocus(direction)` implements cyclic navigation
- **Auto-focus**: `autoFocus` property triggers during the reconciler's `commitMount` stage
- **Blur recovery**: `handleNodeRemoved()` not only clears current focus, but also finds the nearest still-mounted node in the focus stack to restore focus to—preventing focus from "disappearing" due to React dynamic rendering

The focus stack has a deduplication mechanism (line 34): Tab cycling would repeatedly push the same node onto the stack, so `focus()` first removes existing entries before pushing, keeping the stack bounded.

---

## 6. Text Selection System

### 6.1 Why Build Selection from Scratch?

Terminal emulators (iTerm2, Terminal.app, etc.) have their own text selection features. But Claude Code runs in **alt-screen mode** (DECSET 1049), where:

- The terminal's selection can only select **characters on the current screen**, not content that has scrolled off-screen
- The terminal doesn't know about Claude Code's virtual scrolling—it might select old content that has already been overwritten
- The terminal's selection can't cooperate with Claude Code's search highlighting

So Claude Code built a complete selection system from scratch—**`selection.ts` (917 lines)** is the fifth-largest file in the `ink/` directory.

### 6.2 Selection State Model

```typescript
type SelectionState = {
  anchor: Point | null;       // Mouse down position
  focus: Point | null;        // Current drag position
  isDragging: boolean;        // Whether dragging is in progress
  anchorSpan: {               // Double/triple-click initial range
    lo: Point; hi: Point;
    kind: 'word' | 'line'
  } | null;
  scrolledOffAbove: string[]; // Text scrolled above the viewport
  scrolledOffBelow: string[]; // Text scrolled below the viewport
  scrolledOffAboveSW: boolean[]; // Parallel soft-wrap flags
  scrolledOffBelowSW: boolean[];
  virtualAnchorRow?: number;  // Anchor row before clamping
  virtualFocusRow?: number;   // Focus row before clamping
  lastPressHadAlt: boolean;   // macOS Option key state
};
```

The complexity of this state model is notable: the `scrolledOffAbove/Below` arrays solve the problem of **drag-selection while content scrolls**. When a user drags past the bottom of the viewport, the ScrollBox starts auto-scrolling—the screen buffer only holds currently visible content, and rows that scroll out are lost. `captureScrolledRows` extracts rows about to scroll out from frontFrame before scrolling and saves them to these arrays; `getSelectedText` ultimately concatenates them with current screen text.

The `softWrap` flags (`scrolledOffAboveSW`/`BelowSW`) solve another subtle problem: when text wraps automatically due to terminal width limitations (soft-wrap), the selection result should restore these "fake line breaks"—`getSelectedText` uses these flags to decide whether to insert `\n` or concatenate directly.

### 6.3 Double-Click Word Selection

The `charClass()` function (lines 151-155) divides characters into three classes:

- Whitespace (0): space, empty string
- Word characters (1): Unicode letters/digits + `/-+\~_.`
- Symbols (2): everything else

The word selection rules match iTerm2's default behavior: `/usr/bin/bash` counts as one word (includes `/`), `~/.claude/config.json` is also one word. This is intentional—macOS terminal users' muscle memory is that double-clicking a path selects the entire path.

---

## 7. Bidirectional Text Support (bidi.ts, 139 lines)

### 7.1 The Problem: Terminals Don't Understand Arabic

**Q: Why is software Bidi needed? Don't terminals support Unicode?**

Terminals support Unicode **characters**, but not necessarily the **Unicode Bidi Algorithm**. The Bidi algorithm determines the **visual position** of each character in mixed-direction text (e.g., "Hello مرحبا World").

macOS Terminal.app and iTerm2 natively implement Bidi. But **Windows Terminal does not** (GitHub issue #538). When Claude Code runs on these terminals, RTL text (Hebrew, Arabic, etc.) displays reversed.

### 7.2 Solution: Conditional Bidi Reordering

`bidi.ts` implements a three-layer judgment:

1. **Platform detection** (`needsBidi()`): `process.platform === 'win32'` or `WT_SESSION` environment variable (WSL in Windows Terminal) or `TERM_PROGRAM === 'vscode'` (VS Code integrated terminal uses xterm.js)
2. **RTL quick detection** (`hasRTLCharacters()`): Regex matches Hebrew (U+0590-U+05FF), Arabic (U+0600-U+06FF and 4 other ranges), Syriac, Thaana characters—pure LTR text has zero overhead
3. **Standard Bidi reordering**: Uses the `bidi-js` library to get embedding levels, then reverses level by level from highest to 1—standard Unicode Bidi Algorithm L2 rule

`reorderBidi()` operates on `ClusteredChar[]` (already-parsed grapheme cluster arrays), not raw strings. This ensures that after reordering, each grapheme's width, style ID, and hyperlink information remain correct.

---

## 8. Terminal Querier (terminal-querier.ts, 212 lines)

### 8.1 The Problem: Terminal Capabilities Vary Wildly

Claude Code needs to know what features the user's terminal supports: synchronized output (mode 2026)? Kitty keyboard protocol? Grapheme clustering (mode 2027)? Is the background light or dark?

The traditional approach is guessing based on the `TERM_PROGRAM` environment variable. But this is unreliable—SSH tunnels drop environment variables, tmux overrides TERM values.

### 8.2 Solution: DA1 Sentinel Query

`TerminalQuerier` implements a **zero-timeout** terminal capability probing protocol:

1. **Send query sequences** (e.g., DECRQM CSI ? 2026 $ p) to stdout
2. **Send DA1 sentinel** (CSI c)—a request that **every terminal since VT100 must respond to**
3. **Wait for responses**: Terminals respond in order (FIFO)
   - If the query response arrives before DA1 → terminal supports that feature
   - If DA1 arrives first → terminal doesn't support it, resolve to undefined

This design eliminates timeout guessing. The traditional approach is "send query, wait 100ms, if no response received, assume not supported"—but 100ms may not be enough in high-latency SSH scenarios, and is unnecessary waiting locally. The DA1 sentinel turns "how long to wait" into "wait until the sentinel responds"—**adaptive delay**.

```typescript
// Usage example (from comments)
const [sync, grapheme] = await Promise.all([
  querier.send(decrqm(2026)),    // Synchronized output mode
  querier.send(decrqm(2027)),    // Grapheme clustering mode
  querier.flush(),                // DA1 sentinel
]);
```

> 💡 **Plain English**: The DA1 sentinel is like a **queue ticket number**—you ask three questions at once (query 1, query 2, sentinel), and if your number is called before the sentinel, the terminal answered your question (supports the feature); if the sentinel's number is called first, the terminal skipped your question (doesn't support it). No need to set an alarm for how long to wait—once the sentinel arrives, you have your answer.

Supports 7 query types: `decrqm` (DEC private mode status), `da1` (primary device attributes), `da2` (secondary device attributes), `kittyKeyboard` (Kitty keyboard protocol flags), `cursorPosition` (cursor position, using DEC private marker to avoid conflict with Shift+F3), `oscColor` (dynamic color query, for detecting dark/light theme), `xtversion` (terminal version, penetrates SSH to identify the client terminal).

---

## 9. Terminal Focus State (terminal-focus-state.ts, 47 lines)

This is a concise but important module. Through DECSET 1004 (focus event reporting), Claude Code knows whether the user's terminal window is in the foreground:

- **focused**: Terminal window is in the foreground
- **blurred**: Terminal window is in the background
- **unknown**: Terminal doesn't support focus reporting (treated as focused)

What's this good for? **Render throttling**. When the terminal is in the background, nobody is looking at the screen—rendering can be reduced or paused, decreasing CPU and TTY I/O overhead. `subscribeTerminalFocus` provides a `useSyncExternalStore`-compatible subscription interface, letting React components respond to focus changes.

---

## 10. Search Highlighting (searchHighlight.ts, 93 lines)

### 10.1 Implementation Strategy

Search highlighting isn't implemented in the React component tree—it's a **post-render overlay**, directly manipulating the Screen buffer.

`applySearchHighlight()` workflow:

1. Scan Screen buffer line by line
2. For each line, build a **code-unit→cell mapping**—because wide characters (CJK, emoji) occupy two cells, the Nth character is not in the Nth column
3. Case-insensitive search (`toLowerCase` executed per character, not on a concatenated string—avoiding position shifts from edge cases like Turkish İ)
4. Matched cells are inverted via `stylePool.withInverse(styleId)` (SGR 7)
5. Non-overlapping advancement (like less/vim/grep behavior: `pos + qlen` rather than `pos + 1`)

Why not implement it in the React layer? Because search results can span multiple components—a match might start in message text and end in a code block. Implementing this in React would mean every component needs to be search-aware, a classic **cross-cutting concern**. The post-render overlay decouples search logic from components.

Three cell types are skipped: `SpacerTail` (second cell of a wide character), `SpacerHead` (line-wrap padding), `noSelect` (line number bars and other non-selectable regions). This matches the text selection exclusion rules—"what search can find = what selection can select = what content the user can see."

---

## 11. Performance Optimization Architecture

### 11.1 Three-Layer Cache Architecture

| Cache Layer | File | Granularity | Hit Scenario |
|-------------|------|-------------|--------------|
| Line width cache | `line-width-cache.ts` (24 lines) | Text line → terminal width | Completed lines in streaming output don't change |
| Node cache | `node-cache.ts` (54 lines) | DOMElement → screen rectangle | Blit dirty checks, hit testing |
| Char cache | `charCache` in `output.ts` | Text line → ClusteredChar[] | Avoids repeated tokenize + grapheme clustering |

**line-width-cache.ts** is the simplest cache—a `Map<string, number>` with a 4,096-entry limit, fully cleared when full (LRU doesn't make sense when everything gets rebuilt next frame; simple clearing is faster). In streaming AI output, completed lines are immutable (only the last line grows), so cache hit rates are extremely high—the comment mentions **~50×** reduction in `stringWidth` calls.

**node-cache.ts** is the foundation of blit optimization. `nodeCache` (WeakMap<DOMElement, CachedLayout>) saves each node's screen coordinates from the previous frame. `pendingClears` (WeakMap<DOMElement, Rectangle[]>) tracks rectangles of removed child nodes—next frame needs to clear these areas. The `absoluteNodeRemoved` flag indicates an absolutely positioned node was removed—in this case blit must be disabled, because absolutely positioned nodes may draw across subtrees.

### 11.2 Dirty-Region Tracking

The entire rendering system is designed around "only update what changed":

1. **React Reconciler layer**: `markDirty()` propagates dirty flags upward
2. **render-node-to-output layer**: Traverses tree skipping clean subtrees (blit copies from prevScreen)
3. **LogUpdate layer**: `diffEach()` compares cells between two Screens, only outputting changed cells
4. **optimizer layer**: Merges, deduplicates, and cancels redundant ANSI patches

For steady-state frames (spinner rotation, clock refresh, text stream appending to a fixed-height container), after these four layers of filtering, the output ANSI sequence volume is minimal—only a few changed cells' styles and characters.

### 11.3 Render Throttling

`constants.ts` defines `FRAME_INTERVAL_MS = 16`—a ~60fps rendering cap. `ink.tsx` uses `throttle(scheduleRender, FRAME_INTERVAL_MS)` to ensure that even if React commits frequently (each token in a stream might trigger setState), actual terminal output doesn't exceed 60fps.

The `Ink` class in `ink.tsx` (1,722 lines) also implements **double buffering** (`frontFrame`/`backFrame` alternation) and **periodic HyperlinkPool reset** (clearing the hyperlink pool every 5 minutes to prevent memory growth in long sessions—the `lastPoolResetTime` field tracks the last reset time).

### 11.4 Commit Performance Monitoring

The commit instrumentation in reconciler.ts (lines 189-198) provides production-grade performance monitoring. When the `CLAUDE_CODE_COMMIT_LOG` environment variable is set:

- Records timestamp and interval (gap) of each commit
- Frames exceeding 30ms intervals or 20ms reconcile time are flagged
- Yoga counters (visited/measured/cacheHits/live nodes) are logged on slow frames
- Yoga layouts exceeding 20ms are marked `SLOW_YOGA`
- Renders exceeding 10ms are marked `SLOW_PAINT`
- Commits/s and maxGapMs are summarized once per second

This monitoring lets developers pinpoint performance bottlenecks precisely: is React reconcile slow? Is Yoga layout slow? Or is terminal rendering slow?

---

## 12. Ink Core Class (ink.tsx, 1,722 lines)

### 12.1 Responsibilities of the Ink Class

The `Ink` class in `ink.tsx` is the **central dispatcher** of the entire UI system—in the city analogy, it's the **central traffic management center**. Its instance field inventory reveals the coordination scope:

| Field Category | Key Fields | Purpose |
|----------------|------------|---------|
| Render pipeline | `renderer`, `scheduleRender`, `log` | Renderer, throttled scheduling, terminal output |
| React integration | `container` (FiberRoot), `rootNode` | React 19 Concurrent Root |
| Memory management | `stylePool`, `charPool`, `hyperlinkPool` | Three types of string deduplication pools |
| Double buffering | `frontFrame`, `backFrame` | Frame buffer alternation |
| Interaction state | `selection`, `searchHighlightQuery`, `searchPositions` | Selection and search |
| Mouse state | `hoveredNodes` (Set) | Current set of hovered DOM nodes |
| Alt-screen | `altScreenActive`, `altScreenMouseTracking` | Fullscreen mode state |
| Terminal cursor | `cursorDeclaration`, `displayCursor` | CJK input method and a11y |
| Dirty tracking | `prevFrameContaminated`, `needsEraseBeforePaint` | Full-frame redraw triggers |

The `cursorDeclaration` field is notable—it lets components declare where the terminal cursor should be positioned via the `useDeclaredCursor` hook. Terminal emulators render IME pre-edit text at the cursor position, and screen readers/magnifiers track cursor position—so placing the cursor at a text input's insertion point enables inline CJK input method display and allows assistive technology to work properly.

### 12.2 onRender Flow

Each frame's rendering completes in `onRender()` (simplified):

1. Call `renderer(options)` to generate a new frame (render-node-to-output → output → screen)
2. **Post-render overlays**:
   - `applySearchHighlight()` — search highlight inversion
   - `applySelectionOverlay()` — text selection inversion
   - `applyPositionedHighlight()` — current search match yellow highlight
3. Generate Diff patches: `log.render(frame, prevFrame)`
4. Optimize patches: `optimize(diff)`
5. Write to terminal: `writeDiffToTerminal(terminal, diff)`
6. Swap buffers: `frontFrame ↔ backFrame`

Overlays are applied after rendering but before diff—meaning selections and search highlights are **treated as ordinary content changes**, and the diff engine doesn't need to know they exist. This is elegant decoupling.

---

## 13. Three "Pages"

### 13.1 REPL (Main Interface)

The interface Claude Code shows 99% of the time—conversation window, message list, tool call displays, permission dialogs.

The REPL runs inside `<AlternateScreen>`, which means:
- Uses the alternate screen buffer (DECSET 1049)
- Mouse tracking enabled (mode 1002/1003)
- Automatically restores original screen content on exit

### 13.2 Setup (Initialization Wizard)

The setup flow for first-time use—choose model, configure API key, accept terms.

### 13.3 Login

The UI for the OAuth login flow.

---

## 14. Streaming Rendering: AI "Typing"

```
API stream chunk → React state update → Ink re-render → ANSI output → Terminal display
```

Each stream chunk (possibly just a few tokens) triggers a React re-render. The rendering pipeline's four layers of optimization ensure that only changed cells are written to the terminal. In steady-state streaming output (text appending to a fixed-height ScrollBox), a typical frame only needs to update a few characters on the last line—a single `stdout.write` might be only a few dozen bytes of ANSI sequences.

**The 16ms frame interval (60fps)** is a carefully chosen number: too low causes CPU busy-waiting and TTY I/O overload; too high makes the user perceive latency. 16ms is just below the human perception threshold for updates, and also matches the refresh rate of most displays.

---

## 15. Permission Dialogs: Modal Dialogs in the Terminal

When the AI needs to execute an operation requiring confirmation, a "dialog" appears on screen—actually a React component overlaying the message stream.

```
+------------------------------------------+
|  Claude wants to run: Bash(rm -rf build/)|
|                                          |
|  [Allow]  [Deny]  [Always Allow]         |
+------------------------------------------+
```

This "dialog" leverages multiple capabilities of the Ink fork:
- **Focus management**: The dialog automatically captures focus on mount (`autoFocus`), buttons cycle via Tab
- **Keyboard events**: `onKeyDown` handles Enter (confirm) and Escape (deny)
- **Click events**: Buttons support mouse clicks
- **Absolute positioning**: The dialog uses `position: absolute` to overlay content above

---

## 16. Buddy System: The Engineering of a Small Animal

Buddy is Claude Code's pet system—a small animal sitting beside the input box.

### 16.1 Bones/Soul Separation Architecture

```
Bones (deterministic appearance)
  +-- Species: 18 types (rabbit, cat, fox...)
  +-- Color: determined by hash
  +-- Rarity: Gacha mechanic (Common 60% / Rare 10% / Legendary 1%)

Soul (AI-generated personality)
  +-- Name: AI-generated
  +-- Personality traits: AI-generated
  +-- Conversation style: AI-generated
```

Appearance is determined by a deterministic hash (the same user always sees the same animal), while personality is generated by an LLM.

### 16.2 18 Species Encoded with `String.fromCharCode()`

Species names aren't hardcoded strings, but encoded with `String.fromCharCode()`. Reason: avoid collision with model internal codename strings.

---

## 17. Keyboard Handling

`keybindings/` (14 files) defines Claude Code's shortcut system:

| Shortcut | Function |
|----------|----------|
| Enter | Submit message |
| Ctrl+C | Interrupt current operation |
| Ctrl+J | Insert newline |
| Esc | Cancel/Return |
| Tab | Auto-complete / Focus cycle |
| Up/Down | History message navigation |

**Vim mode** (`vim/`, 5 files): Claude Code supports Vim-style input mode—Insert/Normal/Visual mode switching.

The `hooks/` directory provides 12 React Hooks, of which `use-input.ts` is the core for keyboard input, `use-selection.ts` manages text selection state, `use-search-highlight.ts` manages search highlighting, `use-declared-cursor.ts` declares cursor position, and `use-terminal-focus.ts` subscribes to terminal focus state.

---

## 18. Themes and Styles

`styles.ts` (771 lines) defines 40+ CSS Flexbox mapped properties. The `applyStyles()` function translates React props into Yoga layout instructions:

- Dimensions: `width`, `height`, `minWidth`, `maxHeight` (supports numbers and percentages)
- Flex: `flexDirection`, `flexGrow`, `flexShrink`, `flexBasis`, `flexWrap`
- Alignment: `alignItems`, `alignSelf`, `justifyContent`
- Spacing: `padding`, `margin`, `gap` (supports setting all four sides independently)
- Borders: `borderStyle` (6 border styles), `borderColor`
- Positioning: `position` (relative/absolute), `top/right/bottom/left`
- Overflow: `overflow` (visible/hidden/scroll)
- Display: `display` (flex/none)

The `termio/` subdirectory (9 files) encapsulates terminal I/O primitives: CSI control sequences (cursor movement, scroll region, clear screen), OSC operating system commands (hyperlinks, clipboard, tab titles), DEC private modes (alt-screen, mouse tracking, focus reporting), SGR graphic rendition (colors, bold, inverse).

---

## 19. Design Trade-offs

### The Good

1. **Complete fork rather than monkey-patch** gives the team full control over the rendering pipeline—from React Reconciler to ANSI output, every layer can be optimized
2. **Pure TS Yoga** eliminates WASM startup latency and memory management issues, while preserving the ability to switch back via the `LayoutNode` interface
3. **Four-layer dirty-region tracking** (React dirty → node dirty → screen cell diff → patch optimize) minimizes steady-state frame output to the absolute minimum
4. **DA1 sentinel query protocol** is the correct approach for terminal capability probing—zero timeout, adaptive delay, production-reliable
5. **Post-render overlay design** (search highlighting, text selection) extracts cross-cutting concerns from the component tree—the diff engine is completely unaware of them
6. **Separating event handlers from attributes** prevents function identity changes from triggering unnecessary dirty marks—this micro-optimization has massive impact on streaming rendering performance

### The Cost

1. **19,842 lines of self-built UI framework** is a massive maintenance burden—every React upgrade requires manual reconciler adaptation
2. **917 lines in selection.ts** exposes the complexity of rebuilding browser-grade selection in a terminal—scroll accumulators, soft-wrap flags, and virtual row recovery are all features browsers provide for free but the terminal must implement from scratch
3. **Double buffering + three memory pools + frame throttling** increases debugging difficulty—a UI bug might span multiple frames of state, requiring understanding of the entire pipeline to reproduce
4. **Conditional Bidi reordering** is dead code on non-Windows platforms—but it's a necessary cost for WSL and VS Code terminal user experience
5. **The 1,722-line Ink class** has too many responsibilities—it manages the render pipeline, mouse events, selection state, search highlighting, focus system, and cursor declaration all at once—a "god object" worth splitting

---

> **[Chart placeholder 2.12-A]**: Full rendering pipeline diagram: React → Reconciler → DOM → Yoga → Output → Screen → LogUpdate → Terminal
> **[Chart placeholder 2.12-B]**: REPL main interface component tree diagram—from `<App>` → `<AlternateScreen>` → `<ScrollBox>` → message component hierarchy
> **[Chart placeholder 2.12-C]**: Streaming rendering sequence diagram—API stream chunk → React state → throttled render → ANSI output, annotated with 16ms frame interval and four optimization layers
> **[Chart placeholder 2.12-D]**: Dirty-region tracking four-layer filter diagram—showing how a steady-state frame converges from "full tree 1000+ nodes" to "3 cells' ANSI patches"
> **[Chart placeholder 2.12-E]**: TerminalQuerier DA1 sentinel sequence diagram—query sent → response received → sentinel arrival in three scenarios
