claude-code/spec/08_ink_terminal.md
2026-04-01 01:20:27 +05:30

75 KiB
Raw Permalink Blame History

Claude Code — Ink Terminal Rendering System

Overview

The ink directory contains a complete, custom terminal UI framework built on top of React. It is a heavily modified and extended fork of the open-source Ink library, tuned for Claude Code's requirements: fullscreen alternate-screen rendering, hardware-accelerated scroll, text selection, search highlighting, mouse tracking, bidirectional text, and fine-grained performance instrumentation.

The system can be summarized as a pipeline:

React tree
    → React Reconciler (reconciler.ts)
    → Virtual DOM (dom.ts)
    → Yoga layout engine (layout/)
    → Output buffer (output.ts)
    → Screen cell buffer (screen.ts)
    → Diff engine (log-update.ts)
    → Patch optimizer (optimizer.ts)
    → Terminal write (terminal.ts / termio/)

The top-level entry point for consumers is /x/Bigger-Projects/Claude-Code/src/ink.ts, which wraps the internal root.ts render and createRoot APIs with a mandatory ThemeProvider.


File-by-File Reference

/x/Bigger-Projects/Claude-Code/src/ink.ts — Public API Module

Purpose: The package-level façade that re-exports all public APIs from the ink subsystem. Wraps every render() and createRoot() call in a ThemeProvider so that ThemedBox/ThemedText components work at every call site without requiring consumers to mount the provider manually.

Exports:

  • render(node, options?) — async, mounts a React tree wrapped in ThemeProvider; returns Instance
  • createRoot(options?) — async; returns a Root whose .render() method also wraps in ThemeProvider
  • RenderOptions, Instance, Root — type re-exports from root.ts
  • color — from the design-system color module
  • Box, BoxProps — themed box (design-system ThemedBox)
  • Text, TextProps — themed text (design-system ThemedText)
  • ThemeProvider, usePreviewTheme, useTheme, useThemeSetting
  • Ansi — ANSI-string rendering component
  • BaseBox, BaseBoxProps — raw ink Box without theme
  • BaseText, BaseTextProps — raw ink Text without theme
  • Button, ButtonProps, ButtonState
  • Link, LinkProps
  • Newline, NewlineProps
  • NoSelect
  • RawAnsi
  • Spacer
  • DOMElement — the virtual DOM element type
  • ClickEvent, EventEmitter, Event, Key, InputEvent
  • TerminalFocusEvent, TerminalFocusEventType
  • FocusManager
  • FlickerReason
  • useAnimationFrame, useApp, useInput, useAnimationTimer, useInterval
  • useSelection, useStdin, useTabStatus, useTerminalFocus
  • useTerminalTitle, useTerminalViewport
  • measureElement
  • supportsTabStatus
  • wrapText

/x/Bigger-Projects/Claude-Code/src/ink/constants.ts

Purpose: Shared timing constant.

Exports:

  • FRAME_INTERVAL_MS = 16 — target frame interval (~60 fps), used by the throttled scheduleRender.

/x/Bigger-Projects/Claude-Code/src/ink/ink.tsx — Core Ink Class

Purpose: The central orchestrator. class Ink owns the React fiber root, the yoga layout tree, the double-buffered screen, the focus manager, stdin/stdout event handlers, selection state, and the main render loop.

Class Ink

Constructor receives Options:

type Options = {
  stdout: NodeJS.WriteStream
  stdin: NodeJS.ReadStream
  stderr: NodeJS.WriteStream
  exitOnCtrlC: boolean
  patchConsole: boolean
  waitUntilExit?: () => Promise<void>
  onFrame?: (event: FrameEvent) => void
}

Key private state:

Field Type Description
log LogUpdate Diff engine that writes ANSI to stdout
terminal Terminal { stdout, stderr } write streams
scheduleRender throttled fn Throttled at 16 ms, leading+trailing, deferred via queueMicrotask
container FiberRoot React-reconciler fiber root (ConcurrentRoot mode)
rootNode DOMElement The ink-root DOM node
focusManager FocusManager DOM-style focus state machine
renderer Renderer createRenderer() closure
stylePool StylePool Session-lived ANSI style interning pool
charPool CharPool Session-lived character interning pool
hyperlinkPool HyperlinkPool Session-lived hyperlink URL interning pool
frontFrame / backFrame Frame Double-buffered screen frames
selection SelectionState Text selection for alt-screen mode
searchHighlightQuery string Current /search term
searchPositions object or null Pre-scanned match positions for current-match highlight
altScreenActive boolean Set by <AlternateScreen>
prevFrameContaminated boolean Forces full repaint next frame

Render loop (onRender):

  1. Run createRenderer() — walks DOM, runs yoga, fills back-buffer screen
  2. Apply selection overlay (invert styled cells in selection)
  3. Apply search highlight (invert cells matching searchHighlightQuery)
  4. Apply positioned highlight (yellow/bold current-match via searchPositions)
  5. Diff back-frame vs. front-frame via log.render()Patch[]
  6. Run optimize(patches) to merge/deduplicate
  7. Call writeDiffToTerminal() to serialize patches and write ANSI to stdout
  8. Emit onFrame event if wired
  9. Swap front/back frames

Alt-screen handling:

  • setAltScreenActive(active, mouseTracking) — called by <AlternateScreen> during insertion effects; enables BSU/ESU synchronized output, DECSTBM hardware scroll hints, and selection-aware repaints
  • resetFramesForAltScreen() — replaces both frames with blank screens, sets prevFrameContaminated = true
  • reenterAltScreen() — re-asserts alt-screen state on SIGCONT

Resize handling (handleResize):

  • Synchronous (no debounce) to keep terminalColumns/terminalRows and yoga in sync
  • For alt-screen: resets frame buffers and sets needsEraseBeforePaint = true so the erase happens atomically inside the next BSU/ESU block

Console patching:

  • patchConsole() — intercepts console.log/warn/error so they write to a separate file descriptor (not stdout), preventing output mixing
  • patchStderr() — same for stderr

Key public methods:

  • render(node) — calls reconciler.updateContainer()
  • unmount() — graceful teardown: restore console, disable mouse tracking, exit alt screen, write final frame, free yoga nodes
  • waitUntilExit() — returns a promise resolved on unmount()
  • clearTextSelection() — clear selection state and force repaint
  • setSearchHighlight(query) — set live search term
  • setSearchPositions(positions, rowOffset, currentIdx) — set positioned highlights for search navigation

/x/Bigger-Projects/Claude-Code/src/ink/root.ts — Public Entry Points

Purpose: Wraps Ink in the public render() and createRoot() APIs; manages the instances map so repeated calls to render() reuse the same Ink instance for the same stdout stream.

Exports:

type RenderOptions = {
  stdout?: NodeJS.WriteStream
  stdin?: NodeJS.ReadStream
  stderr?: NodeJS.WriteStream
  exitOnCtrlC?: boolean
  patchConsole?: boolean
  onFrame?: (event: FrameEvent) => void
}

type Instance = {
  rerender: Ink['render']
  unmount: Ink['unmount']
  waitUntilExit: Ink['waitUntilExit']
  cleanup: () => void
}

type Root = {
  render: (node: ReactNode) => void
  unmount: () => void
  waitUntilExit: () => Promise<void>
}

export const renderSync(node, options?): Instance   // synchronous mount
export default async function render(node, options?): Promise<Instance>
export async function createRoot(options?): Promise<Root>

renderSync is used internally; the public-facing render and createRoot are the async versions exported through ink.ts.


/x/Bigger-Projects/Claude-Code/src/ink/instances.ts

Purpose: Module-level singleton map keyed by NodeJS.WriteStream. Ensures one Ink instance per stdout stream.

const instances = new Map<NodeJS.WriteStream, Ink>()
export default instances

DOM Layer

/x/Bigger-Projects/Claude-Code/src/ink/dom.ts — Virtual DOM

Purpose: Defines the virtual DOM node types and all mutation operations. Mirrors a minimal browser DOM API adapted for a terminal. Every mutation marks the affected node and all ancestors dirty so the render pass can skip clean subtrees.

Element name types:

type ElementNames =
  | 'ink-root'      // Document root; owns FocusManager
  | 'ink-box'       // Flex container (yoga node)
  | 'ink-text'      // Leaf text container (yoga measure func)
  | 'ink-virtual-text' // Inline text, no yoga node
  | 'ink-link'      // OSC 8 hyperlink wrapper, no yoga node
  | 'ink-progress'  // Terminal progress indicator, no yoga node
  | 'ink-raw-ansi'  // Pre-rendered ANSI, yoga node with rawWidth/rawHeight

type TextName = '#text'
type NodeNames = ElementNames | TextName

DOMElement structure:

type DOMElement = {
  nodeName: ElementNames
  attributes: Record<string, DOMNodeAttribute>
  childNodes: DOMNode[]
  textStyles?: TextStyles
  onComputeLayout?: () => void   // Called by reconciler.resetAfterCommit
  onRender?: () => void           // Points to throttled scheduleRender
  onImmediateRender?: () => void  // Synchronous, for tests
  hasRenderedContent?: boolean    // React 19 test-mode guard
  dirty: boolean
  isHidden?: boolean
  _eventHandlers?: Record<string, unknown>  // Event handlers (separated from attrs)
  // Scroll state (overflow: scroll boxes):
  scrollTop?: number
  pendingScrollDelta?: number
  scrollClampMin?: number
  scrollClampMax?: number
  scrollHeight?: number
  scrollViewportHeight?: number
  scrollViewportTop?: number
  stickyScroll?: boolean
  scrollAnchor?: { el: DOMElement; offset: number }
  focusManager?: FocusManager    // Only on ink-root
  debugOwnerChain?: string[]     // CLAUDE_CODE_DEBUG_REPAINTS mode
  yogaNode?: LayoutNode
  style: Styles
  parentNode: DOMElement | undefined
}

Exported functions:

  • createNode(nodeName) — allocates a DOMElement; attaches a yoga measure function for ink-text and ink-raw-ansi
  • createTextNode(text) — allocates a TextNode
  • appendChildNode(node, childNode) — appends child, syncs yoga tree, marks dirty
  • insertBeforeNode(node, newChild, beforeChild) — inserts before; yoga index computed separately from DOM index because some nodes lack yoga nodes
  • removeChildNode(node, removeNode) — removes child, collects pendingClears, marks dirty
  • setAttribute(node, key, value) — sets attribute only if changed; skips children
  • setStyle(node, style) — shallow-compares style objects; skips if unchanged
  • setTextStyles(node, textStyles) — shallow-compares; skips if unchanged
  • setTextNodeValue(node, text) — updates text, marks dirty
  • markDirty(node?) — walks the ancestor chain setting dirty = true; marks yoga dirty on ink-text/ink-raw-ansi leaf nodes
  • scheduleRenderFrom(node?) — walks to root and calls onRender()
  • clearYogaNodeReferences(node) — recursively clears yogaNode pointers (call before freeRecursive())
  • findOwnerChainAtRow(root, y) — DFS to find the React component stack covering screen row y (debug repaints mode)

Dirty-checking optimization: stylesEqual and shallowEqual prevent marking dirty when React creates a new style object with identical values on every render.

Yoga index vs DOM index: Nodes like ink-virtual-text, ink-link, and ink-progress have no yoga node. insertBeforeNode counts only yoga-equipped children to compute the correct yoga insertion index.


Reconciler

/x/Bigger-Projects/Claude-Code/src/ink/reconciler.ts

Purpose: Configures react-reconciler to use the ink virtual DOM as the host environment. This is the bridge between React's fiber tree and the ink DOM tree.

Reconciler type parameters:

createReconciler<
  ElementNames,   // Type
  Props,          // Props
  DOMElement,     // Container
  DOMElement,     // Instance
  TextNode,       // TextInstance
  DOMElement,     // SuspenseInstance
  unknown,        // HydratableInstance
  unknown,        // PublicInstance
  DOMElement,     // HostContext (root)
  HostContext,    // ChildSet
  null,           // UpdatePayload (unused in React 19)
  NodeJS.Timeout, // TimeoutHandle
  -1,             // NoTimeout
  null            // TransitionStatus
>

Key reconciler methods:

Method Behavior
createInstance(type, props, rootContainer, context, fiber) Calls createNode(type); applies all props via applyProp; optionally captures debugOwnerChain from fiber
createTextInstance(text) Calls createTextNode(text)
appendInitialChild / appendChild Calls appendChildNode
insertBefore Calls insertBeforeNode
removeChild Calls removeChildNode; notifies focusManager.handleNodeRemoved
commitUpdate(instance, updatePayload, type, oldProps, newProps) Diffs old/new props and applies changes; uses diff() to find changed keys
commitTextUpdate(textInstance, oldText, newText) Calls setTextNodeValue
hideInstance(instance) / unhideInstance(instance) Sets isHidden and LayoutDisplay.None / restores display
prepareForCommit Records timing start
resetAfterCommit(rootNode) Records commit duration; calls onComputeLayout() (yoga layout); triggers onImmediateRender in test mode; calls onRender() in production
commitMount(instance, type, props) Calls focusManager.focus() if autoFocus prop set

applyProp(node, key, value): Routes to setStyle (key=style), setTextStyles (key=textStyles), setEventHandler (key in EVENT_HANDLER_PROPS), or setAttribute.

Event handler separation: Event handler props are stored in node._eventHandlers rather than node.attributes. This prevents handler identity changes from marking the node dirty and defeating the blit optimization.

getOwnerChain(fiber): Walks the React fiber's _debugOwner / return chain to collect component names. Used for CLAUDE_CODE_DEBUG_REPAINTS mode to attribute flicker to source components.

Profiling exports:

  • recordYogaMs(ms) / getLastYogaMs() — yoga layout timing
  • markCommitStart() / getLastCommitMs() — React commit timing
  • resetProfileCounters()
  • dispatcher — the Dispatcher instance (event dispatch)
  • isDebugRepaintsEnabled() — reads CLAUDE_CODE_DEBUG_REPAINTS env var once

Layout Engine

/x/Bigger-Projects/Claude-Code/src/ink/layout/node.ts — Layout Node Interface

Purpose: Abstract interface for a layout node. Decouples the ink DOM from the concrete Yoga WASM implementation.

Constants:

LayoutEdge: { All, Horizontal, Vertical, Left, Right, Top, Bottom, Start, End }
LayoutGutter: { All, Column, Row }
LayoutDisplay: { Flex, None }
LayoutFlexDirection: { Row, RowReverse, Column, ColumnReverse }
LayoutAlign: { Auto, Stretch, FlexStart, Center, FlexEnd }
LayoutJustify: { FlexStart, Center, FlexEnd, SpaceBetween, SpaceAround, SpaceEvenly }
LayoutWrap: { NoWrap, Wrap, WrapReverse }
LayoutPositionType: { Relative, Absolute }
LayoutOverflow: { Visible, Hidden, Scroll }
LayoutMeasureMode: { Undefined, Exactly, AtMost }

LayoutNode interface (abbreviated):

interface LayoutNode {
  // Tree operations
  insertChild(child, index): void
  removeChild(child): void
  getChildCount(): number
  getParent(): LayoutNode | null

  // Layout computation
  calculateLayout(width?, height?): void
  setMeasureFunc(fn: LayoutMeasureFunc): void
  unsetMeasureFunc(): void
  markDirty(): void

  // Layout reading (post-layout)
  getComputedLeft(): number
  getComputedTop(): number
  getComputedWidth(): number
  getComputedHeight(): number
  getComputedBorder(edge): number
  getComputedPadding(edge): number

  // Style setters (width, height, min/max, flex*, align*, justify, display,
  //                position, overflow, margin, padding, border, gap)

  // Lifecycle
  free(): void
  freeRecursive(): void
}

/x/Bigger-Projects/Claude-Code/src/ink/layout/yoga.ts — Yoga Adapter

Purpose: Implements LayoutNode by wrapping the native Yoga WASM node (src/native-ts/yoga-layout). Maps LayoutEdge/LayoutGutter/etc. string enums to Yoga enum values.

Class YogaLayoutNode:

  • Holds a YogaNode (this.yoga)
  • setMeasureFunc: wraps the LayoutMeasureFunc to translate MeasureMode enum values
  • calculateLayout(width): calls this.yoga.calculateLayout(width, undefined, Direction.LTR) — height is always undefined (intrinsic)

Edge/gutter enum maps are static EDGE_MAP and GUTTER_MAP objects.

/x/Bigger-Projects/Claude-Code/src/ink/layout/engine.ts

Purpose: Factory for layout nodes. A one-line indirection: createLayoutNode() calls createYogaLayoutNode(). Allows swapping the layout backend without changing the DOM layer.

/x/Bigger-Projects/Claude-Code/src/ink/layout/geometry.ts

Purpose: 2D geometry primitives used throughout the rendering pipeline.

Exports:

type Point = { x: number; y: number }
type Size = { width: number; height: number }
type Rectangle = Point & Size
type Edges = { top: number; right: number; bottom: number; left: number }

edges(all): Edges
edges(v, h): Edges
edges(t, r, b, l): Edges
addEdges(a, b): Edges
resolveEdges(partial?): Edges
ZERO_EDGES: Edges
unionRect(a, b): Rectangle   // bounding union
clampRect(rect, size): Rectangle
withinBounds(size, point): boolean
clamp(value, min?, max?): number

Styles

/x/Bigger-Projects/Claude-Code/src/ink/styles.ts

Purpose: TypeScript types for box/text styles plus applyStyles() which translates style props onto a LayoutNode.

Color types:

type RGBColor = `rgb(${number},${number},${number})`
type HexColor = `#${string}`
type Ansi256Color = `ansi256(${number})`
type AnsiColor = 'ansi:black' | 'ansi:red' | ...  // 16 named colors
type Color = RGBColor | HexColor | Ansi256Color | AnsiColor

TextStyles: { color?, backgroundColor?, dim?, bold?, italic?, underline?, strikethrough?, inverse? } — applied during rendering via chalk/colorize; not yoga properties.

Styles: The complete set of layout and text style props including:

  • textWrap: 'wrap' | 'wrap-trim' | 'end' | 'middle' | 'truncate-end' | 'truncate' | 'truncate-middle' | 'truncate-start'
  • position: 'absolute' | 'relative'
  • top | bottom | left | right: number | '${number}%'
  • columnGap | rowGap | gap: number
  • margin | marginX | marginY | marginTop | marginBottom | marginLeft | marginRight: number
  • padding | paddingX | paddingY | paddingTop | paddingBottom | paddingLeft | paddingRight: number
  • flexGrow | flexShrink | flexBasis: number
  • flexDirection: 'row' | 'row-reverse' | 'column' | 'column-reverse'
  • flexWrap: 'wrap' | 'nowrap' | 'wrap-reverse'
  • alignItems | alignSelf | justifyContent
  • width | height | minWidth | minHeight | maxWidth | maxHeight: number or '${number}%' or '100%'
  • display: 'flex' | 'none'
  • overflow | overflowX | overflowY: 'visible' | 'hidden' | 'scroll'
  • borderStyle: BorderStyle
  • borderColor | borderTopColor | borderRightColor | borderBottomColor | borderLeftColor: Color
  • borderDimColor | ...: boolean
  • borderTop | borderRight | borderBottom | borderLeft: boolean
  • color | backgroundColor | dimColor | bold | italic | underline | strikethrough | inverse

applyStyles(yogaNode, styles): Translates each Styles property to yogaNode.setXxx() calls. Percentage values use setWidthPercent, etc. Position/overflow/display use enum mapping.


Screen Buffer

/x/Bigger-Projects/Claude-Code/src/ink/screen.ts

Purpose: The core cell-based screen buffer. Stores the rendered content as a 2D grid of cells. Each cell is packed into two 32-bit integers for memory efficiency.

Cell encoding (packed as two Int32Array elements per cell):

  • Word 0 (low 32 bits): charId (high 22 bits) | styleId (low 10 bits)
  • Word 1 (high 32 bits): hyperlinkId (high 16 bits) | width (2 bits) | flags

CellWidth enum: Single = 0, Wide = 1, SpacerTail = 2, SpacerHead = 3

Pools (shared across all screens for zero-allocation diffing):

CharPool:

  • intern(char): returns a stable integer ID; ASCII chars use a fast Int32Array lookup; others use a Map
  • get(id): retrieves the string
  • Pool index 0 = space, index 1 = empty (spacer cell)

HyperlinkPool:

  • intern(hyperlink?): returns 0 for no hyperlink
  • get(id): returns the URL string or undefined

StylePool:

  • intern(styles: AnsiCode[]): returns a tagged integer ID; bit 0 = VISIBLE_ON_SPACE flag (background/inverse/underline affect spaces)
  • get(id): strips bit-0 flag and returns AnsiCode[]
  • transition(fromId, toId): returns the cached ANSI transition string (pre-serialized diff)
  • withInverse(baseId): returns ID of style with SGR 7 (inverse) added
  • withCurrentMatch(baseId): returns ID of style with inverse + bold + yellow-fg + underline (current search match)
  • withSelectionBg(baseId): returns ID of style with selection background color applied
  • setSelectionBg(bg): sets the selection background AnsiCode (clears cache)

Screen type:

type Screen = {
  cells: Int32Array   // packed cell data, width * height * 2 words per cell
  width: number
  height: number
  charPool: CharPool
  stylePool: StylePool
  hyperlinkPool: HyperlinkPool
  softWrap: Uint8Array  // 1 bit per row; 1 = soft-wrapped continuation
  noSelect: Uint8Array  // 1 bit per cell; set on gutter/non-selectable regions
}

Key functions:

  • createScreen(width, height, stylePool, charPool, hyperlinkPool) — allocates a new screen
  • resetScreen(screen) — zeroes all cells
  • setCellAt(screen, x, y, char, styleId, width, hyperlink?) — writes a cell
  • cellAt(screen, x, y) — reads a cell
  • cellAtIndex(screen, idx) — reads cell at raw index
  • isEmptyCellAt(screen, x, y) — both packed words = 0
  • blitRegion(dst, src, srcRect, dstX, dstY) — bulk-copy a rectangle from one screen to another (pure Int32Array copy, no decoding)
  • shiftRows(screen, top, bottom, delta) — hardware scroll simulation: move rows up/down within bounds
  • markNoSelectRegion(screen, x, y, w, h) — set the noSelect bit on a rectangular region
  • diffEach(prev, next, callback) — iterate only changed cells between two screens
  • migrateScreenPools(screen, charPool, hyperlinkPool) — re-intern all cells when pools are replaced (generational reset)

Rendering Pipeline

/x/Bigger-Projects/Claude-Code/src/ink/renderer.ts

Purpose: Creates a Renderer function (a closure over the root DOM node and StylePool) that converts the virtual DOM into a Frame object on each call.

Renderer type: (options: RenderOptions) => Frame

RenderOptions:

{
  frontFrame: Frame
  backFrame: Frame
  isTTY: boolean
  terminalWidth: number
  terminalRows: number
  altScreen: boolean
  prevFrameContaminated: boolean
}

Algorithm:

  1. Check yoga computed dimensions; return empty frame if invalid
  2. For alt-screen: clamp height to terminalRows (enforces the invariant)
  3. Reuse or create Output with the back-buffer screen
  4. Reset layoutShifted, scrollHint, scrollDrainNode
  5. If prevFrameContaminated or an absolute node was removed: disable blit (pass prevScreen = undefined)
  6. Call renderNodeToOutput(node, output, { prevScreen })
  7. After render: if a scroll-drain node remains, call markDirty(drainNode) to schedule the next drain frame
  8. Return Frame with the rendered screen, viewport, cursor position, and scroll hint

Cursor position:

  • Alt-screen: y = min(screen.height, terminalRows) - 1 — keeps cursor inside viewport to prevent LF-induced scroll
  • Main-screen: y = screen.height
  • visible = !isTTY || screen.height === 0 — cursor is hidden during active rendering

The Output instance is reused across frames so its charCache (per-line grapheme cluster cache) persists between renders, making steady-state spinner/clock renders near zero-allocation.

/x/Bigger-Projects/Claude-Code/src/ink/render-node-to-output.ts

Purpose: Recursively walks the DOM tree and emits write/blit/clear/clip/shift operations onto the Output buffer. This is the layout-to-pixels bridge.

Key exported state:

  • didLayoutShift() / resetLayoutShifted() — set when any node moves; gates the full-damage path in ink.tsx
  • getScrollHint() / resetScrollHint() — DECSTBM hardware scroll hint for LogUpdate
  • getScrollDrainNode() / resetScrollDrainNode() — identifies a ScrollBox with remaining pendingScrollDelta
  • consumeFollowScroll() — consumes the at-bottom follow-scroll event for selection adjustment

Scroll drain parameters:

SCROLL_MIN_PER_FRAME = 4        // minimum rows applied per frame
SCROLL_INSTANT_THRESHOLD = 5   // ≤ this: drain all at once (xterm.js wheel click)
SCROLL_HIGH_PENDING = 12        // threshold for high-speed drain
SCROLL_STEP_MED = 2             // medium pending drain step
SCROLL_STEP_HIGH = 3            // high pending drain step

ScrollBox rendering (three-pass algorithm):

  1. First pass: compute scrollHeight from yoga, apply pendingScrollDelta (proportional drain), handle stickyScroll, detect scrollAnchor
  2. Second pass (blit path): when content is unchanged and layout hasn't shifted, blit the scrollbox from prevScreen and emit a hardware shiftRows hint
  3. Third pass (full render): render children with clip and y-offset = -scrollTop; record absoluteRectsCur for position:absolute descendants

Blit optimization: When a node's bounding box matches nodeCache and the node is not dirty, the renderer blits from prevScreen instead of re-rendering. The condition is: !dirty && prevScreen && !hasRemovedChild && !layoutShifted. This makes steady-state frames O(changed cells).

nodeCache updates: After rendering each node, nodeCache.set(node, { x, y, width, height }) records the screen-space bounding box for hit-testing and blit reuse.

/x/Bigger-Projects/Claude-Code/src/ink/output.ts

Purpose: Collects rendering operations (write, blit, clip, clear, no-select, shift) and applies them to a Screen buffer in get().

Operation union type:

type Operation =
  | WriteOperation    // { type: 'write', x, y, text, softWrap? }
  | ClipOperation     // { type: 'clip', clip: Clip }
  | UnclipOperation   // { type: 'unclip' }
  | BlitOperation     // { type: 'blit', srcScreen, srcRect, dstX, dstY }
  | ClearOperation    // { type: 'clear', x, y, width, height }
  | NoSelectOperation // { type: 'noSelect', x, y, width, height }
  | ShiftOperation    // { type: 'shift', top, bottom, delta }

Clip type: { x1?, x2?, y1?, y2? } — undefined on an axis means unbounded. Clips are intersected when nested.

Write operation processing: The WriteOperation handler is the hot path. For each character:

  1. Tokenize the text (ANSI codes) via @alcalzone/ansi-tokenize
  2. Build a ClusteredChar[] array (grapheme + width + styleId + hyperlink), cached per unique line via charCache: Map<string, ClusteredChar[]>
  3. Apply bidirectional reordering (reorderBidi) on Windows/xterm.js
  4. Call setCellAt for each grapheme

charCache: Keyed by the raw ANSI string line. Cache miss: tokenize + cluster + intern styles. Cache hit: reuse the ClusteredChar[] array directly. The cache persists across frames (it lives on the Output instance). This is the dominant performance optimization for text-heavy content.

Tab expansion: Tabs in text are expanded to spaces at write time using screen x-position (not at measurement time), so the rendered width matches the measured width.

/x/Bigger-Projects/Claude-Code/src/ink/render-to-screen.ts

Purpose: Off-screen renderer used for search scanning. Renders a React element to an isolated Screen buffer at a given width without writing to the terminal. Used to pre-scan message content for search match positions.

renderToScreen(el, width): Returns { screen: Screen; height: number }. Uses a shared persistent root/container/pools (LegacyRoot mode for synchronous rendering) so repeated calls reuse Yoga nodes. Unmounts between calls to free resources.

scanPositions(screen, query): Scans a rendered screen for all occurrences of query, returning MatchPosition[] with { row, col, len } in message-relative coordinates.

applyPositionedHighlight(screen, positions, rowOffset, currentIdx, stylePool): Applies the current-match yellow/bold/underline style to the cell range at positions[currentIdx] and inverse style to all other positions.

/x/Bigger-Projects/Claude-Code/src/ink/frame.ts

Purpose: Defines the Frame type and the diff/patch type hierarchy.

Frame type:

type Frame = {
  readonly screen: Screen
  readonly viewport: Size
  readonly cursor: Cursor
  readonly scrollHint?: ScrollHint | null
  readonly scrollDrainPending?: boolean
}

Patch union type:

type Patch =
  | { type: 'stdout'; content: string }
  | { type: 'clear'; count: number }
  | { type: 'clearTerminal'; reason: FlickerReason; debug?: {...} }
  | { type: 'cursorHide' }
  | { type: 'cursorShow' }
  | { type: 'cursorMove'; x: number; y: number }
  | { type: 'cursorTo'; col: number }
  | { type: 'carriageReturn' }
  | { type: 'hyperlink'; uri: string }
  | { type: 'styleStr'; str: string }

type Diff = Patch[]

shouldClearScreen(prevFrame, frame): Returns 'resize' | 'offscreen' | undefined:

  • 'resize' — viewport dimensions changed
  • 'offscreen' — current or previous screen height exceeds viewport rows

FlickerReason: 'resize' | 'offscreen' | 'clear'

FrameEvent: Timing breakdown emitted to onFrame:

type FrameEvent = {
  durationMs: number
  phases?: {
    renderer: number; diff: number; optimize: number; write: number
    patches: number; yoga: number; commit: number
    yogaVisited: number; yogaMeasured: number; yogaCacheHits: number; yogaLive: number
  }
  flickers: Array<{ desiredHeight, availableHeight, reason }>
}

/x/Bigger-Projects/Claude-Code/src/ink/log-update.ts — Diff Engine

Purpose: Computes a Diff (list of Patch objects) by comparing the new Frame to the previous frame, or handles full-screen clears when needed.

LogUpdate class:

Constructor takes { isTTY, stylePool }. Maintains previousOutput: string (deprecated legacy string tracking).

Key methods:

  • render(prevFrame, frame) — main diff entry point. If shouldClearScreen() triggers, prepends a clearTerminal patch. Otherwise runs the incremental diff algorithm
  • renderPreviousOutput_DEPRECATED(prevFrame) — used for final output on exit (writes the last frame to terminal)
  • reset() — clears previousOutput (called after SIGCONT)

Incremental diff algorithm (renderDiff):

  1. Walk rows top-down, comparing prevScreen and screen cell-by-cell via diffEach
  2. For unchanged rows: emit cursor moves to skip them
  3. For changed rows: emit styleStr transitions + stdout content patches + hyperlink patches
  4. Handle wide chars: skip SpacerTail cells; emit a space for SpacerHead (end-of-line wrap guard)
  5. Track hyperlink state across rows (emit LINK_END when hyperlink changes)
  6. After last row: position cursor per frame.cursor

DECSTBM hardware scroll (scrollHint): When the back frame has a scrollHint and no layout shift occurred and the viewport can accommodate the shift:

  • Emit setScrollRegion(top, bottom) (DECSTBM)
  • Emit scrollUp(n) (CSI S) or scrollDown(n) (CSI T)
  • Emit RESET_SCROLL_REGION
  • Only repaint the rows that changed due to the scroll (a narrow repair band)

This replaces O(viewport) cell writes with O(scrolled region) writes for smooth scroll in fullscreen mode.

/x/Bigger-Projects/Claude-Code/src/ink/optimizer.ts

Purpose: Single-pass patch list optimizer.

optimize(diff): Rules applied:

  • Remove empty stdout patches
  • Remove no-op cursorMove(0,0) patches
  • Remove clear patches with count 0
  • Merge consecutive cursorMove patches (add x/y)
  • Collapse consecutive cursorTo patches (keep last)
  • Concat adjacent styleStr patches
  • Deduplicate consecutive hyperlink patches with same URI
  • Cancel adjacent cursorHide/cursorShow pairs

/x/Bigger-Projects/Claude-Code/src/ink/node-cache.ts

Purpose: Stores the rendered bounding box for each DOMElement, used for blit optimization and hit-testing.

Exports:

type CachedLayout = { x: number; y: number; width: number; height: number; top?: number }
const nodeCache = new WeakMap<DOMElement, CachedLayout>()
const pendingClears = new WeakMap<DOMElement, Rectangle[]>()

addPendingClear(parent, rect, isAbsolute): void
consumeAbsoluteRemovedFlag(): boolean

pendingClears holds rectangles of removed children that must be painted over on the next render. absoluteNodeRemoved gates the global blit disable path for absolute-positioned removals.


Terminal I/O

/x/Bigger-Projects/Claude-Code/src/ink/terminal.ts

Purpose: Terminal capability detection and the writeDiffToTerminal serializer.

Terminal type: { stdout: NodeJS.WriteStream; stderr: NodeJS.WriteStream }

Capability detection:

  • isSynchronizedOutputSupported() — returns true for iTerm2, WezTerm, Warp, ghostty, kitty, VS Code, alacritty, foot, etc. Returns false for tmux (parses but doesn't implement DEC 2026 properly)
  • isProgressReportingAvailable() — returns true for ConEmu, Ghostty 1.2.0+, iTerm2 3.6.6+; excludes Windows Terminal (interprets OSC 9;4 as notifications)
  • supportsExtendedKeys() — detects Kitty keyboard protocol or modifyOtherKeys support
  • isXtermJs() — set by XTVERSION probe (survives SSH, unlike TERM_PROGRAM)
  • setXtversionName(name) — called by App.tsx after terminal query response

writeDiffToTerminal(terminal, diff, syncOutput): Serializes a Diff into ANSI sequences and writes to terminal.stdout. If syncOutput === SYNC_OUTPUT_SUPPORTED, wraps in BSU/ESU (DEC 2026 synchronized output). The serialization is a tight loop over the Patch array:

  • stdout: write content directly
  • clear: eraseLines(count) — moves cursor up and erases
  • clearTerminal: getClearTerminalSequence() — erase screen + scrollback
  • cursorHide / cursorShow: emit HIDE/SHOW_CURSOR sequences
  • cursorMove: emit cursorMove(x, y) relative move
  • cursorTo: emit cursorTo(col) absolute column
  • carriageReturn: emit \r
  • hyperlink: emit link(uri) or LINK_END
  • styleStr: write pre-serialized ANSI transition string directly

SYNC_OUTPUT_SUPPORTED constant is computed once at module load.


Termio Layer

/x/Bigger-Projects/Claude-Code/src/ink/termio.ts — Termio Public API

Re-exports:

  • Parser from termio/parser.ts
  • All types: Action, Color, CursorAction, CursorDirection, EraseAction, Grapheme, LinkAction, ModeAction, NamedColor, ScrollAction, TextSegment, TextStyle, TitleAction, UnderlineStyle
  • colorsEqual, defaultStyle, stylesEqual

/x/Bigger-Projects/Claude-Code/src/ink/termio/types.ts

Purpose: Semantic type definitions for all ANSI parser output.

Key types:

// 16-color palette
type NamedColor = 'black' | 'red' | ... | 'brightWhite'

// 3-way color union
type Color =
  | { type: 'named'; name: NamedColor }
  | { type: 'indexed'; index: number }   // 0-255
  | { type: 'rgb'; r: number; g: number; b: number }
  | { type: 'default' }

type UnderlineStyle = 'none' | 'single' | 'double' | 'curly' | 'dotted' | 'dashed'

type TextStyle = {
  bold: boolean; dim: boolean; italic: boolean; underline: UnderlineStyle
  blink: boolean; inverse: boolean; hidden: boolean; strikethrough: boolean
  overline: boolean; fg: Color; bg: Color; underlineColor: Color
}

// All parsed actions
type Action =
  | { type: 'text'; graphemes: Grapheme[]; style: TextStyle }
  | { type: 'cursor'; action: CursorAction }
  | { type: 'erase'; action: EraseAction }
  | { type: 'scroll'; action: ScrollAction }
  | { type: 'mode'; action: ModeAction }
  | { type: 'link'; action: LinkAction }
  | { type: 'title'; action: TitleAction }
  | { type: 'tabStatus'; action: TabStatusAction }
  | { type: 'sgr'; params: string }
  | { type: 'bell' }
  | { type: 'reset' }
  | { type: 'unknown'; sequence: string }

TabStatusAction: { indicator?: Color | null; status?: string | null; statusColor?: Color | null } — for OSC 21337 tab chrome metadata.

Utility functions: defaultStyle(), stylesEqual(a, b), colorsEqual(a, b).

/x/Bigger-Projects/Claude-Code/src/ink/termio/ansi.ts

Purpose: Base ANSI constants and C0 control character codes.

Exports:

  • C0 object — complete C0 control character table (NUL through DEL)
  • ESC = '\x1b', BEL = '\x07', SEP = ';'
  • ESC_TYPE — escape sequence introducers: CSI=0x5b, OSC=0x5d, DCS=0x50, APC=0x5f, PM=0x5e, SOS=0x58, ST=0x5c
  • isC0(byte), isEscFinal(byte)

/x/Bigger-Projects/Claude-Code/src/ink/termio/csi.ts

Purpose: CSI (Control Sequence Introducer) sequence generation and constants.

Exports:

  • CSI_PREFIX = ESC + '['
  • CSI_RANGE — parameter/intermediate/final byte ranges
  • isCSIParam, isCSIIntermediate, isCSIFinal
  • csi(...args) — sequence generator: ESC [ params... final
  • CSI enum — final byte codes: CUU=0x41(A), CUD=0x42(B), CUF=0x43(C), CUB=0x44(D), CNL=0x45(E), CPL=0x46(F), CHA=0x47(G), CUP=0x48(H), ED=0x4a(J), EL=0x4b(K), SU=0x53(S), SD=0x54(T), SGR=0x6d(m), DECSTBM=0x72(r), ...
  • Pre-generated sequences: CURSOR_HOME, ERASE_SCREEN, ERASE_SCROLLBACK, RESET_SCROLL_REGION, PASTE_START, PASTE_END, FOCUS_IN, FOCUS_OUT, ENABLE_KITTY_KEYBOARD, DISABLE_KITTY_KEYBOARD, ENABLE_MODIFY_OTHER_KEYS, DISABLE_MODIFY_OTHER_KEYS
  • Parameterized: cursorMove(x,y), cursorTo(col), cursorPosition(row,col), eraseLines(count), setScrollRegion(top,bottom), scrollUp(n), scrollDown(n)

/x/Bigger-Projects/Claude-Code/src/ink/termio/dec.ts

Purpose: DEC private mode sequence generation.

Exports:

  • DEC enum — mode numbers: CURSOR_VISIBLE=25, ALT_SCREEN=47, ALT_SCREEN_CLEAR=1049, MOUSE_NORMAL=1000, MOUSE_BUTTON=1002, MOUSE_ANY=1003, MOUSE_SGR=1006, FOCUS_EVENTS=1004, BRACKETED_PASTE=2004, SYNCHRONIZED_UPDATE=2026
  • decset(mode) / decreset(mode)CSI ? N h / CSI ? N l
  • Pre-generated: BSU (begin synchronized update), ESU, EBP/DBP (bracketed paste), EFE/DFE (focus events), SHOW_CURSOR/HIDE_CURSOR, ENTER_ALT_SCREEN/EXIT_ALT_SCREEN
  • ENABLE_MOUSE_TRACKING — combination of 1000+1002+1003+1006 set
  • DISABLE_MOUSE_TRACKING — reverse order reset

/x/Bigger-Projects/Claude-Code/src/ink/termio/osc.ts

Purpose: OSC (Operating System Command) sequence generation and clipboard/tab-status support.

Exports:

  • OSC_PREFIX = ESC + ']', ST = ESC + '\\'
  • osc(...parts) — generates ESC ] parts BEL (or ST for Kitty)
  • wrapForMultiplexer(sequence) — wraps in tmux DCS passthrough (ESC P tmux ; ... ESC \) or GNU screen DCS if TMUX/STY env vars set
  • link(url) / LINK_END — OSC 8 hyperlink start/end
  • setClipboard(text) — OSC 52 + optional pbcopy/tmux load-buffer; returns ClipboardPath
  • getClipboardPath()'native' | 'tmux-buffer' | 'osc52' without side effects
  • tmuxLoadBuffer(text) — async: runs tmux load-buffer [-w] -
  • tabStatus({indicator, status, statusColor}) / CLEAR_TAB_STATUS — OSC 21337 tab chrome
  • supportsTabStatus() — detects iTerm2 / Claude Code terminal from env
  • CLEAR_ITERM2_PROGRESS — clears iTerm2 progress bar

Clipboard path decision:

  • native: macOS + no SSH_CONNECTION → use pbcopy
  • tmux-buffer: inside tmux → use tmux load-buffer [-w]
  • osc52: fallback → write OSC 52 raw sequence

/x/Bigger-Projects/Claude-Code/src/ink/termio/sgr.ts

Purpose: SGR (Select Graphic Rendition) parameter parser.

applySGR(paramStr, style): Parses semicolon/colon separated SGR params and mutates a TextStyle. Handles:

  • SGR 0: reset
  • SGR 1/2/3/4/5/7/8/9/53: bold/dim/italic/underline/blink/inverse/hidden/strikethrough/overline
  • SGR 21/22/23/24/25/27/28/29/55: attribute reset
  • SGR 30-37/90-97: named fg colors
  • SGR 40-47/100-107: named bg colors
  • SGR 38/48: extended fg/bg (256-indexed via 5 or truecolor via 2)
  • SGR 39/49: default fg/bg
  • SGR 58/59: underline color
  • Kitty extended underline styles (SGR 4:1-4:5)

Colon-separated subparams take priority over semicolon-separated for extended colors.

/x/Bigger-Projects/Claude-Code/src/ink/termio/esc.ts

Purpose: ESC (non-CSI, non-OSC) sequence parser. Handles ESC c (full reset), ESC 7/ESC 8 (save/restore cursor), and other two-byte sequences.

parseEsc(sequence): Returns an Action | null.

/x/Bigger-Projects/Claude-Code/src/ink/termio/tokenize.ts

Purpose: Streaming tokenizer for terminal input — splits raw bytes into text chunks and escape sequences without interpreting semantics.

States: ground, escape, escapeIntermediate, csi, ss3, osc, dcs, apc

Token type: { type: 'text'; value: string } | { type: 'sequence'; value: string }

Tokenizer interface:

{
  feed(input: string): Token[]
  flush(): Token[]
  reset(): void
  buffer(): string
}

createTokenizer(options?):

  • options.x10Mouse — enables X10 legacy mouse event parsing (consume 3 extra bytes after CSI M)
  • Maintains incremental state across feed() calls for streaming input

Algorithm: Character-by-character state machine. ground state: text passes through until ESC or C0 control chars. csi state: accumulates until CSI final byte (0x400x7e). osc/dcs/apc states: accumulate until BEL or ST.

/x/Bigger-Projects/Claude-Code/src/ink/termio/parser.ts

Purpose: Semantic parser that wraps the tokenizer and interprets each sequence into a structured Action.

Parser class:

class Parser {
  feed(input: string): Action[]
  flush(): Action[]
  reset(): void
  getStyle(): TextStyle
}

Internal structure:

  • Holds a Tokenizer with x10Mouse: true
  • Maintains current TextStyle (updated by SGR actions)
  • Calls parseCSI, parseEsc, parseOSC for sequence tokens
  • Calls segmentGraphemes for text tokens

Grapheme width detection:

  • isEmoji(codePoint) — ranges: 0x2600-0x26ff, 0x2700-0x27bf, 0x1F300-0x1F9FF, 0x1FA00-0x1FAFF, 0x1F1E0-0x1F1FF
  • isEastAsianWide(codePoint) — standard CJK/Hangul ranges
  • graphemeWidth(grapheme) — returns 1 or 2
  • segmentGraphemes(str) — uses Intl.Segmenter to split by grapheme cluster

parseCSI(rawSequence): Dispatches by final byte:

  • m (SGR): { type: 'sgr', params }
  • Cursor movement (A-H, d, f): { type: 'cursor', action }
  • Erase (J, K, X): { type: 'erase', action }
  • Scroll (S, T): { type: 'scroll', action }
  • DEC private modes (h/l with ? prefix): { type: 'mode', action }
  • Mouse events (M/m with < prefix): decoded SGR mouse

Event System

/x/Bigger-Projects/Claude-Code/src/ink/events/event.ts

Purpose: Base Event class providing stopImmediatePropagation().

class Event {
  stopImmediatePropagation(): void
  didStopImmediatePropagation(): boolean
}

/x/Bigger-Projects/Claude-Code/src/ink/events/terminal-event.ts

Purpose: TerminalEvent extends Event with DOM-style propagation properties.

EventTarget type: { parentNode: EventTarget | undefined; _eventHandlers?: Record<string, unknown> }

TerminalEvent properties:

  • type: string, timeStamp: number, bubbles: boolean, cancelable: boolean
  • target: EventTarget | null, currentTarget: EventTarget | null
  • eventPhase: 'none' | 'capturing' | 'at_target' | 'bubbling'
  • defaultPrevented: boolean

Methods: stopPropagation(), stopImmediatePropagation() (overrides base), preventDefault()

Internal setters: _setTarget, _setCurrentTarget, _setEventPhase, _isPropagationStopped(), _isImmediatePropagationStopped(), _prepareForTarget(target) (hook for subclasses)

/x/Bigger-Projects/Claude-Code/src/ink/events/dispatcher.ts

Purpose: Two-phase (capture + bubble) event dispatcher modeled after the browser DOM event model.

Dispatcher class:

  • dispatch(target, event) — full capture+bubble cycle via collectListeners + processDispatchQueue; runs asynchronously (via unstable_batchedUpdates or React's scheduler for discrete vs continuous events)
  • dispatchDiscrete(target, event) — discrete priority (keyboard, focus); triggers React's synchronous flush
  • Internal collectListeners(target, event) — walks from target to root, prepending capture handlers (root-first) and appending bubble handlers (target-first); result: [root-cap, ..., target-cap, target-bub, ..., root-bub]
  • processDispatchQueue(listeners, event) — calls each handler, checking _isPropagationStopped() and _isImmediatePropagationStopped() before each

React event priority mapping:

  • Keyboard/focus → DiscreteEventPriority
  • Mouse motion → ContinuousEventPriority
  • Other → DefaultEventPriority

/x/Bigger-Projects/Claude-Code/src/ink/events/event-handlers.ts

Purpose: Defines the complete set of event handler props and the reverse lookup table.

EventHandlerProps:

{
  onKeyDown?, onKeyDownCapture?: KeyboardEventHandler
  onFocus?, onFocusCapture?, onBlur?, onBlurCapture?: FocusEventHandler
  onPaste?, onPasteCapture?: PasteEventHandler
  onResize?: ResizeEventHandler
  onClick?: ClickEventHandler
  onMouseEnter?, onMouseLeave?: HoverEventHandler
}

HANDLER_FOR_EVENT: Maps event type strings to { bubble?, capture? } prop name pairs. Used by Dispatcher for O(1) handler lookup.

EVENT_HANDLER_PROPS: Set<string> of all handler prop names; used by the reconciler to route event props to _eventHandlers instead of attributes.

/x/Bigger-Projects/Claude-Code/src/ink/events/keyboard-event.ts

KeyboardEvent extends TerminalEvent:

  • Constructor takes a ParsedKey; type = 'keydown'; bubbles = true; cancelable = true
  • key: string — printable char for printable keys; multi-char name for specials ('down', 'return', 'escape', 'f1', etc.)
  • ctrl, shift, meta, superKey, fn: boolean

Key extraction: ctrl keys use the name (letter); single printable ASCII chars use the literal char; special keys use the parsed name.

/x/Bigger-Projects/Claude-Code/src/ink/events/click-event.ts

ClickEvent extends Event:

  • col: number, row: number — 0-indexed screen coordinates
  • localCol: number, localRow: number — coordinates relative to the current handler's Box (updated by dispatchClick before each handler fires)
  • cellIsBlank: boolean — true if the cell had no content (allows handlers to ignore clicks on empty space)

Other event types:

InputEvent (events/input-event.ts): Legacy input event emitted on stdin data; carries input: string and Key object. Key type: { upArrow, downArrow, leftArrow, rightArrow, pageUp, pageDown, return, escape, ctrl, shift, tab, backspace, delete, meta, fn }.

FocusEvent (events/focus-event.ts): type = 'focus' | 'blur'; relatedTarget: DOMElement | null

TerminalFocusEvent (events/terminal-focus-event.ts): type: TerminalFocusEventType = 'terminal-focus-in' | 'terminal-focus-out'; fired when DECSET 1004 focus events arrive.

EventEmitter (events/emitter.ts): Simple typed event emitter. on(event, handler), off(event, handler), emit(event, ...args). Used by App.tsx for stdin data events.


Focus Management

/x/Bigger-Projects/Claude-Code/src/ink/focus.ts

FocusManager class:

Stored on ink-root node so any node can reach it by walking parentNode.

State:

  • activeElement: DOMElement | null
  • focusStack: DOMElement[] — history for focus restoration (max 32 entries)
  • enabled: boolean

Methods:

  • focus(node) — blur previous, push to stack, focus new node; dispatches focus/blur events
  • blur() — blur activeElement, dispatches blur event
  • handleNodeRemoved(node, root) — removes node from stack, restores focus from stack if activeElement was in the removed subtree
  • handleClickFocus(node) — called by dispatchClick; focuses the nearest focusable ancestor
  • getNextFocusable(root, direction) — Tab/Shift+Tab cycling; collects all nodes with tabIndex >= 0, sorts by order, returns next/previous
  • enable() / disable() — gates all focus operations

getFocusManager(node) / getRootNode(node) — utility functions exported for the reconciler.


Input Parsing

/x/Bigger-Projects/Claude-Code/src/ink/parse-keypress.ts

Purpose: Parses raw terminal stdin bytes into structured ParsedKey objects. Handles standard ANSI sequences, Kitty keyboard protocol (CSI u), xterm modifyOtherKeys, SGR mouse events, and terminal response sequences.

ParsedKey type:

{
  kind: 'key' | 'mouse' | 'terminalResponse'
  name: string        // key name: 'up', 'down', 'return', 'escape', 'f1', 'a', ...
  fn: boolean
  ctrl: boolean; meta: boolean; shift: boolean; option: boolean; super: boolean
  sequence: string   // raw escape sequence
  raw: string        // original input bytes
  isPasted?: boolean
}

ParsedMouse type:

{
  kind: 'mouse'
  button: number     // SGR button code
  col: number; row: number  // 1-indexed
  press: boolean    // true = press, false = release
  isWheel: boolean
  isDrag: boolean
  modifiers: { ctrl, shift, meta, alt }
}

ParsedInput = ParsedKey | ParsedMouse | TerminalResponse

Regex patterns:

  • META_KEY_CODE_RE: ESC + [a-zA-Z0-9] — meta key combos
  • FN_KEY_RE: SS3/CSI function key sequences
  • CSI_U_RE: Kitty protocol ESC [ codepoint [;modifier] u
  • MODIFY_OTHER_KEYS_RE: xterm ESC [ 27 ; modifier ; keycode ~
  • DECRPM_RE, DA1_RE, DA2_RE, KITTY_FLAGS_RE, CURSOR_POSITION_RE — terminal response patterns
  • OSC_RESPONSE_RE: OSC sequence responses
  • XTVERSION_RE: DCS >| terminal name/version
  • SGR_MOUSE_RE: ESC [ < btn ; col ; row M/m

parseMultipleKeypresses(buffer, state): Main entry point. Uses the tokenizer to split the input, then dispatches each token to the appropriate parser. Handles bracketed paste (accumulates until PASTE_END).

INITIAL_STATE: Initial parser state for parseMultipleKeypresses.


Hit Testing

/x/Bigger-Projects/Claude-Code/src/ink/hit-test.ts

Purpose: Mouse click hit-testing against the DOM tree.

hitTest(node, col, row): DFS in reverse child order (last child = top paint layer wins). Uses nodeCache for bounding-box lookups. Returns the deepest DOMElement whose rendered rect contains (col, row), or null.

dispatchClick(root, col, row, cellIsBlank?):

  1. Runs hitTest to find the deepest hit node
  2. Calls focusManager.handleClickFocus() to click-to-focus the nearest focusable ancestor
  3. Creates a ClickEvent(col, row, cellIsBlank)
  4. Bubbles up via parentNode chain, calling onClick handlers
  5. Before each handler: sets event.localCol/localRow relative to the handler's bounding rect
  6. Stops on stopImmediatePropagation()
  7. Returns true if any handler fired

dispatchHover(root, col, row, hoveredNodes): Diff-based hover dispatch. Finds the set of nodes hit at (col, row), fires onMouseEnter for newly-entered nodes and onMouseLeave for exited nodes. Mutates hoveredNodes in place (owned by the Ink instance).


Text Selection

/x/Bigger-Projects/Claude-Code/src/ink/selection.ts

Purpose: Linear text selection in screen-buffer coordinates for fullscreen mode.

SelectionState type:

{
  anchor: { col, row } | null
  focus: { col, row } | null
  isDragging: boolean
  anchorSpan: { lo, hi, kind: 'word' | 'line' } | null  // word/line mode
  scrolledOffAbove: string[]     // rows that scrolled off the top
  scrolledOffBelow: string[]
  scrolledOffAboveSW: boolean[]  // soft-wrap flags parallel to scrolledOffAbove
  scrolledOffBelowSW: boolean[]
  virtualAnchorRow?: number      // pre-clamp anchor row (for scroll restore)
  virtualFocusRow?: number
  lastPressHadAlt: boolean
}

Exported functions:

  • createSelectionState() — returns zeroed state
  • startSelection(s, col, row, alt?) — initializes anchor and focus
  • updateSelection(s, col, row) — updates focus during drag
  • finishSelection(s) — clears isDragging
  • clearSelection(s) — resets to empty
  • hasSelection(s) — returns true if anchor !== null && focus !== null
  • getSelectedText(s, screen) — extracts the selected text; handles soft-wrap (joins wrapped lines), wide chars (skips SpacerTail), noSelect regions (excluded), and scrolledOffAbove/scrolledOffBelow accumulators
  • applySelectionOverlay(s, screen, stylePool) — inverts cell styles in the selected region
  • selectWordAt(s, col, row, screen) — double-click: select the word under the cursor
  • selectLineAt(s, row, screen) — triple-click: select the entire line
  • extendSelection(s, col, row, screen) — word/line-mode drag extension: extends to word/line boundaries
  • shiftSelection(s, dRow, min, max, screenWidth) — keyboard scroll: shifts both anchor and focus
  • shiftAnchor(s, dRow, min, max) — shift anchor only (keyboard selection extension)
  • moveFocus(s, move, screen) — keyboard character/line focus extension
  • captureScrolledRows(s, firstRow, lastRow, side, screen) — saves rows about to scroll off into scrolledOffAbove/scrolledOffBelow
  • shiftSelectionForFollow(s, delta, screen) — called after follow-scroll to keep selection anchored to text

Search Highlighting

/x/Bigger-Projects/Claude-Code/src/ink/searchHighlight.ts

applySearchHighlight(screen, query, stylePool):

  • Case-insensitive scan of the screen buffer
  • Builds per-row char text + codeUnitToCell index map (handles wide chars)
  • For each match: calls setCellStyleId(screen, ..., stylePool.withInverse(cellStyleId)) to invert the cell style
  • Skips noSelect regions (gutters, line numbers)
  • Returns true if any match was applied (triggers full-damage flag in caller)

The applyPositionedHighlight (in render-to-screen.ts) handles the "current match" in yellow, on top of applySearchHighlight's inverse.


Component Library

/x/Bigger-Projects/Claude-Code/src/ink/components/App.tsx

Purpose: The root React component. Wires together stdin input, terminal size context, focus, clock, error boundaries, and all context providers.

Props: children, stdin, stdout, stderr, exitOnCtrlC, onExit, terminalColumns, terminalRows, selection, onSelectionChange, onClickAt, onHoverAt, getHyperlinkAt, onOpenHyperlink, onMultiClick, onSelectionDrag, onStdinResume?, onCursorDeclaration

Responsibilities:

  • Provides AppContext, StdinContext, TerminalSizeContext, ClockContext, TerminalFocusProvider, CursorDeclarationContext, TerminalWriteProvider
  • Listens to stdin data; calls parseMultipleKeypresses for each chunk
  • Dispatches KeyboardEvent through the DOM via dispatcher.dispatchDiscrete(rootNode, event)
  • Handles mouse events: left-button selection start/update/finish; wheel → pendingScrollDelta; hover → onHoverAt; click → onClickAt
  • Handles pasted content via bracketed paste markers
  • Detects terminal focus via DECSET 1004 FOCUS_IN/FOCUS_OUT sequences
  • Runs TerminalQuerier on mount to probe XTVERSION and extended key support
  • Re-asserts terminal modes after STDIN_RESUME_GAP_MS = 5000 ms of stdin silence
  • Handles Ctrl+ConExit when exitOnCtrlC = true
  • Handles Ctrl+Z (SIGTSTP) on non-Windows platforms

Class component (PureComponent) for stable reference identity. All mouse/keyboard state is imperative (refs), not React state, to avoid re-renders.

/x/Bigger-Projects/Claude-Code/src/ink/components/Box.tsx

Purpose: The primary layout container. Analogous to <div style="display: flex">.

Props: All Styles properties (except textWrap) plus:

  • ref?: Ref<DOMElement>
  • tabIndex?: number — focus order (>= 0 participates in Tab cycling, -1 = programmatic only)
  • autoFocus?: boolean — focus on mount
  • onClick?: (event: ClickEvent) => void
  • onFocus?, onFocusCapture?, onBlur?, onBlurCapture?
  • onKeyDown?, onKeyDownCapture?
  • onMouseEnter?, onMouseLeave?

Renders to <ink-box> host element. Compiled with React Compiler (memo cache _c(42)).

/x/Bigger-Projects/Claude-Code/src/ink/components/Text.tsx

Purpose: Renders styled text. Wraps children in ink-text and ink-virtual-text elements.

Props:

  • color?, backgroundColor?
  • bold?, dim? (mutually exclusive via TypeScript union)
  • italic?, underline?, strikethrough?, inverse?
  • wrap?: Styles['textWrap']
  • children?: ReactNode

Text styling props are mapped to TextStyles and passed as the textStyles prop on the host element. Layout props (flexDirection, flexGrow, etc.) are mapped to Styles on the ink-text node.

/x/Bigger-Projects/Claude-Code/src/ink/components/ScrollBox.tsx

Purpose: A Box with imperative scroll API and viewport culling.

ScrollBoxHandle:

{
  scrollTo(y): void
  scrollBy(dy): void
  scrollToElement(el, offset?): void   // defers position read to render time
  scrollToBottom(): void
  getScrollTop(): number
  getPendingDelta(): number
  getScrollHeight(): number
  getFreshScrollHeight(): number       // reads Yoga directly
  getViewportHeight(): number
  getViewportTop(): number
  isSticky(): boolean
  subscribe(listener): () => void
  setClampBounds(min, max): void
}

ScrollBoxProps: All Styles except overflow/overflowX/overflowY, plus stickyScroll?: boolean.

Implementation: Sets overflow: 'scroll' on the underlying Box. Scroll mutations call markDirty + scheduleRenderFrom to trigger an Ink frame without going through React's reconciler. scrollToElement stores a scrollAnchor on the DOM node; render-node-to-output reads it at paint time (after Yoga has computed the element's position) and clears it.

/x/Bigger-Projects/Claude-Code/src/ink/components/AlternateScreen.tsx

Purpose: Enters the terminal's alternate screen buffer for fullscreen rendering.

Props: children, mouseTracking?: boolean (default true)

Uses useInsertionEffect (not useLayoutEffect) to send ENTER_ALT_SCREEN before the first Ink render frame. On cleanup (unmount), sends EXIT_ALT_SCREEN. Calls instances.get(process.stdout).setAltScreenActive(true/false) to coordinate with the Ink instance. Renders children inside a Box constrained to terminalRows height.

Purpose: Renders OSC 8 terminal hyperlinks.

Wraps children in an ink-link host element with href attribute. During rendering, squashTextNodesToSegments propagates the hyperlink URL to contained text segments.

/x/Bigger-Projects/Claude-Code/src/ink/components/RawAnsi.tsx

Purpose: Renders pre-formatted ANSI strings with known dimensions.

Props: children: string, width: number, height: number

Renders to ink-raw-ansi element with rawWidth/rawHeight attributes. The yoga measure function reads these dimensions directly (no string width measurement, no wrapping).

/x/Bigger-Projects/Claude-Code/src/ink/components/NoSelect.tsx

Purpose: Marks a rectangular region as non-selectable (e.g., gutters, line numbers).

Sets the noSelect flag on cells in the screen buffer via markNoSelectRegion. Text in these cells is excluded from selection copy and search highlighting.

/x/Bigger-Projects/Claude-Code/src/ink/components/Newline.tsx

Purpose: Renders \n characters (count configurable via count prop).

/x/Bigger-Projects/Claude-Code/src/ink/components/Spacer.tsx

Purpose: Flexible spacer. Renders a Box with flexGrow: 1.

/x/Bigger-Projects/Claude-Code/src/ink/components/Button.tsx

Purpose: Focusable, clickable button with keyboard activation.

ButtonState: 'idle' | 'active' | 'focus'

Props: children, onPress?, disabled? plus styling.

Uses useApp for exit integration. Handles Return/Space keydown when focused.

/x/Bigger-Projects/Claude-Code/src/ink/components/ErrorOverview.tsx

Purpose: Error boundary overlay shown when a React component throws. Renders the stack trace and error message with formatting.

Context components:

AppContext.ts — provides { exit(error?): void }. Consumed by useApp.

StdinContext.ts — provides { stdin, setRawMode, isRawModeSupported, internal_exitOnCtrlC, internal_eventEmitter }. Consumed by useStdin, useInput.

TerminalSizeContext.tsx — provides { columns: number; rows: number } | null. Consumed by useTerminalViewport, AlternateScreen.

ClockContext.tsx — provides a shared animation clock with subscribe(cb, keepAlive?) and now(). All useAnimationFrame instances share one clock; idle clock (no keepAlive subscribers) suspends to avoid waking the process.

CursorDeclarationContext.ts — provides a setter for declaring native cursor position. Consumed by useDeclaredCursor. The setter signature: (decl: CursorDeclaration | null, node?: DOMElement | null) => void.

TerminalFocusContext.tsx — provides useTerminalFocus() which subscribes to DECSET 1004 focus events via useSyncExternalStore on terminal-focus-state.ts.


Hooks

/x/Bigger-Projects/Claude-Code/src/ink/hooks/use-input.ts

useInput(handler, options?): Subscribes to stdin input events. Calls setRawMode(true) via useLayoutEffect (synchronous, before render returns). Subscribes to internal_eventEmitter 'input' events. Options: { isActive?: boolean }.

/x/Bigger-Projects/Claude-Code/src/ink/hooks/use-app.ts

default useApp(): Returns { exit(error?): void } from AppContext. Throws if used outside the App tree.

/x/Bigger-Projects/Claude-Code/src/ink/hooks/use-stdin.ts

default useStdin(): Returns the full StdinContext value.

/x/Bigger-Projects/Claude-Code/src/ink/hooks/use-animation-frame.ts

useAnimationFrame(intervalMs?): Returns [ref, time]. Subscribes to the shared clock and updates time every intervalMs. Pauses (unsubscribes) when intervalMs = null or the element is off-screen (via useTerminalViewport).

/x/Bigger-Projects/Claude-Code/src/ink/hooks/use-interval.ts

useInterval(callback, delay?): Calls callback every delay ms. Uses the shared clock.

useAnimationTimer(intervalMs): Returns time (elapsed ms). Similar to useAnimationFrame but without the viewport visibility check.

/x/Bigger-Projects/Claude-Code/src/ink/hooks/use-terminal-viewport.ts

useTerminalViewport(): Returns [ref, { isVisible }]. Computes visibility by walking the DOM ancestor chain (including scrollTop offsets) during useLayoutEffect. Does NOT cause re-renders on visibility change — callers read the current value naturally.

/x/Bigger-Projects/Claude-Code/src/ink/hooks/use-terminal-focus.ts

useTerminalFocus(): Returns boolean — whether the terminal window is focused. Uses useSyncExternalStore on terminal-focus-state.ts module-level signal.

/x/Bigger-Projects/Claude-Code/src/ink/hooks/use-terminal-title.ts

useTerminalTitle(title): Sets the terminal window title via OSC 0/2 on mount and clears on unmount.

/x/Bigger-Projects/Claude-Code/src/ink/hooks/use-selection.ts

useSelection(): Returns an API object for text selection operations. Falls back to no-ops when not in fullscreen mode. The Ink instance is located via instances.get(process.stdout).

Methods: copySelection(), copySelectionNoClear(), clearSelection(), hasSelection(), getState(), subscribe(cb), shiftAnchor(dRow, min, max), shiftSelection(dRow, min, max), moveFocus(move), captureScrolledRows(first, last, side), setSelectionBgColor(color).

/x/Bigger-Projects/Claude-Code/src/ink/hooks/use-tab-status.ts

useTabStatus(kind: TabStatusKind | null): Emits OSC 21337 tab status sequences. kind = 'idle' | 'busy' | 'waiting'. Transitions to null emit CLEAR_TAB_STATUS. Wrapped for tmux passthrough. Uses TerminalWriteContext.

/x/Bigger-Projects/Claude-Code/src/ink/hooks/use-declared-cursor.ts

useDeclaredCursor({ line, column, active }): Returns a ref callback. When active, declares the cursor position to the Ink instance so the native cursor parks at the text caret. Uses useLayoutEffect (no dep array — re-declares every commit) for correct sibling handoff. Clears on unmount via a separate useLayoutEffect with empty deps.

/x/Bigger-Projects/Claude-Code/src/ink/hooks/use-search-highlight.ts

Internal hook for wiring search query to the Ink instance's setSearchHighlight / setSearchPositions.


Utility Modules

/x/Bigger-Projects/Claude-Code/src/ink/Ansi.tsx

Ansi component: Parses ANSI escape codes in a string and renders them using Text and Link components. Memoized. Accepts children: string and optional dimColor: boolean. Uses termio.Parser to extract spans and maps them to Text props + Link wrappers for hyperlinks.

/x/Bigger-Projects/Claude-Code/src/ink/bidi.ts

reorderBidi(characters: ClusteredChar[]): Applies the Unicode Bidi Algorithm to a ClusteredChar array when running on Windows Terminal, WSL, or xterm.js (all lack native bidi). Uses bidi-js library. Detects need via WT_SESSION env var or TERM_PROGRAM=vscode. No-op on platforms with native bidi support.

/x/Bigger-Projects/Claude-Code/src/ink/clearTerminal.ts

getClearTerminalSequence(): Returns ERASE_SCREEN + ERASE_SCROLLBACK + CURSOR_HOME on modern terminals. Windows: uses HVP (ESC [ 0 f) for cursor home on legacy console; includes scrollback clear for Windows Terminal, VS Code, and mintty.

clearTerminal: Pre-computed clear sequence (module load time).

/x/Bigger-Projects/Claude-Code/src/ink/colorize.ts

colorize(text, styles): Applies chalk-based color/style transforms to a text string. Detects the chalk level and adjusts for xterm.js and tmux environments.

applyTextStyles(text, textStyles): Converts TextStyles to chalk method chain calls.

Color level management:

  • boostChalkLevelForXtermJs() — upgrades chalk to level 3 (truecolor) when TERM_PROGRAM=vscode and chalk detected level 2
  • clampChalkLevelForTmux() — downgrades to level 2 (256-color) inside tmux to avoid truecolor passthrough bugs; skipped when CLAUDE_CODE_TMUX_TRUECOLOR=1

/x/Bigger-Projects/Claude-Code/src/ink/get-max-width.ts

getMaxWidth(node, offsetWidth?): Computes the available render width for a node accounting for padding and border. Reads from yogaNode.getComputedPadding/getComputedBorder for each relevant edge.

/x/Bigger-Projects/Claude-Code/src/ink/line-width-cache.ts

lineWidth(line: string): Memoized string width for individual lines (no newlines). Cache is a Map<string, number>. Used by measureText.

/x/Bigger-Projects/Claude-Code/src/ink/measure-element.ts

measureElement(node: DOMElement): Returns { width, height } by reading yogaNode.getComputedWidth()/getComputedHeight(). Throws if the node has no yoga node.

/x/Bigger-Projects/Claude-Code/src/ink/measure-text.ts

measureText(text, maxWidth): Single-pass computation of { width, height } for a text string. Uses lineWidth per line. Height = sum of ceil(lineWidth / maxWidth) per line (or 1 when noWrap).

/x/Bigger-Projects/Claude-Code/src/ink/squash-text-nodes.ts

squashTextNodes(node): Concatenates all text content of a node tree into a plain string (no styles). Used by measureTextNode in dom.ts.

squashTextNodesToSegments(node, inheritedStyles?, inheritedHyperlink?, out?): Walks the text node tree and produces StyledSegment[] — text with inherited styles and hyperlink URLs. Used by output.ts for structured rendering.

StyledSegment type: { text: string; styles: TextStyles; hyperlink?: string }

/x/Bigger-Projects/Claude-Code/src/ink/stringWidth.ts

stringWidth(str): Terminal display width of a string (counts wide chars as 2, strips ANSI codes). Uses @alcalzone/ansi-tokenize + grapheme segmentation.

/x/Bigger-Projects/Claude-Code/src/ink/widest-line.ts

widestLine(text): Returns the display width of the widest line in a multi-line string.

/x/Bigger-Projects/Claude-Code/src/ink/wrap-text.ts

wrapText(text, maxWidth, wrapType): Applies the appropriate text wrap strategy:

  • 'wrap': wrapAnsi(text, maxWidth, { trim: false, hard: true })
  • 'wrap-trim': wrapAnsi(text, maxWidth, { trim: true, hard: true })
  • 'truncate-end' / 'truncate': append
  • 'truncate-middle': insert in middle
  • 'truncate-start': prepend
  • 'end' / 'middle': same as corresponding truncate

Uses sliceFit to avoid wide-char boundary errors in slice operations.

/x/Bigger-Projects/Claude-Code/src/ink/wrapAnsi.ts

Custom ANSI-aware word-wrap implementation. Handles wide chars, hyperlinks (OSC 8), and soft-wrap tracking. Returns wrapped text plus softWrap[] flags.

supportsHyperlinks(): Detects terminal support for OSC 8 hyperlinks. Returns true for iTerm2, VS Code, kitty, Ghostty, WezTerm, Warp, and others.

/x/Bigger-Projects/Claude-Code/src/ink/tabstops.ts

expandTabs(text, startColumn?): Expands tab characters to spaces based on 8-column tab stops. Used during text measurement (worst-case width). Actual tab expansion at render time uses the screen x-position.

/x/Bigger-Projects/Claude-Code/src/ink/render-border.ts

renderBorder(node, output, x, y, width, height): Draws box borders using cli-boxes glyphs (single, double, round, bold, classic, dashed). Supports borderText option to embed text into top/bottom border lines with start/end/center alignment. Colors applied via chalk.

/x/Bigger-Projects/Claude-Code/src/ink/terminal-focus-state.ts

Module-level singleton signal for terminal focus state.

TerminalFocusState: 'focused' | 'blurred' | 'unknown'

Exports:

  • setTerminalFocused(v) — updates state, notifies useSyncExternalStore subscribers
  • getTerminalFocused() — returns focusState !== 'blurred' (unknown treated as focused)
  • getTerminalFocusState() — returns the tristate value
  • subscribeTerminalFocus(cb) — subscribe function for useSyncExternalStore
  • resetTerminalFocusState() — resets to 'unknown'

/x/Bigger-Projects/Claude-Code/src/ink/terminal-querier.ts

Purpose: Queries the terminal for capability information using DA1/DECRQM/XTVERSION sentinel protocol (no timeouts — DA1 is the universal sentinel).

TerminalQuery<T> type: { request: string; match: (r) => r is T }

Query builders:

  • decrqm(mode) — DECRQM query; response: DecrpmResponse
  • da1() — Primary Device Attributes
  • da2() — Secondary Device Attributes
  • kittyKeyboard() — Kitty keyboard flags query
  • cursorPosition() — DECXCPR
  • oscColor(code, index?) — OSC 10/11/12 color queries
  • xtversion() — DCS >| terminal name/version

TerminalQuerier class:

  • send<T>(query) — returns a Promise<T | undefined>
  • flush() — sends a DA1 sentinel; all pending queries resolve when DA1 response arrives (terminals answer in order)
  • Internal: holds a queue of { query, resolve } entries

xtversion: Stores the XTVERSION name (set by App.tsx after the query resolves; read by isXtermJs()).

/x/Bigger-Projects/Claude-Code/src/ink/useTerminalNotification.ts

TerminalWriteContext: React context providing a (data: string) => void write function that bypasses the normal Ink render pipeline (direct stdout write).

TerminalWriteProvider: = TerminalWriteContext.Provider

useTerminalNotification(): Returns notification methods:

  • notifyITerm2({ message, title? }) — OSC 9 iTerm2 notification
  • notifyKitty({ message, title, id }) — Kitty notification via OSC 99
  • notifyGhostty({ message, title }) — Ghostty notification via OSC 99 variant
  • notifyBell() — raw BEL character
  • progress(state, percentage?) — OSC 9;4 progress bar (Ghostty 1.2+, iTerm2 3.6.6+, ConEmu)

/x/Bigger-Projects/Claude-Code/src/ink/warn.ts

Centralized warning emitter (wraps console.warn with deduplication). Used by Box.tsx for invalid prop combinations.


Architecture: The Complete Pipeline

[stdin bytes]
    → App.tsx handleInput()
    → parseMultipleKeypresses()  (termio tokenizer + key parser)
    → KeyboardEvent / ParsedMouse / TerminalResponse
    → dispatcher.dispatchDiscrete(rootNode, event)  // keyboard
    → React setState / component handlers

[React state change]
    → React reconciler commit
    → reconciler.resetAfterCommit(rootNode)
    → rootNode.onComputeLayout()  // yoga calculateLayout()
    → rootNode.onRender()  →  queueMicrotask(ink.onRender)

[ink.onRender()]
    → createRenderer(rootNode, stylePool)(options)
        → renderNodeToOutput(rootNode, output, { prevScreen })
            → DOM walk with blit/clip/write/scroll ops
            → output.get() → Screen (cell buffer)
        → return Frame { screen, viewport, cursor, scrollHint }
    → applySelectionOverlay(selection, frame.screen, stylePool)
    → applySearchHighlight(frame.screen, query, stylePool)
    → applyPositionedHighlight(frame.screen, positions, ...)
    → log.render(prevFrame, frame) → Diff (Patch[])
    → optimize(diff) → compressed Patch[]
    → writeDiffToTerminal(terminal, diff, syncOutput)
        → BSU (if sync supported)
        → for each Patch: write ANSI to stdout
        → ESU (if sync supported)
    → emit onFrame(FrameEvent)
    → swap frontFrame ↔ backFrame

Double Buffering

The Ink class maintains frontFrame (the last displayed frame) and backFrame (the rendering target). After each render:

  • backFrame.screen contains the newly rendered content
  • It becomes the new frontFrame
  • Old frontFrame becomes the new backFrame for the next render

The renderer always reads prevScreen = frontFrame.screen for blit operations and writes into backFrame.screen.

Blit Optimization

The blit path is the dominant fast path for steady-state rendering:

  1. If a node's dirty = false AND nodeCache has a valid rect AND prevScreen is available AND no layout shift occurred AND no removed children: call blitRegion(backScreen, prevScreen, cachedRect) — pure Int32Array.copyWithin, O(cells).
  2. This means spinner ticks and clock updates only re-render the changed cell ranges; the rest of the screen is copied in bulk.

Synchronized Output (BSU/ESU)

When SYNC_OUTPUT_SUPPORTED = true, each frame is wrapped in DEC mode 2026 begin/end synchronized update sequences (BSU / ESU). This prevents terminals from rendering intermediate states during the diff write. Supported terminals: iTerm2, WezTerm, Warp, ghostty, kitty, VS Code, alacritty, foot, kitty.

DECSTBM Hardware Scroll

When a ScrollBox's scrollTop changes and layout is otherwise stable, render-node-to-output sets a ScrollHint. LogUpdate.render() checks for this hint and emits:

  1. setScrollRegion(top, bottom) — DECSTBM restricts scroll to the box's viewport
  2. SU(n) or SD(n) — hardware scroll by n rows
  3. RESET_SCROLL_REGION — restore full-screen scroll
  4. Then only re-renders the narrow band of newly exposed cells

This replaces O(viewport × width) cell writes with O(exposed_rows × width) for smooth scrolling.

Pool Generational Reset

The StylePool and CharPool live for the entire session (never reset) to ensure stable IDs for the blit optimization (IDs are comparable as integers across frames). The HyperlinkPool is reset every 5 minutes (hyperlinks are ephemeral) via migrateScreenPools(), which re-interns all active cells into fresh pool instances.