Terminal UI Framework: Building a CLI with React

A deep dive into Claude Code's React/Ink terminal UI — the rendering pipeline, 140+ component architecture, Markdown terminal rendering, live progress, and permission dialogs

Setting the Stage

When you run the claude command in your terminal, what you see is not bare plaintext output — it's syntax-highlighted code blocks, live bouncing loading animations, structured diff views, interactive permission dialogs, and smooth virtual scrolling. All of this runs in an environment with no browser, no DOM, and no CSS: your terminal.

Behind this terminal UI is a complete React application. Claude Code switches React's render target from the browser DOM to a terminal character matrix. Through a custom reconciler, components like <Box> and <Text> are transformed into terminal ANSI sequences, forming a UI system of 140+ components. The core questions it must answer are:

  1. Render target switching: How does the React reconciler map a component tree to terminal pixels (character cells)? What replaces CSS as the layout engine?
  2. Component architecture: How are 140+ components organized? How are complex UIs like message rendering, tool progress, diff views, and permission dialogs implemented in a terminal?
  3. Performance constraints: Terminal rendering frame rates are far lower than the browser's 60fps. How do you complete layout calculation, diff detection, and terminal writes within a 16ms frame budget?
  4. Interaction model: Without mouse clicks (in most cases) or focus switching, how do React Hooks adapt to a keyboard-first interaction paradigm?

This article begins with the Ink framework's rendering pipeline, then progressively dives into component architecture, core UI deep dives, Hooks usage, and finally discusses the limitations and portable patterns of this architecture.

Ink Framework Overview: React's Render Target Becomes the Terminal

What is Ink

Ink is a framework that replaces React's render target from the browser DOM to the terminal. In the browser, React uses react-dom to render <div> and <span> as DOM nodes; in Ink, React uses a custom reconciler to render <Box> and <Text> as terminal characters. Claude Code has done extensive deep customization on top of Ink — the src/ink/ directory contains the complete rendering engine, not a simple reference to an npm package.

The core idea can be summed up in one sentence: React provides the declarative UI programming model, Ink provides the terminal rendering backend.

...

Rendering Pipeline: From JSX to Terminal Pixels

The entire rendering pipeline consists of six stages:

Stage 1: React Reconciliation

React's reconciler diffs the JSX component tree against the previous frame's Fiber tree, producing a series of DOM operations (create, update, delete nodes). Claude Code uses the react-reconciler library to create a custom reconciler:

src/ink/reconciler.ts
TypeScript
1const reconciler = createReconciler<
2 ElementNames,
3 Props,
4 DOMElement,
5 DOMElement,
6 TextNode,
7 DOMElement,
8 unknown,
9 unknown,
10 DOMElement,
11 HostContext,
12 null, // UpdatePayload - not used in React 19
13 NodeJS.Timeout,
14 -1,
15 null
16>({
17 getRootHostContext: () => ({ isInsideText: false }),
18 // ...
19})

This reconciler implements methods like createInstance, commitUpdate, and removeChild, translating React operations into operations on the Ink DOM tree. Each JSX element type corresponds to an Ink node type:

src/ink/dom.ts
TypeScript
1export type ElementNames =
2 | 'ink-root'
3 | 'ink-box'
4 | 'ink-text'
5 | 'ink-virtual-text'
6 | 'ink-link'
7 | 'ink-progress'
8 | 'ink-raw-ansi'

Note the HostContext's isInsideText field — it prevents nesting <Box> inside <Text>, which is a fundamental constraint of terminal rendering (text nodes cannot contain block-level layout):

src/ink/reconciler.ts
TypeScript
1createInstance(
2 originalType: ElementNames,
3 newProps: Props,
4 _root: DOMElement,
5 hostContext: HostContext,
6): DOMElement {
7 if (hostContext.isInsideText && originalType === 'ink-box') {
8 throw new Error(`<Box> can't be nested inside <Text> component`)
9 }
10 // ...
11}

Stage 2: Yoga Layout Calculation

Each node in the Ink DOM tree is associated with a Yoga layout node. Yoga is a cross-platform Flexbox layout engine developed by Facebook (originally designed for React Native). Layout calculation is triggered in the reconciler's resetAfterCommit:

src/ink/reconciler.ts
TypeScript
1resetAfterCommit(rootNode) {
2 // ...
3 if (typeof rootNode.onComputeLayout === 'function') {
4 rootNode.onComputeLayout()
5 }
6 // ...
7 rootNode.onRender?.()
8}

onComputeLayout calls Yoga's calculateLayout() method, computing precise x, y, width, height for each node — similar to browser CSS Flexbox layout, but with output units in terminal character cells rather than pixels.

Stage 3: Rendering to Output Operations

After layout is complete, renderNodeToOutput traverses the Ink DOM tree, transforming each visible node into Output operations (write, clip, blit, clear, etc.). This stage also handles terminal-specific logic like scrolling, border drawing, and text wrapping:

src/ink/render-node-to-output.ts
TypeScript
1import indentString from 'indent-string'
2import { applyTextStyles } from './colorize.js'
3import type { DOMElement } from './dom.js'
4import getMaxWidth from './get-max-width.js'
5import type { Rectangle } from './layout/geometry.js'
6import { LayoutDisplay, LayoutEdge, type LayoutNode } from './layout/node.js'
7import { nodeCache, pendingClears } from './node-cache.js'
8import type Output from './output.js'
9import renderBorder from './render-border.js'
10import type { Screen } from './screen.js'
11import {
12 type StyledSegment,
13 squashTextNodesToSegments,
14} from './squash-text-nodes.js'
15import type { Color } from './styles.js'
16import { isXtermJs } from './terminal.js'
17import { widestLine } from './widest-line.js'
18import wrapText from './wrap-text.js'

A key optimization: blit (block transfer). If a node's position and content haven't changed, the character data for that region is copied directly from the previous frame's Screen buffer, skipping the entire subtree's rendering. This makes steady-state frames (where only a spinner is animating) approach O(changed cells) cost rather than O(total cells).

Stage 4: Screen Buffer Generation

Output operations are applied to a Screen object — a two-dimensional character matrix. Screen uses pooling and interning mechanisms to optimize memory:

src/ink/screen.ts
TypeScript
1export class CharPool {
2 private strings: string[] = [' ', ''] // Index 0 = space, 1 = empty
3 private stringMap = new Map<string, number>([
4 [' ', 0],
5 ['', 1],
6 ])
7 private ascii: Int32Array = initCharAscii()
8
9 intern(char: string): number {
10 // ASCII fast-path: direct array lookup instead of Map.get
11 if (char.length === 1) {
12 const code = char.charCodeAt(0)
13 if (code < 128) {
14 const cached = this.ascii[code]!
15 if (cached !== -1) return cached
16 const index = this.strings.length
17 this.strings.push(char)
18 this.ascii[code] = index
19 return index
20 }
21 }
22 // ...
23 }
24}

Each character position stores not the string itself but an integer index into the CharPool. This turns inter-frame diff comparisons into integer comparisons rather than string comparisons — a critical performance optimization.

Stage 5: Double-Buffered Frame Diffing

createRenderer maintains Screen buffers for the current and previous frames, implementing double buffering:

src/ink/renderer.ts
TypeScript
1export default function createRenderer(
2 node: DOMElement,
3 stylePool: StylePool,
4): Renderer {
5 let output: Output | undefined
6 return options => {
7 const { frontFrame, backFrame, isTTY, terminalWidth, terminalRows } =
8 options
9 // ...
10 }
11}

The LogUpdate class converts differences between two frames into minimal terminal write operations — only updating character cells that actually changed, rather than redrawing the entire screen.

Stage 6: Terminal Write

The final ANSI sequences are sent to the terminal via stdout.write(). The rendering frame rate is controlled by throttle, with the default interval defined in FRAME_INTERVAL_MS.

Complete Rendering Pipeline Flow

...

The ink/ Directory's Renderer Encapsulation

The src/ink/ directory contains 40+ files forming a complete terminal rendering engine. This is not a simple reference to the npm ink package — the Claude Code team forked and deeply customized the entire rendering layer. Here is the responsibility breakdown of the core modules:

ModuleFileResponsibility
DOM Abstractiondom.tsDefines Ink node types, attribute setting, child node operations, dirty marking
Reconcilerreconciler.tsReact 19 reconciler host config, bridging React Fiber to Ink DOM
Layout Enginelayout/TypeScript wrapper around Yoga Flexbox engine
Style Systemstyles.tsMapping Flexbox properties (flex, margin, padding, border) to Yoga nodes
Rendererrenderer.tsGenerating Screen buffers from Ink DOM tree
Output Pipelineoutput.tsCollecting write/blit/clip operations and applying to Screen
Screenscreen.ts2D character matrix, CharPool/StylePool/HyperlinkPool pooling
Frame Diffinglog-update.tsFront/back frame Screen diff -> minimal ANSI sequences
Node Renderingrender-node-to-output.tsInk DOM tree traversal, handling blit, scroll, border
Text Processingsquash-text-nodes.ts, wrap-text.ts, measure-text.tsText node merging, wrapping, measurement
Character WidthstringWidth.ts, widest-line.tsUnicode full-width/half-width, emoji width calculation
Terminal Abstractionterminal.ts, termio/ANSI CSI/DEC/OSC sequence generation, terminal capability detection
Event Systemevents/Keyboard event dispatch, event bubbling/capturing
Focus Managementfocus.tsTab focus chain, autoFocus
Selection/Searchselection.ts, searchHighlight.tsText selection (mouse drag), search highlighting
Component Librarycomponents/Box, Text, ScrollBox, Link, and other base components

Ink Instance Lifecycle

ink.tsx is the rendering engine's core class (1722 lines), managing the entire Ink instance lifecycle:

src/ink/root.ts
TypeScript
1export const renderSync = (
2 node: ReactNode,
3 options?: NodeJS.WriteStream | RenderOptions,
4): Instance => {
5 const opts = getOptions(options)
6 const inkOptions: InkOptions = {
7 stdout: process.stdout,
8 stdin: process.stdin,
9 stderr: process.stderr,
10 exitOnCtrlC: true,
11 patchConsole: true,
12 ...opts,
13 }
14
15 const instance: Ink = getInstance(
16 inkOptions.stdout,
17 () => new Ink(inkOptions),
18 )
19
20 instance.render(node)
21 // ...
22}

The Ink class in ink.tsx implements the following key responsibilities:

  1. Creating the React container: Creating a ConcurrentRoot via the reconciler (React 19 concurrent mode)
  2. Frame scheduling: Throttling render frames to a fixed interval to avoid over-rendering
  3. Double-buffer management: Alternating between frontFrame / backFrame Screen buffers
  4. Input handling: Reading raw keystrokes from stdin, parsing them into KeyboardEvents, and dispatching via Dispatcher
  5. Mouse/selection: Supporting terminal mouse events, implementing text selection and dragging
  6. Terminal modes: Alt Screen support, Kitty keyboard protocol, modifier key protocol
  7. Debug tools: Commit logging, Yoga counters, redraw debugging

Style System

Ink's style system is a subset of CSS Flexbox. styles.ts defines available style properties and maps them to Yoga layout nodes:

src/ink/styles.ts
TypeScript
1export type Styles = {
2 readonly textWrap?:
3 | 'wrap'
4 | 'wrap-trim'
5 | 'end'
6 | 'middle'
7 // ...flexDirection, alignItems, justifyContent, width, height,
8 // minWidth, minHeight, padding*, margin*, border*, position, overflow...
9}

The color system supports multiple formats:

src/ink/styles.ts
TypeScript
1export type RGBColor = `rgb(${number},${number},${number})`
2export type HexColor = `#${string}`
3export type Ansi256Color = `ansi256(${number})`
4export type AnsiColor =
5 | 'ansi:black'
6 | 'ansi:red'
7 | 'ansi:green'
8 // ...16 ANSI colors

Text styles are defined through the TextStyles type, supporting bold, dim, italic, underline, strikethrough, and inverse — these map directly to ANSI SGR escape sequences.

Organizing 140+ Components

Claude Code's src/components/ directory contains 144 files/directories, forming a clearly layered component system.

Component Layer Architecture

...

Foundation Layer: Ink Native Components

Located in src/ink/components/, 18 files total:

  • Box.tsx: Equivalent to HTML's <div>, supports Flexbox layout
  • Text.tsx: Equivalent to HTML's <span>, supports color, bold, italic, and other text styles
  • ScrollBox.tsx: Box with scroll support, implementing overflow: scroll semantics
  • Link.tsx: Terminal hyperlinks (OSC 8 protocol)
  • RawAnsi.tsx: Direct output of raw ANSI sequences (bypassing Ink's text processing)
  • AlternateScreen.tsx: Switches to the terminal Alt Screen (fullscreen mode)
  • Spacer.tsx: Flexbox spacer, shorthand for flex: 1

These foundation components establish the layout primitives for terminal UI. The subset of properties supported by Box covers nearly the full capability of Flexbox:

TSX
1<Box
2 flexDirection="column"
3 padding={1}
4 borderStyle="round"
5 borderColor="cyan"
6 width="100%"
7>
8 <Text bold color="green">Title</Text>
9 <Text dimColor>Description text</Text>
10</Box>

Application Layer: Business Components

The 140+ files in src/components/ are organized by functional domain into multiple subdirectories and standalone files:

DomainComponents/DirectoryCountResponsibility
Message Renderingmessages/, Message.tsx, Messages.tsx~15Rendering various message types (user, assistant, system, tool result)
Permission Systempermissions/~30Tool execution permission dialogs (Bash, FileEdit, WebFetch, etc.)
Diff ViewsStructuredDiff/, diff/, FileEditToolDiff.tsx~5File edit difference display
Spinner/ProgressSpinner/, ToolUseLoader.tsx, AgentProgressLine.tsx~12Loading animations, tool execution progress
InputPromptInput/, TextInput.tsx, VimTextInput.tsx~5User input box (with Vim mode support)
Dialogs*Dialog.tsx~15Various modal dialogs (model picker, settings, export, etc.)
NavigationFullscreenLayout.tsx, VirtualMessageList.tsx~3Fullscreen layout, virtual scrolling, screen management
Design Systemdesign-system/~5Theme colors, typography components, common UI patterns
MarkdownMarkdown.tsx, MarkdownTable.tsx2Markdown terminal rendering
Code HighlightingHighlightedCode/~2Syntax-highlighted code blocks
Task Managementtasks/, TaskListV2.tsx~5Background task list, agent status
Shellshell/~3Shell command output rendering

Message Type Routing

Message.tsx is the core routing component for message rendering. It dispatches to different sub-components based on message type and content block type:

src/components/Message.tsx
TypeScript
1function MessageImpl(t0) {
2 const {
3 message,
4 lookups,
5 containerWidth,
6 addMargin,
7 tools,
8 commands,
9 verbose,
10 inProgressToolUseIDs,
11 progressMessagesForMessage,
12 shouldAnimate,
13 shouldShowDot,
14 style,
15 width,
16 isTranscriptMode,
17 // ...
18 } = t0;

The message dispatch logic handles the following types:

  • User text message -> UserTextMessage
  • User image message -> UserImageMessage
  • Assistant text output -> AssistantTextMessage -> Markdown
  • Assistant tool call -> AssistantToolUseMessage -> Tool-specific rendering components
  • Tool execution result -> UserToolResultMessage
  • Assistant thinking process -> AssistantThinkingMessage
  • System message -> SystemTextMessage
  • Attachment message -> AttachmentMessage
  • Collapsed read/search group -> CollapsedReadSearchContent
  • Grouped tool calls -> GroupedToolUseContent

Core UI Component Deep Dives

Markdown Terminal Rendering

Markdown.tsx renders Markdown text as terminal-formatted output. It uses the marked library for lexical analysis, then converts tokens into ANSI-formatted strings:

src/components/Markdown.tsx
TypeScript
1// Module-level token cache — marked.lexer is the hot cost on virtual-scroll
2// remounts (~3ms per message). useMemo doesn't survive unmount→remount
3const TOKEN_CACHE_MAX = 500;
4const tokenCache = new Map<string, Token[]>();
5
6// Characters that indicate markdown syntax. If none are present, skip the
7// ~3ms marked.lexer call entirely — render as a single paragraph.
8const MD_SYNTAX_RE = /[#*`|[>\-_~]|\n\n|^\d+\. |\n\d+\. /;
9
10function hasMarkdownSyntax(s: string): boolean {
11 return MD_SYNTAX_RE.test(s.length > 500 ? s.slice(0, 500) : s);
12}
13
14function cachedLexer(content: string): Token[] {
15 // Fast path: plain text with no markdown syntax → single paragraph token.
16 if (!hasMarkdownSyntax(content)) {
17 return [{
18 type: 'paragraph',
19 raw: content,
20 text: content,
21 tokens: [{ type: 'text', raw: content, text: content }]
22 } as Token];
23 }
24 // LRU cache with hash key...
25}

Three key performance optimizations are worth noting:

  1. Fast path detection: A regex first checks the first 500 characters for Markdown syntax markers. Most short messages are plain text and can completely skip the 3ms marked.lexer parsing overhead.

  2. LRU token cache: Message content is immutable, so Markdown tokens for the same message can be reused across frames. Content hashes are used as keys (avoiding memory bloat from retaining full strings), with a maximum cache of 500 entries.

  3. Hybrid rendering: Tables use the React component <MarkdownTable> for rendering (requiring Flexbox for column alignment), while other content uses ANSI strings for direct output (via the <Ansi> component to bypass Ink's text processing).

Markdown rendering also integrates cli-highlight for code block syntax highlighting — content within code fences (```) is syntax-colored by language, with the highlighter loaded asynchronously via Suspense to avoid blocking the initial render.

Tool Execution Progress: Spinner and ToolUseLoader

Real-time feedback during tool execution is accomplished by two components working together:

ToolUseLoader is a minimalist status indicator — a blinking black circle:

src/components/ToolUseLoader.tsx
TypeScript
1export function ToolUseLoader({ isError, isUnresolved, shouldAnimate }) {
2 const [ref, isBlinking] = useBlink(shouldAnimate)
3 const color = isUnresolved ? undefined : isError ? "error" : "success"
4
5 return (
6 <Box ref={ref} minWidth={2}>
7 <Text color={color} dimColor={isUnresolved}>
8 {!shouldAnimate || isBlinking || isError || !isUnresolved
9 ? BLACK_CIRCLE
10 : ' '}
11 </Text>
12 </Box>
13 )
14}

Note the ANSI style conflict issue mentioned in code comments: </dim> and </bold> share the same reset code \x1b[22m in ANSI, and chalk cannot distinguish between them. This causes bold text to become dim when bold follows dim. The solution is to wrap with a <Box> using minWidth={2}, inserting whitespace isolation between dim and bold.

Spinner is a much more complex component (200+ lines), displaying:

  • Animation frames (characters bouncing back and forth)
  • Current operation description ("Reading file...", "Running command...")
  • Elapsed time
  • Token usage
  • Shimmer effect (during streaming responses)
  • Background agent tree status

Spinner uses the useAnimationFrame Hook to update animation frames at fixed intervals, rather than relying on setInterval — this coordinates with Ink's frame scheduling system to avoid unnecessary reconciliation.

File Diff Views

FileEditToolDiff.tsx displays file edit difference views. It uses the Suspense + use() pattern for asynchronous diff data loading:

src/components/FileEditToolDiff.tsx
TypeScript
1export function FileEditToolDiff(props) {
2 const [dataPromise] = useState(() => loadDiffData(props.file_path, props.edits));
3
4 return (
5 <Suspense fallback={<DiffFrame placeholder={true} />}>
6 <DiffBody promise={dataPromise} file_path={props.file_path} />
7 </Suspense>
8 )
9}
10
11function DiffBody({ promise, file_path }) {
12 const { patch, firstLine, fileContent } = use(promise);
13 const { columns } = useTerminalSize();
14
15 return (
16 <DiffFrame>
17 <StructuredDiffList
18 hunks={patch}
19 dim={false}
20 width={columns}
21 filePath={file_path}
22 firstLine={firstLine}
23 fileContent={fileContent}
24 />
25 </DiffFrame>
26 )
27}

The StructuredDiffList component renders diff hunks as terminal-formatted addition/deletion line views, supporting:

  • Line number display
  • Color coding for additions (green +) / deletions (red -)
  • Gray display of context lines
  • Automatic truncation of long lines based on terminal width

Permission Dialogs

The permission system is one of the most complex interaction patterns in Claude Code's UI. PermissionRequest.tsx serves as a router, dispatching to tool-specific permission request components:

src/components/permissions/PermissionRequest.tsx
TypeScript
1function permissionComponentForTool(tool: Tool): ComponentType<PermissionRequestProps> {
2 switch (tool) {
3 case FileEditTool:
4 return FileEditPermissionRequest;
5 case FileWriteTool:
6 return FileWritePermissionRequest;
7 case BashTool:
8 return BashPermissionRequest;
9 case PowerShellTool:
10 return PowerShellPermissionRequest;
11 case WebFetchTool:
12 return WebFetchPermissionRequest;
13 // ... 12+ tool types
14 }
15}

The permissions/ directory contains about 30 files, with a dedicated dialog component for each tool that requires permission. Taking BashPermissionRequest as an example, it displays:

  1. The command to execute (with syntax highlighting)
  2. Working directory
  3. Action options: allow once / allow for session / deny
  4. Optional rule explanation (why permission is needed)
  5. Worker indicator (if from a background agent)

Permission dialogs use useKeybinding to register keyboard shortcut handlers (y to allow, n to deny, number keys to select options), rather than traditional form interactions.

Virtual Scrolling

As conversation history grows, rendering all messages would cause serious performance issues. VirtualMessageList paired with the useVirtualScroll Hook implements virtual scrolling — only rendering messages near the viewport:

src/hooks/useVirtualScroll.ts
TypeScript
1const DEFAULT_ESTIMATE = 3 // Estimated height for unmeasured items (in lines)
2const OVERSCAN_ROWS = 80 // Extra lines rendered above/below viewport
3const COLD_START_COUNT = 30 // Initial render count before ScrollBox has laid out
4const SCROLL_QUANTUM = OVERSCAN_ROWS >> 1 // scrollTop quantization step
5const PESSIMISTIC_HEIGHT = 1 // Worst-case height assumption for unmeasured items
6const MAX_MOUNTED_ITEMS = 300 // Upper limit on mounted items
7const SLIDE_STEP = 25 // Max new mounts per commit

The core virtual scrolling strategies:

  1. Height estimation: Unmeasured items use DEFAULT_ESTIMATE = 3 lines as an initial estimate. Intentionally biased low — overestimating causes bottom whitespace (premature mount stopping), while underestimating just means mounting a few extra items (absorbed by overscan).

  2. Overscan buffer: 80 extra lines of content are rendered above and below the viewport. Because message heights vary wildly (a tool result could be 100+ lines), a large enough buffer is needed.

  3. Scroll quantization: SCROLL_QUANTUM = 40 lines. Triggering a React commit on every scroll wheel tick (3-5 pixels) is unnecessary — as long as the mount range doesn't need to change, visual scrolling is handled by ScrollBox's forceRender (directly reading the DOM node's scrollTop, bypassing React).

  4. Progressive mounting: SLIDE_STEP = 25 limits each commit to at most 25 new mounted items. This prevents synchronous blocking from mounting 200+ items at once when rapidly scrolling to a completely new area (each MessageRow render takes about 1.5ms).

  5. Render-time clamping: scrollClampMin/Max constrains scrollTop to the range of mounted content during rendering. When scrollTo writes arrive before React's async re-render, the user sees the edge of mounted content rather than blank space.

React Hooks in CLI Applications

Claude Code's src/hooks/ directory contains 85 Hooks, covering everything from keyboard interaction and state management to IDE integration. Here are the most representative ones.

useGlobalKeybindings: Global Shortcuts

useGlobalKeybindings.tsx registers application-level keyboard shortcut handlers:

src/hooks/useGlobalKeybindings.tsx
TypeScript
1/**
2 * Registers global keybinding handlers for:
3 * - ctrl+t: Toggle todo list
4 * - ctrl+o: Toggle transcript mode
5 * - ctrl+e: Toggle showing all messages in transcript
6 * - ctrl+c/escape: Exit transcript mode
7 */
8export function GlobalKeybindingHandlers({
9 screen, setScreen,
10 showAllInTranscript, setShowAllInTranscript,
11 messageCount,
12 onEnterTranscript, onExitTranscript,
13 virtualScrollActive, searchBarOpen,
14}: Props): null {

Note this component's return value — null. It is a renderless component: it produces no UI output, only registering side effects through the useKeybinding Hook. This pattern is widely used in Claude Code:

  • GlobalKeybindingHandlers -> global shortcuts
  • CancelRequestHandler -> request cancellation
  • ScrollKeybindingHandler -> scroll control
  • AutoUpdater -> auto-update checking

The advantage of this pattern is encapsulating interaction logic as composable React components, enjoying the convenience of React lifecycle and state management while avoiding the register/unregister issues of imperative event listeners.

The ctrl+o toggle logic demonstrates a typical state machine application within a Hook — it cycles between prompt, transcript, and brief views, handling edge cases for the feature-flag-controlled brief mode.

useCancelRequest: Request Cancellation

useCancelRequest.ts is a more complex example, demonstrating how Hooks handle multi-priority keyboard events:

src/hooks/useCancelRequest.ts
TypeScript
1const handleCancel = useCallback(() => {
2 // Priority 1: If there's an active task running, cancel it first
3 if (abortSignal !== undefined && !abortSignal.aborted) {
4 logEvent('tengu_cancel', cancelProps)
5 setToolUseConfirmQueue(() => [])
6 onCancel()
7 return
8 }
9
10 // Priority 2: Pop queue when Claude is idle
11 if (hasCommandsInQueue()) {
12 if (popCommandFromQueue) {
13 popCommandFromQueue()
14 return
15 }
16 }
17
18 // Fallback: nothing to cancel or pop
19 logEvent('tengu_cancel', cancelProps)
20 setToolUseConfirmQueue(() => [])
21 onCancel()
22}, [abortSignal, popCommandFromQueue, setToolUseConfirmQueue, onCancel, streamMode])

The cancellation logic has a strict priority chain:

  1. Running task -> cancel it
  2. Commands pending in the queue -> pop one
  3. Neither -> clear the permission queue

More complex is the distinction between Escape and Ctrl+C:

src/hooks/useCancelRequest.ts
TypeScript
1// Escape (chat:cancel) defers to mode-exit when in special mode
2const isEscapeActive =
3 isContextActive &&
4 (canCancelRunningTask || hasQueuedCommands) &&
5 !isInSpecialModeWithEmptyInput &&
6 !isViewingTeammate
7
8// Ctrl+C (app:interrupt): must NOT claim ctrl+c when main is idle
9// at the prompt — that blocks the copy-selection handler
10const isCtrlCActive =
11 isContextActive &&
12 (canCancelRunningTask || hasQueuedCommands || isViewingTeammate)

Escape and Ctrl+C have different behaviors in different contexts:

  • Escape yields to mode exit when in a special input mode (bash/background mode)
  • Ctrl+C additionally kills agents + exits teammate view when viewing a teammate
  • Ctrl+C is inactive when the main thread is idle — otherwise it would intercept text copy and double-press-to-exit functionality

Background Agent Termination: Double-Press Confirmation Pattern

useCancelRequest.ts also implements a clever double-press confirmation pattern (chat:killAgents):

src/hooks/useCancelRequest.ts
TypeScript
1const handleKillAgents = useCallback(() => {
2 const tasks = store.getState().tasks
3 const hasRunningAgents = Object.values(tasks).some(
4 t => t.type === 'local_agent' && t.status === 'running',
5 )
6 if (!hasRunningAgents) {
7 addNotification({
8 key: 'kill-agents-none',
9 text: 'No background agents running',
10 priority: 'immediate',
11 timeoutMs: 2000,
12 })
13 return
14 }
15 const now = Date.now()
16 const elapsed = now - lastKillAgentsPressRef.current
17 if (elapsed <= KILL_AGENTS_CONFIRM_WINDOW_MS) {
18 // Second press within window -- kill all background agents
19 lastKillAgentsPressRef.current = 0
20 removeNotification('kill-agents-confirm')
21 killAllAgentsAndNotify()
22 return
23 }
24 // First press -- show confirmation hint
25 lastKillAgentsPressRef.current = now
26 addNotification({
27 key: 'kill-agents-confirm',
28 text: `Press ${shortcut} again to stop background agents`,
29 priority: 'immediate',
30 timeoutMs: KILL_AGENTS_CONFIRM_WINDOW_MS,
31 })
32}, [store, addNotification, removeNotification, killAllAgentsAndNotify])

The first press of ctrl+x ctrl+k only shows a notification prompt; pressing again within 3 seconds actually executes the action. This prevents accidental termination of all background agents. useRef stores the last press time — state isn't used because this value doesn't affect rendering.

More Representative Hooks

HookResponsibilityKey Technical Detail
useBlinkControls element blink animationuseRef + requestAnimationFrame cycle
useTerminalSizeTracks terminal size changesListens to stdout.on('resize')
useVirtualScrollVirtual scroll range calculationuseSyncExternalStore connected to ScrollBox
useExitOnCtrlCDDouble-press Ctrl+C/D to exitTime window detection + useRef
useVimInputVim mode inputMode state machine (NORMAL/INSERT/VISUAL)
useDiffDataAsync diff data loadinguseState(async) + Suspense
useElapsedTimeElapsed time displayuseAnimationFrame-driven updates
useInputBufferInput buffering and debouncingBatch-merging rapid input
usePasteHandlerPaste detection and handlingDistinguishing typing from pasting (time threshold)
useIDEIntegrationIDE connection stateWebSocket event bridging to React state

Ink's Limitations and Workarounds

Limitation 1: No Full CSS Capabilities

Terminal Flexbox is only a subset of CSS Flexbox. Unsupported features:

  • Grid layout: Only Flexbox, no CSS Grid
  • Float/absolute positioning: Yoga's position: absolute support is limited, and "absolute positioning" in the terminal means character-level overwriting
  • Percentage heights: Computing percentage heights in scrollable containers would cause layout cycles
  • Animation: No CSS transition/animation; all animations are JS-driven (setInterval / requestAnimationFrame)

Workarounds:

  • Use flexDirection + alignItems + justifyContent combinations to simulate most layout needs
  • Control animations manually through Hooks like useBlink and useAnimationFrame
  • Implement complex layouts (like multi-column options in permission dialogs) with nested Box components

Limitation 2: Terminal Rendering is Global

In a browser, each DOM element renders independently, but a terminal is a global character matrix. This means:

  • Redraw scope: Any node change can potentially trigger recalculation of the entire Screen
  • z-index simulation: position: absolute nodes implement "stacking" by overwriting characters, but removing them requires marking the entire frame as "contaminated", forcing a full redraw
src/ink/render-node-to-output.ts
TypeScript
1// Removing an absolute-positioned node poisons prevScreen: it may
2// have painted over non-siblings (e.g. an overlay over a ScrollBox
3// earlier in tree order), so their blits would restore the removed
4// node's pixels.
5const absoluteRemoved = consumeAbsoluteRemovedFlag()
6renderNodeToOutput(node, output, {
7 prevScreen: absoluteRemoved || options.prevFrameContaminated
8 ? undefined
9 : prevScreen,
10})

Workarounds: The blit optimization directly copies previous frame data when nodes haven't changed. Layout shift detection tracks node position changes, triggering full redraws only when necessary.

Limitation 3: Inconsistent Character Widths

Unicode characters may have different widths in different terminals. CJK characters (Chinese, Japanese, Korean) occupy 2 character widths, emoji may occupy 1 or 2 widths, and some special characters (zero-width joiners, etc.) occupy 0 width.

src/ink/output.ts
TypeScript
1type ClusteredChar = {
2 value: string
3 width: number // Pre-computed terminal width
4 styleId: number
5 hyperlink: string | undefined
6}

Workarounds: stringWidth.ts provides precise Unicode width calculation, and ClusteredChar pre-computes and caches the width of each grapheme cluster, avoiding repeated calculation in the rendering hot path.

Limitation 4: ANSI Style Code Conflicts

The shared bold/dim reset code issue mentioned earlier (\x1b[22m) is a historical defect in the ANSI standard. The chalk library cannot distinguish between </bold> and </dim> — closing one closes both simultaneously.

Workarounds: Avoid mixing bold and dim between adjacent inline elements. When mixing is necessary, separate them with <Box> (block-level elements reset ANSI state). The minWidth={2} in ToolUseLoader is an instance of this strategy.

Limitation 5: Alt Screen Height Constraint

In Alt Screen mode, content height must exactly equal the terminal row count. If Yoga's calculated height exceeds the terminal rows, cursor positioning becomes corrupted:

src/ink/renderer.ts
TypeScript
1const height = options.altScreen ? terminalRows : yogaHeight
2if (options.altScreen && yogaHeight > terminalRows) {
3 logForDebugging(
4 `alt-screen: yoga height ${yogaHeight} > terminalRows ${terminalRows} — ` +
5 `something is rendering outside <AlternateScreen>. Overflow clipped.`,
6 { level: 'warn' },
7 )
8}

Workarounds: The <AlternateScreen> component wraps children in <Box height={rows} flexShrink={0}>, forcing height to equal the terminal row count. Overflowing content is silently clipped, and a warning log is emitted.

Portable Patterns

Claude Code's terminal UI architecture contains several patterns worth adopting in other CLI projects:

Pattern 1: Renderless Components

Encapsulate interaction logic as React components that return null:

TSX
1function MyKeybindingHandler({ onAction }): null {
2 useKeybinding('action:trigger', onAction, {
3 context: 'Global',
4 isActive: true,
5 })
6 return null
7}

Advantages:

  • Enjoy React's lifecycle management (automatic event listener cleanup)
  • Composable (render in parallel with other components)
  • Conditionally renderable (via isActive or {condition && <Handler />})

Pattern 2: Double Buffering + Frame Diffing

Maintain two Screen buffers, writing only changed cells to the terminal each frame:

Text
1Frame N: [H][e][l][l][o][ ][ ]
2Frame N+1: [H][e][l][l][o][!][ ]
3Diff: [!] -> write only 1 character

This makes steady-state rendering (where only a spinner is changing) approach O(1) cost, rather than O(rows * cols).

Pattern 3: Virtual Scrolling + Progressive Mounting

For long conversations that may contain hundreds of messages:

  1. Only mount messages near the viewport (useVirtualScroll)
  2. Fill unmounted areas with spacers
  3. Progressively mount during rapid scrolling (max 25 items per frame)
  4. Render-time clamping to prevent blank flashes

Pattern 4: Suspense Async Data Loading

Use useState(() => promise) + use(promise) + <Suspense> for async data:

TSX
1function DiffView({ file, edits }) {
2 const [dataPromise] = useState(() => loadDiffData(file, edits));
3 return (
4 <Suspense fallback={<Placeholder />}>
5 <DiffBody promise={dataPromise} />
6 </Suspense>
7 )
8}

This shows a placeholder while data loads, then automatically replaces it when complete — identical to Suspense usage in the browser.

Pattern 5: Double-Press Confirmation for Destructive Actions

For irreversible actions (like terminating all background agents), use time-windowed double confirmation:

TSX
1const CONFIRM_WINDOW_MS = 3000
2const lastPressRef = useRef<number>(0)
3
4const handleDangerousAction = useCallback(() => {
5 const elapsed = Date.now() - lastPressRef.current
6 if (elapsed <= CONFIRM_WINDOW_MS) {
7 // Execute
8 performAction()
9 lastPressRef.current = 0
10 } else {
11 // Show hint
12 lastPressRef.current = Date.now()
13 showNotification("Press again to confirm")
14 }
15}, [])

Pattern 6: Pooled String Interning

For strings that need frequent comparison (characters, styles, hyperlinks), use an intern pool to map strings to integer IDs:

TypeScript
1class CharPool {
2 private strings: string[] = [' ', '']
3 private stringMap = new Map<string, number>()
4 private ascii: Int32Array // ASCII fast path
5
6 intern(char: string): number {
7 if (char.length === 1) {
8 const code = char.charCodeAt(0)
9 if (code < 128) {
10 const cached = this.ascii[code]!
11 if (cached !== -1) return cached
12 // ...
13 }
14 }
15 // ...
16 }
17}

Inter-frame diff comparison becomes integer comparison (===) rather than string comparison, yielding significant performance gains at the millions-of-operations scale.

Summary

Claude Code's terminal UI architecture showcases a deeply customized React terminal rendering solution. From the custom react-reconciler host config, to the Yoga layout engine, double-buffered Screen diffing, virtual scrolling, and through to 140+ business components and 85 Hooks — it is one of the most complex terminal React applications known.

Key takeaways:

  1. Rendering pipeline: JSX -> React Reconcile -> Ink DOM -> Yoga Layout -> Screen Buffer -> ANSI diff -> stdout. A six-stage pipeline where blit optimization makes steady-state frames approach zero cost
  2. Component architecture: Foundation layer (Box/Text/ScrollBox) + Application layer (Message/Permission/Diff/Spinner), with message routing dispatching by type to dedicated rendering components
  3. Performance strategies: Markdown token LRU cache, virtual scroll progressive mounting, string pool interning, scroll quantization to reduce React commits
  4. Interaction patterns: Renderless components for shortcut logic, multi-priority cancellation chain, time-window double confirmation
  5. Limitation mitigation: bold/dim ANSI code conflict isolation with Box, Alt Screen height forced clamping, absolute node removal marking full-frame contamination

This architecture proves an important point: React's value lies not just in DOM rendering, but in its declarative programming model and component-based architecture. After switching the render target to a terminal character matrix, React's state management, lifecycle, Hooks, Suspense and other core capabilities remain perfectly applicable — and these are precisely the capabilities needed to build complex interactive CLI applications.