Chapter 10: Custom Terminal UI Framework (Ink)
What You'll Learn
By the end of this chapter, you will be able to:
- Explain why Claude Code maintains a full fork of Ink in
src/ink/rather than depending on the upstream npm package, and articulate the specific production concerns that drove that decision - Read
src/ink/reconciler.tsand understand how a custom React reconciler works: what host config functions are required, what anInkNoderepresents, and how the commit phase connects to terminal output - Describe the role of Yoga WASM in terminal layout, trace a layout calculation from raw component props through
yogaNode.calculateLayout()to final pixel coordinates, and explain the terminal-specific constraints Yoga must handle - Follow the complete rendering pipeline from React commit phase through layout calculation through ANSI escape sequence generation and differential output
- Describe how
src/ink/termio/handles raw terminal input, including the byte-stream parsing of multi-byte ANSI escape sequences for special keys and mouse events - Explain how Ink's focus management system routes keyboard events to the correct component, how
useFocus()works, and how Tab cycling is implemented - Understand virtual scrolling, text wrapping with CJK awareness, and the component primitives Box and Text that everything else builds on
10.1 Why Fork Ink?
Ink, Meta's library for building terminal UIs with React, is a genuine engineering achievement. It brought the declarative component model — the one frontend engineers already know — to a medium (the terminal) that had always required imperative cursor manipulation. The ability to write <Box flexDirection="column"> and have it just work in a terminal window is, on its own, remarkable.
But Claude Code is not a weekend project. It is a production CLI used continuously by engineers who treat it as a core part of their workflow. That context imposes requirements that the upstream Ink library, designed for broad compatibility and ease of contribution, cannot satisfy without modification.
The fork lives at src/ink/ — 85 files implementing a complete React renderer targeting terminal output. The root-level src/ink.ts is nothing more than a re-export barrel: it collects the public API from the fork and re-exports it so the rest of the codebase can import { Box, Text, useInput } from '../ink.js' without knowing anything about the internal structure. Understanding why this fork exists is the first step toward understanding what it does.
10.1.1 Performance Under Continuous Load
The upstream Ink renders on a timer. It debounces React state changes and triggers a full re-render on a fixed interval. For a simple tool that renders a short status line, this is perfectly adequate. For Claude Code — which maintains a potentially large message list, streams tokens in real time, runs tool output through syntax highlighting, and must remain responsive even after hours of continuous use — a timer-based render strategy creates observable latency.
The fork replaces the timer with a scheduler that is driven by React's own reconciler lifecycle. Renders happen in response to actual state changes, not on a clock. More importantly, the fork implements differential rendering: rather than repainting the entire terminal on every update, it computes which terminal rows changed and writes only those. On a 200-row terminal displaying a long conversation, this reduces per-token output from roughly 200 row rewrites to typically two or three.
10.1.2 Control Over the Rendering Pipeline
The upstream Ink's rendering pipeline is a black box. Claude Code needed to intercept rendering at multiple points: to apply custom color themes, to integrate token streaming with React state, to implement virtual scrolling for long message lists, and to handle terminal resize events in ways that recompute layout rather than corrupting it. None of these required changes could be expressed as Ink plugins or configuration options — they required modifications to the core rendering loop.
The fork exposes the rendering pipeline as a set of composable stages (described in Section 10.4) so that higher-level components can hook into exactly the stage they need.
10.1.3 Bun Compatibility and WASM
Claude Code runs on Bun, not Node.js. The upstream Ink depends on yoga-layout-prebuilt, which ships native addon binaries compiled for specific Node.js versions. These fail to load under Bun. The fork migrates the Yoga dependency to yoga-layout — the pure WASM build — which works correctly under any JavaScript runtime that supports WASM, including Bun.
This change has a small startup cost (WASM loading is slightly slower than a native binary) that was considered acceptable compared to the alternative of requiring users to run on a specific Node.js version.
10.1.4 Production CLI Requirements
Three additional concerns push the fork further from the upstream:
First, Claude Code needs precise control over raw mode entry and exit. When a subcommand spawns a child process that needs to interact with the terminal (for example, a text editor opened by $VISUAL), the rendering loop must suspend itself completely, restore the terminal to normal mode, wait, and then resume rendering after the subprocess exits. The upstream Ink's terminal mode handling was not designed with this lifecycle in mind.
Second, the fork adds bracketed paste mode support. When users paste large blocks of text into the REPL, bracketed paste sequences (\x1B[200~ ... \x1B[201~) wrap the pasted content. Without handling this, each newline inside the paste triggers a premature submit. The termio layer in the fork handles this at the input parsing level, before the content reaches any React component.
Third, mouse event support is more complete in the fork. The upstream has basic mouse support; Claude Code extends it to handle button distinction, scroll events, and the various extended mouse protocols that modern terminals support.
10.2 React Reconciler: src/ink/reconciler.ts
React is not a rendering engine — it is a reconciliation engine. React's job is to compute the minimum set of changes needed to bring a previous state into a desired state. The actual output medium is the responsibility of a "host renderer" that React calls via a well-defined interface.
ReactDOM is one host renderer. React Native's renderer is another. The Ink fork's src/ink/reconciler.ts is a third, targeting terminal output instead of browser DOM or mobile native UI.
10.2.1 The React Reconciler Package
React ships its reconciler as a package called react-reconciler. This package is the core of React — the fiber algorithm, the concurrent mode scheduler, the commit/render phase split — as a standalone module that any host environment can consume. When you call react-reconciler(hostConfig), you get back a renderer factory. The hostConfig object is where you describe your host environment's primitive operations.
The resulting renderer exposes a createContainer / updateContainer API. createContainer creates a React root attached to your host root node. updateContainer schedules a React render targeting that root.
// src/ink/reconciler.ts — conceptual structure
import ReactReconciler from 'react-reconciler'
const hostConfig: ReactReconciler.HostConfig<
InkNodeType, // Type — the string name of a host component ("ink-box", "ink-text")
InkProps, // Props — the props object for host components
InkContainer, // Container — the root node for the React tree
InkNode, // Instance — a host component instance
InkTextNode, // TextInstance — a text node
...
> = {
// ... methods described below
}
const reconciler = ReactReconciler(hostConfig)
export function createRenderer(container: InkContainer) {
return reconciler.createContainer(container, 0, null, false, null, '', {}, null)
}
export function render(root: React.ReactElement, container: InkContainer) {
reconciler.updateContainer(root, container, null, null)
}The type parameters are the key to understanding what the host config describes. InkNode is the reconciler's equivalent of a DOM element — the mutable object that represents a rendered host component. InkTextNode is its equivalent of a DOM text node. InkContainer is the root of the tree, analogous to document.body.
10.2.2 The InkNode Type
Before examining the host config methods, it is worth being precise about what an InkNode is. It is a plain object (not a class) with the following shape:
// src/ink/reconciler.ts — InkNode structure
type InkNode = {
nodeName: 'ink-box' | 'ink-text' | 'ink-virtual-text'
style: Style
textContent: string
yogaNode: Yoga.Node | undefined // undefined for virtual text nodes
parentNode: InkNode | InkContainer | null
childNodes: Array<InkNode | InkTextNode>
// Internal rendering state
onRender?: () => void
}The analogy to the browser DOM is intentional. An InkNode with nodeName: 'ink-box' is the terminal equivalent of a <div>. An InkNode with nodeName: 'ink-text' is the terminal equivalent of a styled <span>. InkTextNode is the terminal equivalent of a Text node in the DOM.
The yogaNode field is the bridge between React's tree and Yoga's layout engine. Every box node owns a Yoga.Node object. When layout needs to run, the reconciler traverses the InkNode tree, reads the style properties, applies them to the corresponding Yoga.Node, and then asks Yoga to compute the layout. The separation between InkNode (React's view of the tree) and Yoga.Node (Yoga's view of the layout) is fundamental to understanding how the pipeline works.
Virtual text nodes (ink-virtual-text) are an optimization. When a <Text> component renders a string that contains no special styles, creating a full InkNode with a yogaNode would be wasteful. Virtual text nodes skip Yoga allocation entirely — they are invisible to the layout engine and exist only to hold content that the rendering stage will read from the parent box's measured dimensions.
10.2.3 Required Host Config Methods
The host config is a large interface, but only a subset of methods need deep explanation for understanding how Ink works. The most important are:
createInstance(type, props, rootContainer, hostContext, internalHandle)
This is the factory function for new host component instances. It is called every time React needs to create a new host node — not a composite component (those live entirely in React's fiber tree), but a terminal-renderable node like <Box> or <Text>.
createInstance(type: InkNodeType, props: InkProps): InkNode {
const node: InkNode = {
nodeName: type,
style: {},
textContent: '',
yogaNode: undefined,
parentNode: null,
childNodes: [],
}
// Allocate a Yoga layout node for box instances
if (type === 'ink-box') {
node.yogaNode = Yoga.Node.create()
}
// Apply initial props (flex direction, gap, padding, etc.)
applyProps(node, props)
return node
}The critical decision here is when to allocate the Yoga.Node. Box nodes get one immediately. Text nodes do not — they receive their layout information from their parent box.
createTextInstance(text)
Creates a InkTextNode for raw text content. In JSX, the string "hello" in <Box>hello</Box> becomes a text instance between the two box create/append calls.
createTextInstance(text: string): InkTextNode {
return { nodeName: '#text', value: text }
}appendChild, insertBefore, removeChild
These mirror the DOM mutation methods. appendChild adds a child to a parent's childNodes array and sets the child's parentNode. If both the parent and child have yogaNode objects, it also calls yogaParent.insertChild(yogaChild, yogaParent.getChildCount()) so the Yoga tree stays synchronized with the InkNode tree.
appendChild(parent: InkNode | InkContainer, child: InkNode | InkTextNode): void {
parent.childNodes.push(child)
child.parentNode = parent
if (parent.yogaNode && (child as InkNode).yogaNode) {
const childYoga = (child as InkNode).yogaNode!
parent.yogaNode.insertChild(childYoga, parent.yogaNode.getChildCount())
}
markDirty(parent)
}markDirty traverses the ancestor chain to flag that layout needs recomputation. This is an optimization: rather than running Yoga on every mutation, the reconciler batches layout runs to happen once after the commit phase completes.
prepareUpdate(instance, type, oldProps, newProps)
This method is called during the render phase, before any mutations are committed. React passes the old and new props for a given instance and asks the host config to compute a "diff" — an opaque value that will later be passed to commitUpdate. The Ink fork computes a partial props object containing only the keys that changed:
prepareUpdate(
instance: InkNode,
type: InkNodeType,
oldProps: InkProps,
newProps: InkProps,
): Partial<InkProps> | null {
const diff: Partial<InkProps> = {}
let hasDiff = false
for (const key of Object.keys(newProps) as Array<keyof InkProps>) {
if (oldProps[key] !== newProps[key]) {
(diff as any)[key] = newProps[key]
hasDiff = true
}
}
return hasDiff ? diff : null
}Returning null tells React that this instance does not need updating — no commitUpdate call will be scheduled. This is a meaningful optimization when the tree is large and most nodes are stable between renders.
commitUpdate(instance, updatePayload, type, oldProps, newProps)
Called in the commit phase when prepareUpdate returned a non-null diff. The implementation applies the partial props to the InkNode and, for layout-affecting properties, marks the Yoga node dirty:
commitUpdate(
instance: InkNode,
updatePayload: Partial<InkProps>,
): void {
applyProps(instance, updatePayload)
markDirty(instance)
}supportsMutation: true
This declaration tells React to use the mutable mode host config (as opposed to persistent mode, which clones nodes on every update). Mutable mode more closely mirrors how a DOM works and is the correct choice for a terminal renderer where the goal is incremental updates.
prepareForCommit and resetAfterCommit
These bracket the commit phase. prepareForCommit is called before any mutations begin; resetAfterCommit is called after all mutations and effect callbacks have run. In the Ink fork, resetAfterCommit is where the layout + render pass is triggered:
resetAfterCommit(container: InkContainer): void {
// 1. Calculate Yoga layout
computeLayout(container)
// 2. Convert layout result to ANSI strings
const output = renderToString(container)
// 3. Emit to terminal with differential update
container.onRender(output)
}This three-step sequence — layout, render, output — is the heart of the rendering pipeline and is covered in detail in Section 10.4.
10.2.4 The Commit Phase and Fiber Priorities
React's fiber architecture splits rendering work into two phases: the render phase (pure, interruptible, can be discarded) and the commit phase (synchronous, cannot be interrupted). The host config methods createInstance, prepareUpdate, and the like are called during the render phase and can be called multiple times before a commit if React decides to throw away a partial render. Only the commit* methods are guaranteed to run exactly once in response to a state change.
This distinction matters for the Ink fork because Yoga layout must only run once per logical update — not once per render phase attempt. The fork triggers layout exclusively in resetAfterCommit, guaranteeing this invariant regardless of how many times React may have speculatively rendered a given subtree.
10.3 Layout Engine: Yoga WASM and the CSS Flexbox Model for Terminals
Yoga (https://yogalayout.dev) is Meta's layout engine. It was originally developed for React Native to bring CSS Flexbox to mobile native views, and it has since been adopted as a standalone library used across multiple rendering targets. Ink uses it to answer the fundamental question of terminal layout: given a tree of nodes with flex styles, what are the exact (column, row, width, height) coordinates of every node?
10.3.1 Why Yoga and Not a Custom Layout Engine
Writing a correct CSS Flexbox implementation from scratch is a multi-year project. The CSS specification for Flexbox spans hundreds of pages and includes numerous edge cases around baseline alignment, wrapping, fractional gap spacing, and interaction with explicit sizes. Yoga is a mature, tested implementation of this specification, proven across billions of React Native renders.
The terminal-specific constraints (everything is in character cells, not pixels) make the problem simpler in some ways and more complex in others. Simpler because there are no fractional character positions — all coordinates are integers. More complex because terminal width is not known at build time and must be read at render time from process.stdout.columns, and because terminal height is technically infinite (scrolling) rather than fixed.
Yoga handles both of these correctly: dimensions are set at runtime before each layout calculation, and unbounded height is represented by leaving the height dimension unconstrained.
10.3.2 Yoga Node Lifecycle
Each InkNode with nodeName === 'ink-box' owns exactly one Yoga.Node. The lifecycle of this Yoga node mirrors the lifecycle of the InkNode:
When createInstance creates a new box node, it calls Yoga.Node.create() to allocate a corresponding layout node. This is a WASM allocation — it reserves memory in the Yoga WASM heap.
As the React tree is built (via appendChild and insertBefore), the InkNode tree and the Yoga node tree are kept synchronized. Every appendChild call that involves two box nodes also calls yogaParent.insertChild(yogaChild, index). The Yoga tree is always a structural mirror of the InkNode tree.
When removeChild is called (node removal during a re-render), the Yoga child is removed from its parent via yogaParent.removeChild(yogaChild) and then freed via yogaChild.freeRecursive(). Memory management is explicit here because WASM memory is outside the JavaScript garbage collector's reach.
10.3.3 Applying Styles to Yoga Nodes
The applyProps function translates Ink's React-style props into Yoga API calls. The translation is mostly direct:
// src/ink/layout/applyYogaProps.ts — representative mappings
function applyYogaProps(yogaNode: Yoga.Node, style: Style): void {
if (style.flexDirection !== undefined) {
yogaNode.setFlexDirection(
style.flexDirection === 'row'
? Yoga.FLEX_DIRECTION_ROW
: Yoga.FLEX_DIRECTION_COLUMN,
)
}
if (style.width !== undefined) {
if (typeof style.width === 'number') {
yogaNode.setWidth(style.width)
} else if (style.width.endsWith('%')) {
yogaNode.setWidthPercent(parseFloat(style.width))
} else if (style.width === 'auto') {
yogaNode.setWidthAuto()
}
}
if (style.padding !== undefined) {
yogaNode.setPadding(Yoga.EDGE_ALL, style.padding)
}
if (style.gap !== undefined) {
yogaNode.setGap(Yoga.GUTTER_ALL, style.gap)
}
// ... and so on for all CSS Flexbox properties
}A key decision in this translation layer is that all numeric values are interpreted as character cells. There is no unit conversion — width: 10 means 10 columns wide, not 10 pixels. This makes the mapping simpler but means that components must be designed with character-level dimensions in mind.
Percentage widths (width: '50%') work correctly because Yoga computes them relative to the parent's measured width, and the root node's width is set to process.stdout.columns before each layout run. A <Box width="50%"> will always occupy exactly half the terminal width, rounding down to the nearest integer column.
10.3.4 The Layout Calculation
Layout runs once per commit, in computeLayout:
// src/ink/layout/computeLayout.ts
function computeLayout(container: InkContainer): void {
const rootYogaNode = container.yogaNode
// Set the terminal's current width as the root constraint.
// Height is left unconstrained — terminal content scrolls vertically.
rootYogaNode.setWidth(process.stdout.columns)
rootYogaNode.setHeight(Yoga.UNDEFINED) // unbounded
// Ask Yoga to compute all positions and sizes.
// This is a synchronous WASM call — expensive for large trees.
rootYogaNode.calculateLayout(
process.stdout.columns,
Yoga.UNDEFINED,
Yoga.DIRECTION_LTR,
)
// Results are now readable from each node:
// yogaNode.getComputedLeft() — column offset from parent
// yogaNode.getComputedTop() — row offset from parent
// yogaNode.getComputedWidth() — width in columns
// yogaNode.getComputedHeight() — height in rows
}After calculateLayout() returns, every Yoga node in the tree has its computed dimensions available. The rendering stage reads these to determine where in the terminal to emit each piece of content.
The performance characteristic of calculateLayout() is worth understanding. Yoga implements an O(n) layout algorithm for most trees — linear in the number of nodes. Trees with complex wrapping or intrinsic size measurement (nodes whose size depends on their content) can be O(n log n) or O(n²) in pathological cases. For Claude Code's typical message list of dozens of components, the layout pass takes well under a millisecond.
10.3.5 Terminal-Specific Layout Constraints
Several CSS properties that work in browsers have no meaningful terminal equivalent and are either unsupported or mapped to terminal semantics:
position: absolute is not supported. All positioning is flow-based (Flexbox). This is a fundamental limitation of terminal rendering — there is no concept of "layers" or "z-index" in a character cell grid. The closest approximation is to use alternate screen buffers or overlapping ANSI rendering, but the Ink fork does not implement this.
overflow: hidden has a terminal-specific meaning. In the browser it clips content. In the Ink fork, it is used to signal that a node should clip its content to its measured width, which is how truncation is implemented for single-line text overflow.
display: flex is the default for every node. There is no display: block or display: inline — all layout is Flexbox-based. This is both a simplification and a constraint: you cannot mix inline and block elements, but you also never need to think about block formatting contexts.
10.4 The Rendering Pipeline
The pipeline that transforms a React component tree into terminal output has three distinct stages, each with clear inputs and outputs:
10.4.1 Stage 1: React Commit Phase
The commit phase is React's mechanism for applying the results of a reconciliation to the host environment. After comparing the previous fiber tree with the next, React executes a series of host config calls — commitMount, commitUpdate, commitTextUpdate, removeChild, and others — to bring the host's state in line with the new component tree.
In the Ink fork's case, "bringing the host in line" means updating InkNode objects in place. A node whose style.color changed from 'blue' to 'green' will have its style object mutated by commitUpdate. A new <Box> that appears in the tree will have a new InkNode created via createInstance and appended to the correct parent via appendChild.
By the time resetAfterCommit is called, the InkNode tree exactly reflects the React element tree that was just rendered. Stage 1 is complete.
10.4.2 Stage 2: Layout Calculation
Stage 2 is exactly what Section 10.3.4 described: computeLayout sets the terminal width, calls yogaNode.calculateLayout(), and the Yoga WASM module fills in the computed dimensions for every node.
The key point here is that Stage 2 reads the style objects on InkNodes (which were written in Stage 1) and writes the computed layout into the yogaNode results (which Stage 3 will read). The Yoga tree and the InkNode tree are synchronized in structure, but their data flow is one-directional: React props in Stage 1 inform Yoga styles, and Yoga results in Stage 2 inform rendering in Stage 3.
10.4.3 Stage 3: Output Generation
Stage 3 converts the annotated InkNode tree into a string of ANSI escape codes and writes it to stdout. This is the most algorithmically interesting stage.
The output is a two-dimensional character buffer. It is initialized to spaces, with dimensions matching the terminal width and the computed height of the root node. The traversal then "paints" each node's content into the buffer at its computed position:
// src/ink/render/renderToString.ts — conceptual implementation
function renderNode(
node: InkNode | InkTextNode,
output: OutputBuffer,
offsetX: number,
offsetY: number,
): void {
if (node.nodeName === '#text') {
// Write text content at the current offset
output.write(offsetX, offsetY, node.value, {})
return
}
// Compute absolute position using Yoga results
const x = offsetX + node.yogaNode!.getComputedLeft()
const y = offsetY + node.yogaNode!.getComputedTop()
const width = node.yogaNode!.getComputedWidth()
const height = node.yogaNode!.getComputedHeight()
// Render background color if specified
if (node.style.backgroundColor) {
output.fillRect(x, y, width, height, node.style.backgroundColor)
}
// Recurse into children with updated offsets
for (const child of node.childNodes) {
renderNode(child, output, x, y)
}
}The OutputBuffer is a two-dimensional array of Cell objects, where each Cell holds a character value, a foreground color, a background color, and a set of style flags (bold, italic, underline, etc.). This intermediate representation exists precisely to enable differential rendering.
10.4.4 Differential Rendering
Differential rendering is the optimization that makes Claude Code's terminal UI feel fast even when large portions of the message list are updating. The principle is simple: instead of writing a full-screen repaint on every React render, compare the new OutputBuffer with the previous one and emit ANSI sequences only for the cells that changed.
// src/ink/render/diff.ts — row-level diffing
function diff(
prev: OutputBuffer,
next: OutputBuffer,
): Array<{ row: number; startCol: number; endCol: number }> {
const changes: Array<{ row: number; startCol: number; endCol: number }> = []
for (let row = 0; row < next.height; row++) {
let changeStart = -1
let changeEnd = -1
for (let col = 0; col < next.width; col++) {
if (!cellsEqual(prev.getCell(row, col), next.getCell(row, col))) {
if (changeStart === -1) changeStart = col
changeEnd = col
}
}
if (changeStart !== -1) {
changes.push({ row, startCol: changeStart, endCol: changeEnd })
}
}
return changes
}For each changed region, the output stage emits a cursor-movement escape sequence followed by the ANSI-encoded content of the changed cells:
// src/ink/render/writeOutput.ts — ANSI output generation
function writeChanges(
next: OutputBuffer,
changes: Array<{ row: number; startCol: number; endCol: number }>,
): string {
let output = ''
for (const { row, startCol, endCol } of changes) {
// Move cursor to the start of the changed region
// ANSI cursor positioning is 1-indexed
output += `\x1B[${row + 1};${startCol + 1}H`
// Emit each changed cell with its style
for (let col = startCol; col <= endCol; col++) {
const cell = next.getCell(row, col)
output += styleToAnsi(cell) + cell.char
}
// Reset style after each changed region
output += '\x1B[0m'
}
return output
}The ANSI sequences used here are standard:
\x1B[{row};{col}Hmoves the cursor to an absolute position (row and column are both 1-indexed)\x1B[{n}msets a display attribute:0resets all,1is bold,4is underline,30–37are foreground colors,40–47are background colors\x1B[38;5;{n}mselects an 8-bit foreground color (256-color palette)\x1B[38;2;{r};{g};{b}mselects a 24-bit true color foreground
The differential approach means that rendering a single streaming token in a long message adds only the cost of writing a few characters to stdout, not repainting the entire screen. This is what enables smooth token streaming at the UI level.
10.4.5 Cursor Management
During rendering, the terminal cursor must be hidden to prevent flickering. The Ink fork hides the cursor before any output and restores it after:
// Before rendering
process.stdout.write('\x1B[?25l') // hide cursor
// ... write ANSI output ...
// After rendering, restore cursor to bottom of content
process.stdout.write(`\x1B[${lastContentRow + 1};1H`)
process.stdout.write('\x1B[?25h') // show cursorThe cursor is always left at the bottom of the rendered content so that normal terminal output (from subprocesses or error logs not routed through the Ink renderer) appears below the UI rather than overwriting it.
10.5 Terminal I/O: src/ink/termio/
The terminal is bidirectional. The rendering pipeline described above handles output. The src/ink/termio/ module handles input: raw bytes arriving from stdin that must be parsed into semantic events — key presses, mouse events, paste events — and dispatched to the appropriate React component.
10.5.1 Raw Mode
By default, the terminal operates in "cooked mode": input is buffered until the user presses Enter, the OS handles line editing (backspace, Ctrl+U to clear a line), and the application receives a complete line of text. This is appropriate for simple command-line tools but completely wrong for an interactive REPL that needs to respond to individual keystrokes.
The Ink fork enters raw mode as part of initialization:
// src/ink/termio/rawMode.ts
export function enterRawMode(): void {
if (process.stdin.isTTY) {
process.stdin.setRawMode(true)
}
}
export function exitRawMode(): void {
if (process.stdin.isTTY) {
process.stdin.setRawMode(false)
}
}In raw mode, every keypress is delivered immediately as one or more bytes. The application receives raw byte sequences and must parse them itself. This is both more powerful (individual keystrokes are visible) and more demanding (the application must handle what the OS previously handled for free, such as Ctrl+C for process termination).
The Ink fork explicitly handles Ctrl+C in raw mode by restoring the terminal and exiting the process:
// src/ink/termio/inputParser.ts — Ctrl+C handling
if (data.length === 1 && data[0] === 0x03) {
exitRawMode()
process.exit(0)
}10.5.2 The Input Byte Stream
In raw mode, stdin is a byte stream. Single printable characters arrive as one byte. Special keys — arrow keys, function keys, Ctrl combinations, Alt combinations — arrive as multi-byte ANSI escape sequences. The parser must read bytes from stdin, buffer them, and decide when a complete escape sequence has arrived.
The challenge is that the escape character (\x1B, byte value 27) can appear both as the first byte of a multi-byte sequence and as a standalone key (the Escape key itself). The parser uses a timing heuristic: if an \x1B byte is not followed by another byte within a short window (typically 50ms), it is treated as a standalone Escape key press. If more bytes follow, they are consumed as part of an escape sequence.
// src/ink/termio/inputParser.ts — escape sequence detection
let escapeBuffer = ''
let escapeTimer: ReturnType<typeof setTimeout> | null = null
function onData(chunk: Buffer): void {
const str = chunk.toString('utf8')
if (escapeBuffer.length > 0 || str.startsWith('\x1B')) {
escapeBuffer += str
if (escapeTimer) clearTimeout(escapeTimer)
escapeTimer = setTimeout(() => {
// No more bytes arrived — parse whatever we have
parseEscapeSequence(escapeBuffer)
escapeBuffer = ''
escapeTimer = null
}, 50)
// Try to parse a complete known sequence immediately
if (isCompleteSequence(escapeBuffer)) {
clearTimeout(escapeTimer!)
parseEscapeSequence(escapeBuffer)
escapeBuffer = ''
escapeTimer = null
}
} else {
// Not an escape sequence — dispatch directly
dispatchKeyEvent(str)
}
}The lookup table of known sequences covers the common cases:
// src/ink/termio/keySequences.ts — representative entries
const SEQUENCES: Record<string, KeyEvent> = {
'\x1B[A': { key: 'upArrow', ctrl: false, meta: false, shift: false },
'\x1B[B': { key: 'downArrow', ctrl: false, meta: false, shift: false },
'\x1B[C': { key: 'rightArrow',ctrl: false, meta: false, shift: false },
'\x1B[D': { key: 'leftArrow', ctrl: false, meta: false, shift: false },
'\x1B[H': { key: 'home', ctrl: false, meta: false, shift: false },
'\x1B[F': { key: 'end', ctrl: false, meta: false, shift: false },
'\x1B[1;2A':{ key: 'upArrow', ctrl: false, meta: false, shift: true },
'\x1BOA': { key: 'upArrow', ctrl: false, meta: false, shift: false }, // alt sequence
'\x1B[5~': { key: 'pageUp', ctrl: false, meta: false, shift: false },
'\x1B[6~': { key: 'pageDown', ctrl: false, meta: false, shift: false },
'\x7F': { key: 'backspace', ctrl: false, meta: false, shift: false },
'\r': { key: 'return', ctrl: false, meta: false, shift: false },
'\t': { key: 'tab', ctrl: false, meta: false, shift: false },
'\x1B[Z': { key: 'tab', ctrl: false, meta: false, shift: true }, // shift+tab
}Terminal sequences are not standardized across terminal emulators. The iTerm2 sequences for modified arrow keys differ from the xterm sequences, which differ from the VT100 sequences. The Ink fork includes sequences for the major terminal families and falls back gracefully for unrecognized sequences.
10.5.3 Bracketed Paste Mode
Bracketed paste mode is an extension that wraps pasted content in a pair of escape sequences, allowing the application to distinguish a paste from manual typing. Without it, pasting a multi-line string would trigger repeated submit events (one per newline), which is catastrophically wrong for a REPL.
The Ink fork enables bracketed paste mode on entry:
// src/ink/termio/pasteMode.ts
export function enableBracketedPaste(): void {
process.stdout.write('\x1B[?2004h')
}
export function disableBracketedPaste(): void {
process.stdout.write('\x1B[?2004l')
}When a paste arrives, the input stream contains \x1B[200~{pasted content}\x1B[201~. The parser detects these markers and emits a single PasteEvent with the complete pasted content, rather than dispatching each character or line separately:
// src/ink/termio/inputParser.ts — paste detection
let inBracketedPaste = false
let pasteBuffer = ''
if (escapeBuffer.startsWith('\x1B[200~')) {
inBracketedPaste = true
pasteBuffer = ''
escapeBuffer = escapeBuffer.slice('\x1B[200~'.length)
}
if (inBracketedPaste) {
const endMarker = escapeBuffer.indexOf('\x1B[201~')
if (endMarker !== -1) {
pasteBuffer += escapeBuffer.slice(0, endMarker)
inBracketedPaste = false
dispatchPasteEvent(pasteBuffer)
pasteBuffer = ''
} else {
pasteBuffer += escapeBuffer
escapeBuffer = ''
}
}10.5.4 Mouse Events
Mouse support is enabled by writing \x1B[?1000h to stdout (for basic button tracking) or \x1B[?1003h (for all-motion tracking, needed for scroll events). The Ink fork enables mouse tracking at initialization:
// src/ink/termio/mouseMode.ts
export function enableMouse(): void {
// Enable all-motion mouse tracking
process.stdout.write('\x1B[?1003h')
// Use SGR extended mouse protocol for coordinates > 223
process.stdout.write('\x1B[?1006h')
}The SGR extended protocol (\x1B[?1006h) is important because the classic X10 protocol encodes mouse coordinates as single bytes offset from 32 (' '), which limits coordinates to columns/rows below 223. Modern terminals support much larger sizes, and the SGR protocol uses decimal numbers to eliminate this constraint.
In the SGR protocol, a mouse event arrives as \x1B[<{buttons};{col};{row}{type} where type is M for press and m for release:
// src/ink/termio/inputParser.ts — SGR mouse parsing
const sgrMousePattern = /\x1B\[<(\d+);(\d+);(\d+)([Mm])/
function parseSgrMouse(seq: string): MouseEvent | null {
const match = seq.match(sgrMousePattern)
if (!match) return null
const buttons = parseInt(match[1]!, 10)
const col = parseInt(match[2]!, 10) - 1 // convert to 0-indexed
const row = parseInt(match[3]!, 10) - 1 // convert to 0-indexed
const release = match[4] === 'm'
return {
x: col,
y: row,
button: buttons & 0x03, // bits 0-1: which button
ctrl: !!(buttons & 0x10), // bit 4: ctrl modifier
shift: !!(buttons & 0x04), // bit 2: shift modifier
meta: !!(buttons & 0x08), // bit 3: meta/alt modifier
scroll: !!(buttons & 0x40), // bit 6: scroll event
scrollUp: !!(buttons & 0x40) && !(buttons & 0x01),
release,
}
}Mouse events are dispatched to the React tree as a separate event stream that components can subscribe to via the useMouse() hook. The REPL uses mouse scroll events to trigger virtual scrolling in the message list (see Section 10.6.2).
10.6 Focus Management, Virtual Scrolling, and Text Wrapping
10.6.1 Focus Management
Focus management in terminal UIs serves the same purpose as document.activeElement in browsers: it determines which component receives keyboard input at any given moment. In Claude Code, the two primary focusable regions are the message input area and any interactive widget (such as a permission dialog or a selection menu) that temporarily captures input.
The Ink fork implements focus as a global registry with a controlled Tab cycling mechanism. The registry is maintained in a React context (FocusContext) that is provided at the root of the tree:
// src/ink/focus/useFocusManager.ts — the registry structure
type FocusManager = {
focusables: Array<{ id: string; autoFocus: boolean }>
activeId: string | null
register: (id: string, options: { autoFocus: boolean }) => () => void
focus: (id: string) => void
focusNext: () => void
focusPrevious: () => void
}Components that want to be focusable call useFocus():
// src/ink/focus/useFocus.ts
export function useFocus(options: { autoFocus?: boolean } = {}): FocusState {
const id = useStableId()
const manager = useFocusManager()
useEffect(() => {
// Register with the manager; the returned function unregisters on unmount
return manager.register(id, { autoFocus: options.autoFocus ?? false })
}, [id, manager, options.autoFocus])
return {
isFocused: manager.activeId === id,
focus: () => manager.focus(id),
}
}useStableId() generates a stable unique identifier per component instance — it uses a counter incremented at mount time and preserved across re-renders via useRef. This id is what allows the focus manager to track which specific component instance is focused, not just which component type.
Tab key presses are intercepted at the root level by the focus manager. When the focus manager sees a Tab key event, it calls focusNext(), which advances the activeId to the next registered focusable in the order they were registered (which corresponds to their visual order in the tree, since registration happens during the React render-phase layout effect):
// src/ink/focus/useFocusManager.ts
function focusNext(): void {
if (focusables.length === 0) return
const currentIndex = focusables.findIndex(f => f.id === activeId)
const nextIndex = (currentIndex + 1) % focusables.length
setActiveId(focusables[nextIndex]!.id)
}Shift+Tab calls focusPrevious(), which decrements the index with wraparound. The useInput() hook, used by focusable components to handle keyboard events, automatically checks whether the component is focused before delivering events:
// src/ink/hooks/useInput.ts
export function useInput(
inputHandler: (input: string, key: KeyEvent) => void,
options: { isActive?: boolean } = {},
): void {
const { isFocused } = useFocus()
const isActive = options.isActive ?? isFocused
useEffect(() => {
if (!isActive) return
return subscribeToKeyEvents((input, key) => {
inputHandler(input, key)
})
}, [inputHandler, isActive])
}This design ensures that keyboard events are only delivered to the focused component, without requiring any centralized routing logic in the input parser. The parser dispatches all key events to all subscribers; the useInput hook filters them based on focus state.
10.6.2 Virtual Scrolling
Claude Code's message list grows without bound during a long session. A conversation with hundreds of messages cannot be rendered in its entirety on a terminal with 50 rows — even if Yoga could compute the layout (it can), the output would extend far below the visible area. Virtual scrolling addresses this by rendering only the messages that fit in the current viewport.
The implementation is a standard virtualization pattern adapted to the terminal's character-grid model.
The first step is height measurement. Every message has a computed Yoga height — the number of terminal rows it occupies. The virtual scroller maintains a list of these heights, accumulated into a prefix sum array for O(1) offset lookups:
// src/ink/components/VirtualScroller.ts — height accounting
type VirtualScrollState = {
itemHeights: number[] // measured height of each item
prefixSums: number[] // prefixSums[i] = sum of heights[0..i-1]
totalHeight: number // sum of all item heights
scrollOffset: number // current scroll position in rows
viewportHeight: number // number of visible rows
}
function computeVisibleRange(state: VirtualScrollState): [number, number] {
const { prefixSums, scrollOffset, viewportHeight, itemHeights } = state
// Binary search for first item that starts at or after the scroll offset
let startIndex = binarySearchLowerBound(prefixSums, scrollOffset)
// Find the last item that fits within the viewport
const visibleEnd = scrollOffset + viewportHeight
let endIndex = startIndex
while (endIndex < itemHeights.length && prefixSums[endIndex]! < visibleEnd) {
endIndex++
}
return [startIndex, Math.min(endIndex, itemHeights.length - 1)]
}The scroller renders only the items in [startIndex, endIndex], passing a marginTop offset to the first visible item so that it appears at the correct row within the viewport. Items outside this range are not rendered at all — they have no React nodes, no Yoga nodes, and consume no layout or rendering resources.
When the user scrolls (via mouse scroll events or keyboard Page Up/Page Down), the scrollOffset is updated, computeVisibleRange returns a new range, and React re-renders the visible slice:
// src/ink/components/VirtualScroller.ts — scroll event handler
function handleScroll(event: MouseEvent): void {
if (event.scrollUp) {
setScrollOffset(prev => Math.max(0, prev - SCROLL_STEP))
} else {
setScrollOffset(prev =>
Math.min(state.totalHeight - state.viewportHeight, prev + SCROLL_STEP),
)
}
}The scroller always scrolls to the bottom on new messages (following the "live tail" behavior expected of a chat interface). This is implemented by a useEffect that runs after each append to the message list:
useEffect(() => {
setScrollOffset(Math.max(0, state.totalHeight - state.viewportHeight))
}, [messageCount])10.6.3 Text Wrapping
The terminal does not perform text wrapping automatically. A string of 200 characters written to a 80-column terminal without explicit wrapping will either overflow (some terminals truncate, some wrap at a hardware level that ignores ANSI styles), or more typically, simply extend beyond the visible area. Ink must handle wrapping explicitly.
The wrapping logic lives in src/ink/text/wrapText.ts and is applied during the rendering stage, before content is written to the OutputBuffer.
For wrap="wrap" (the default), the available width is computed from the Yoga layout result of the containing node. The text is then split into segments that fit within that width:
// src/ink/text/wrapText.ts — character-level wrapping
function wrapText(text: string, availableWidth: number): string[] {
if (availableWidth <= 0) return [text]
const lines: string[] = []
let currentLine = ''
let currentWidth = 0
for (const char of text) {
const charWidth = getCharWidth(char) // 1 for ASCII, 2 for CJK
if (currentWidth + charWidth > availableWidth) {
lines.push(currentLine)
currentLine = char
currentWidth = charWidth
} else {
currentLine += char
currentWidth += charWidth
}
}
if (currentLine.length > 0) {
lines.push(currentLine)
}
return lines
}The getCharWidth(char) function is the CJK handling hook. Characters in the CJK Unified Ideographs range, Hangul, full-width Latin, and several other Unicode blocks occupy two columns in the terminal's character grid. A naive implementation that treats every character as one column wide will produce misaligned layout when CJK characters are present.
The Ink fork uses the wcwidth algorithm (derived from the POSIX wcswidth standard) to determine the display width of each character:
// src/ink/text/charWidth.ts — simplified excerpt
function getCharWidth(char: string): 1 | 2 {
const cp = char.codePointAt(0)!
// CJK Unified Ideographs: U+4E00–U+9FFF
if (cp >= 0x4E00 && cp <= 0x9FFF) return 2
// CJK Extension A: U+3400–U+4DBF
if (cp >= 0x3400 && cp <= 0x4DBF) return 2
// Hangul Syllables: U+AC00–U+D7A3
if (cp >= 0xAC00 && cp <= 0xD7A3) return 2
// Fullwidth Latin: U+FF01–U+FF60
if (cp >= 0xFF01 && cp <= 0xFF60) return 2
// ... additional ranges
return 1
}For wrap="truncate", the behavior is different. Instead of wrapping onto multiple lines, text beyond the available width is cut off and an ellipsis character is appended:
// src/ink/text/truncateText.ts
function truncateText(text: string, availableWidth: number): string {
if (availableWidth <= 0) return ''
let width = 0
let result = ''
for (const char of text) {
const charWidth = getCharWidth(char)
if (width + charWidth + 1 > availableWidth) {
// +1 reserves space for the ellipsis
return result + '…'
}
result += char
width += charWidth
}
return result
}The wrap="truncate-middle" variant (which truncates the middle of the string rather than the end) follows the same pattern but splits the available width between a prefix and a suffix.
10.7 Component Primitives: Box, Text, and Beyond
The component primitives are the public API of the Ink framework. Application code never interacts with InkNode objects directly — it uses JSX components that are compiled by the reconciler into InkNode trees.
10.7.1 Box
Box is the fundamental layout container. It maps to ink-box in the InkNode tree and always has a corresponding Yoga.Node. Every layout property — flex direction, alignment, justification, gap, padding, margin, width, height — is expressed through Box:
// Typical Box usage in Claude Code's UI
<Box flexDirection="column" gap={1} paddingX={2} width="100%">
<Box flexDirection="row" justifyContent="space-between">
<Text bold>Session ID</Text>
<Text color="gray">{sessionId}</Text>
</Box>
<Box flexDirection="column" borderStyle="single" padding={1}>
{messages.map(msg => <MessageCard key={msg.id} message={msg} />)}
</Box>
</Box>Box accepts a borderStyle prop that draws ASCII or Unicode box-drawing characters around the node's boundary. The available styles are single (standard box-drawing: ┌─┐│└─┘), double (╔═╗║╚═╝), round (╭─╮│╰─╯), bold (┏━┓┃┗━┛), and classic (ASCII: +-+|+-+). Border drawing happens in the rendering stage, not in Yoga — the border characters are written to the OutputBuffer at the computed boundary positions, and the Yoga padding is pre-increased to account for the border thickness.
The Box component's TypeScript interface is extensive but consistent with the CSS Flexbox model:
// src/ink/components/Box.tsx — abbreviated interface
type BoxProps = {
// Layout
flexDirection?: 'row' | 'column' | 'row-reverse' | 'column-reverse'
flexWrap?: 'wrap' | 'nowrap' | 'wrap-reverse'
flexGrow?: number
flexShrink?: number
flexBasis?: number | string
alignItems?: 'flex-start' | 'flex-end' | 'center' | 'stretch' | 'baseline'
alignSelf?: 'flex-start' | 'flex-end' | 'center' | 'stretch' | 'auto'
justifyContent?: 'flex-start' | 'flex-end' | 'center' | 'space-between' | 'space-around'
gap?: number
rowGap?: number
columnGap?: number
// Sizing
width?: number | string
height?: number | string
minWidth?: number | string
maxWidth?: number | string
// Spacing
padding?: number
paddingX?: number
paddingY?: number
paddingTop?: number
paddingBottom?: number
paddingLeft?: number
paddingRight?: number
margin?: number
// Visual
borderStyle?: 'single' | 'double' | 'round' | 'bold' | 'classic' | 'singleDouble' | 'doubleSingle'
borderColor?: string
backgroundColor?: string
// Overflow
overflow?: 'hidden' | 'visible'
}10.7.2 Text
Text is the primitive for displaying styled character content. It maps to ink-text in the InkNode tree and, unlike Box, does not own a Yoga.Node directly — its size is determined by its parent Box's layout.
// Text usage examples
<Text bold color="cyan">Headline</Text>
<Text dimColor>Secondary information</Text>
<Text color="#ff6b6b" underline>Error message</Text>
<Text wrap="truncate">{longPath}</Text>
<Text italic strikethrough>Deprecated</Text>The color system accepts named colors from a set of 16 terminal standard colors, 8-bit color names from Chalk's color list, and 24-bit hex colors (#rrggbb). The rendering stage converts each color specification to the appropriate ANSI escape sequence based on the terminal's detected color support level. If the terminal reports 256-color support, hex colors are quantized to the nearest 256-color palette entry. If it reports true color (24-bit) support, hex colors are emitted as-is using the \x1B[38;2;r;g;bm sequence.
The wrap prop accepts the same values described in Section 10.6.3: "wrap" for word/character wrapping, "truncate" for end truncation with ellipsis, "truncate-start" for beginning truncation, and "truncate-middle" for middle truncation. The default is "wrap".
Text also supports nested styling via JSX composition:
// Nested Text styling
<Text>
Normal{' '}
<Text bold>bold</Text>
{' '}and{' '}
<Text color="green">colored</Text>
{' '}content
</Text>The rendering stage handles nested Text nodes by maintaining a style stack. When it encounters a child text node, it pushes the parent's style onto the stack, applies the child's style on top, renders the content, and then pops back. The ANSI output for nested styles uses full attribute resets (\x1B[0m) followed by the complete style of the surrounding context to avoid ANSI escape sequence accumulation errors.
10.7.3 Custom Terminal Components Built on Primitives
Claude Code builds a rich set of application-specific components on top of Box and Text. These are not part of the Ink fork itself — they live in src/components/ — but their design illustrates how the primitives compose.
The PermissionRequest component, for example, uses a Box with borderStyle="round" and borderColor="yellow" to draw a warning panel, nested Boxes for the content layout, and Text components with specific colors and weights for the different parts of the permission message. It uses useInput() to capture y/n keystrokes and useFocus() to ensure it captures those strokes only while it is the active dialog.
The Spinner component uses useEffect to advance a rotating character (⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏) on a 100ms interval, storing the current frame index in useState. Each frame update triggers a React re-render, which triggers a layout computation, which triggers a differential output that writes only the single changed character. The full overhead of the rendering pipeline for a spinner update is one React state change and approximately two ANSI escape sequences.
The ProgressBar component renders a horizontal bar of filled and unfilled characters within a Box of known width. The width prop of the containing Box, available after Yoga layout, determines how many filled characters to render. This is an example of a component that intentionally reads Yoga layout results (via a ref set to the container Box's computed width) to drive its content — the opposite of the usual direction in which components express size preferences to Yoga and Yoga tells them their actual size.
10.8 Lifecycle Integration: How the Ink Framework Starts and Stops
The Ink framework's lifecycle is tightly coupled to Claude Code's startup sequence (described in Chapter 2). Understanding this coupling is important for anyone debugging rendering issues or extending the UI.
10.8.1 Framework Initialization
The framework is initialized in src/replLauncher.tsx via the renderAndRun function. This function:
- Creates the Ink container (the
InkContainerroot that the reconciler operates on) - Creates the reconciler's fiber root via
createContainer - Enters raw mode on stdin
- Enables bracketed paste mode
- Enables mouse tracking
- Registers resize handlers for
process.stdout'sresizeevent - Calls
updateContainerto perform the initial React render - Returns a cleanup function that reverses all of the above
The resize handler is worth highlighting. When the terminal window is resized, process.stdout.columns and process.stdout.rows change. The handler responds by triggering a full re-render (which forces Yoga to recalculate layout with the new terminal width) and a full-screen repaint (which clears any stale content and repaints from scratch):
// src/ink/index.ts — resize handling
process.stdout.on('resize', () => {
// Clear the entire screen to prevent stale content at new sizes
process.stdout.write('\x1B[2J\x1B[H')
// Force a full repaint by invalidating the diff buffer
outputBuffer.invalidate()
// Trigger a React re-render, which will recompute Yoga layout
// with the new process.stdout.columns value
reconciler.updateContainer(currentElement, container, null, null)
})The outputBuffer.invalidate() call is important. Without it, the differential renderer would compare the new output against the previous frame's buffer (which was computed for a different terminal width) and produce incorrect diffs. Invalidating the buffer forces a full repaint on the next render, which is the correct behavior after a resize.
10.8.2 Clean Shutdown
When Claude Code exits (via a user command, process termination, or uncaught exception), the Ink framework must clean up the terminal state. Failing to do so leaves the terminal in raw mode, with the cursor hidden and mouse tracking enabled — a completely broken state for the user.
The cleanup function returned by renderAndRun is registered in three places:
- As a
SIGTERMsignal handler - As a
SIGINTsignal handler (in addition to the Ctrl+C raw mode handler described earlier) - Via
process.on('exit')for cases where the process exits without a signal
Cleanup performs: exit raw mode, disable bracketed paste, disable mouse tracking, show the cursor, and write a final newline so the shell prompt appears on a fresh line.
The setupGracefulShutdown call in src/entrypoints/init.ts (mentioned in Chapter 2) works in coordination with this — it ensures that any in-flight async operations are given a short window to complete before the Ink cleanup runs.
Key Takeaways
The decision to fork Ink rather than depend on the upstream package was driven by four concrete production requirements: differential rendering for smooth token streaming, Bun/WASM compatibility for Yoga layout, controlled raw mode lifecycle for child process integration, and bracketed paste handling for correct multi-line input. These are not speculative improvements — each addresses a real failure mode observed in the upstream library.
The React reconciler at src/ink/reconciler.ts is the foundation of the entire system. It implements the react-reconciler host config interface, translating React's commit-phase operations into mutations on a tree of InkNode objects. The commit phase ends with resetAfterCommit, which triggers the three-stage rendering pipeline.
Yoga WASM provides CSS Flexbox layout in a terminal context. The key insight is that all dimensions are in character cells, terminal width is read from process.stdout.columns at render time, and height is left unbounded. The structural synchronization between the InkNode tree and the Yoga node tree — maintained by appendChild, insertBefore, and removeChild — ensures that layout is always computed on a structurally correct tree.
The differential renderer is what makes the UI performant. By maintaining an OutputBuffer from the previous frame and comparing it cell-by-cell with the new frame, the output stage emits only the ANSI sequences needed to update the changed cells. For streaming token output, this means writing a handful of characters per token rather than a full-screen repaint.
The termio layer handles all the complexities of raw terminal input: the 50ms heuristic for distinguishing the Escape key from the beginning of an escape sequence, the bracketed paste mode wrapper that prevents multi-line pastes from triggering premature submit, and the SGR extended mouse protocol that correctly handles coordinates on wide terminals.
Focus management, virtual scrolling, and CJK-aware text wrapping are higher-level concerns built on the primitives, but they follow directly from the same core insight: a terminal UI must explicitly manage everything that a browser handles automatically. The Ink fork provides the infrastructure; the application components in src/components/ use it to build the full interactive REPL experience.
The next chapter examines the REPL itself — src/screens/REPL.tsx and its surrounding component tree — which is the application layer that consumes this framework. Chapter 11 explains how the message list is structured, how the input area manages multi-line editing, how streaming token output is displayed, and how the REPL coordinates between user interaction and the agentic loop through the state architecture described in Chapter 4.