Claude Code 架构全解Claude Code Architecture, in Full第 10 章 · 14 章Chapter 10 of 14
全文目录Contents
  1. 0 · 项目全景与代码地图
    1. 0.1 这个软件是什么
    2. 0.2 源码目录逐个解释
    3. 0.3 从这张地图能读出的三件事
    4. 0.4 一次完整请求的旅程(全文导航)
    5. 0.5 全文章节索引
  2. 1 · 入口层与启动流程
    1. 1.1 四种启动形态
    2. 1.2 启动的第一个设计:快路径分派
    3. 1.3 命令行参数:60 多个选项
    4. 1.4 --bare:一个值得单独讲的极简模式
    5. 1.5 启动时序
    6. 1.6 系统提示词的三段结构
  3. 2 · 会话层:QueryEngine
    1. 2.1 它解决什么问题
    2. 2.2 它持有哪些状态
    3. 2.3 一次 submitMessage 的完整流程
    4. 2.4 用户消息为什么必须先落盘
    5. 2.5 消费主循环输出:一个大 switch
    6. 2.6 压缩分界点:主动释放内存
    7. 2.7 三种退出结果
    8. 2.8 ask():一次性调用的便捷封装
  4. 3 · 智能体主循环 ★
    1. 3.1 循环的骨架
    2. 3.2 State:把跨轮次状态集中管理
    3. 3.3 transition:只为可测试而存在的字段
    4. 3.4 七条转移边逐条详解
    5. 3.5 错误扣留机制
    6. 3.6 中断处理
    7. 3.7 模型降级:三个动作
    8. 3.8 思考块三定律
    9. 3.9 循环里的其他机制
    10. 3.10 循环的所有退出点
  5. 4 · 工具模型
    1. 4.1 Tool 接口:七组正交能力
    2. 4.2 为什么「安全谓词」值得单独成组
    3. 4.3 失败保守默认值
    4. 4.4 40 个内建工具分类
    5. 4.5 渐进式工具加载
    6. 4.6 工具清单装配:一个关于缓存的隐藏约束
    7. 4.7 backfillObservableInput:一个极致的缓存保护例子
  6. 5 · 工具执行
    1. 5.1 执行链路总览
    2. 5.2 并发分区:贪心算法
    3. 5.3 上下文修改要排队到批次结束
    4. 5.4 单次执行:runToolUse 的完整流程
    5. 5.5 流式工具执行器
    6. 5.6 兄弟中止控制器:最漂亮的一处设计
    7. 5.7 丢弃机制
    8. 5.8 「墓碑」消息
    9. 5.9 结果的最终处理
  7. 6 · 上下文治理 ★
    1. 6.1 五级流水线
    2. 6.2 第 ① 级:工具结果预算
    3. 6.3 第 ③ 级:微压缩与缓存编辑
    4. 6.4 第 ⑤ 级:自动摘要压缩
    5. 6.5 上下文真的超了:三级恢复瀑布
  8. 7 · 权限系统
    1. 7.1 六种权限模式
    2. 7.2 十步决策级联
    3. 7.3 自动模式:模型分类器 + 三级快速通道
    4. 7.4 权限规则的语法
    5. 7.5 沙箱与只读命令判定
    6. 7.6 权限判定的完整数据结构
    7. 7.7 权限决策的可解释性
  9. 8 · 子智能体
    1. 8.1 首要动机是上下文隔离,不是并行
    2. 8.2 三种形态
    3. 8.3 分叉:把提示词缓存用到极致
    4. 8.4 子智能体的工具限制
    5. 8.5 子智能体的上下文构造
    6. 8.6 内建智能体类型
    7. 8.7 后台任务的几种形态
    8. 8.8 中断的级联
  10. 9 · 扩展体系
    1. 9.1 四类扩展点对照
    2. 9.2 技能系统
    3. 9.3 插件系统
    4. 9.4 MCP 客户端
    5. 9.5 钩子:15 类生命周期事件
    6. 9.6 输出样式
  11. 10 · 终端界面层
    1. 10.1 用 React 写终端界面
    2. 10.2 最大的四个组件
    3. 10.3 输入框为什么有 347 KB
    4. 10.4 虚拟消息列表
    5. 10.5 工具结果的六种渲染状态
    6. 10.6 折叠:避免刷屏
    7. 10.7 87 个状态管理单元
    8. 10.8 界面和内核的接口:ToolUseContext 里的回调
    9. 10.9 一个有趣的细节:ANSI 转 PNG
  12. 11 · 持久化与恢复
    1. 11.1 对话记录的格式:JSONL
    2. 11.2 对话记录是一棵树,不是一个列表
    3. 11.3 写入队列
    4. 11.4 子智能体的记录:支链文件
    5. 11.5 恢复:三种方式
    6. 11.6 文件历史:智能体改过的文件可以回滚
    7. 11.7 配置的多来源与迁移
    8. 11.8 记忆目录
  13. 12 · 可观测体系
    1. 12.1 埋点密度
    2. 12.2 事件命名
    3. 12.3 查询链路追踪
    4. 12.4 类型层面的隐私保护
    5. 12.5 缓存断裂检测
    6. 12.6 性能剖析检查点
    7. 12.7 慢操作日志
    8. 12.8 内存错误缓冲区
    9. 12.9 内部错误的响亮日志
    10. 12.10 埋点的成本意识
  14. 13 · 构建与分发
    1. 13.1 Bun 单文件可执行程序
    2. 13.2 编译期特性开关:89 个
    3. 13.3 死代码消除:为什么这不只是「if 判断」
    4. 13.4 编译期宏
    5. 13.5 运行时特性开关:另一套系统
    6. 13.6 版本与更新
    7. 13.7 从构建方式反推的架构约束
  1. 0 · The Project at a Glance, and a Map of the Code
    1. 0.1 What this software is
    2. 0.2 The source tree, directory by directory
    3. 0.3 Three things you can read off this map
    4. 0.4 The journey of one complete request (a map of the whole book)
    5. 0.5 Chapter index
  2. 1 · The Entry Layer and Startup
    1. 1.1 Four launch modes
    2. 1.2 The first design decision at startup: fast-path dispatch
    3. 1.3 Command-line arguments: 60-plus options
    4. 1.4 --bare: a minimal mode worth its own section
    5. 1.5 Startup sequence
    6. 1.6 The three-part structure of the system prompt
  3. 2 · The Session Layer: QueryEngine
    1. 2.1 The problem it solves
    2. 2.2 What state it holds
    3. 2.3 The full flow of one submitMessage
    4. 2.4 Why the user message must hit disk first
    5. 2.5 Consuming the main loop’s output: one big switch
    6. 2.6 The compact boundary: proactively releasing memory
    7. 2.7 Three kinds of exit result
    8. 2.8 ask(): a convenience wrapper for one-shot calls
  4. 3 · The Agent Main Loop ★
    1. 3.1 The skeleton of the loop
    2. 3.2 State: centralizing cross-iteration state
    3. 3.3 transition: a field that exists purely for testability
    4. 3.4 The seven transition edges, one by one
    5. 3.5 The error-withholding mechanism
    6. 3.6 Interrupt handling
    7. 3.7 Model fallback: three actions
    8. 3.8 The three laws of thinking blocks
    9. 3.9 Other mechanisms in the loop
    10. 3.10 Every exit point of the loop
  5. 4 · The Tool Model
    1. 4.1 The Tool interface: seven orthogonal capability groups
    2. 4.2 Why the “safety predicates” deserve their own group
    3. 4.3 Fail-safe defaults
    4. 4.4 The 40 built-in tools, by category
    5. 4.5 Progressive tool loading
    6. 4.6 Tool list assembly: a hidden constraint about caching
    7. 4.7 backfillObservableInput: an extreme example of cache protection
  6. 5 · Tool Execution
    1. 5.1 The execution pipeline at a glance
    2. 5.2 Concurrency partitioning: a greedy algorithm
    3. 5.3 Context modifications are queued until the batch ends
    4. 5.4 A single execution: the full flow of runToolUse
    5. 5.5 The streaming tool executor
    6. 5.6 The sibling abort controller: the most elegant design in the file
    7. 5.7 The discard mechanism
    8. 5.8 “Tombstone” messages
    9. 5.9 Final processing of results
  7. 6 · Context Management ★
    1. 6.1 The five-tier pipeline
    2. 6.2 Tier ①: the tool result budget
    3. 6.3 Tier ③: micro-compaction and cache edits
    4. 6.4 Tier ⑤: auto summary compaction
    5. 6.5 When the context really does overflow: the three-tier recovery cascade
  8. 7 · The Permission System
    1. 7.1 Six Permission Modes
    2. 7.2 The Ten-Step Decision Cascade
    3. 7.3 Auto Mode: Model Classifier + Three-Tier Fast Path
    4. 7.4 Permission Rule Syntax
    5. 7.5 Sandboxing and Read-Only Command Detection
    6. 7.6 The Full Data Structure Behind a Permission Decision
    7. 7.7 Explainability of Permission Decisions
  9. 8 · Subagents
    1. 8.1 The Primary Motivation Is Context Isolation, Not Parallelism
    2. 8.2 Three Forms
    3. 8.3 Forking: Pushing Prompt Caching to the Limit
    4. 8.4 Tool Restrictions on Subagents
    5. 8.5 Constructing a Subagent's Context
    6. 8.6 Built-in Agent Types
    7. 8.7 The Forms Background Tasks Take
    8. 8.8 Cascading Interruption
  10. 9 · The Extension System
    1. 9.1 The Four Extension Points Compared
    2. 9.2 The Skill System
    3. 9.3 The Plugin System
    4. 9.4 The MCP Client
    5. 9.5 Hooks: 15 Kinds of Lifecycle Events
    6. 9.6 Output Styles
  11. 10 · The Terminal UI Layer
    1. 10.1 Writing a Terminal UI in React
    2. 10.2 The Four Biggest Components
    3. 10.3 Why the Input Box Is 347 KB
    4. 10.4 The Virtualized Message List
    5. 10.5 Six Rendering States for Tool Results
    6. 10.6 Collapsing: Avoiding Screen Flood
    7. 10.7 87 State-Management Units
    8. 10.8 The Interface Between UI and Kernel: Callbacks in ToolUseContext
    9. 10.9 A Fun Detail: ANSI to PNG
  12. 11 · Persistence and Resume
    1. 11.1 The Transcript Format: JSONL
    2. 11.2 The Transcript Is a Tree, Not a List
    3. 11.3 The Write Queue
    4. 11.4 Subagent Records: Sidechain Files
    5. 11.5 Resume: Three Ways
    6. 11.6 File History: Rolling Back Files the Agent Changed
    7. 11.7 Multi-Source Settings and Migrations
    8. 11.8 The Memory Directory
  13. 12 · The Observability System
    1. 12.1 Instrumentation Density
    2. 12.2 Event Naming
    3. 12.3 Query Chain Tracing
    4. 12.4 Privacy Protection at the Type Level
    5. 12.5 Cache-Break Detection
    6. 12.6 Profiling Checkpoints
    7. 12.7 Slow-Operation Logging
    8. 12.8 The In-Memory Error Buffer
    9. 12.9 Loud Logging for Internal Errors
    10. 12.10 Cost-Awareness in Instrumentation
  14. 13 · Build and Distribution
    1. 13.1 The Bun Single-File Executable
    2. 13.2 Compile-Time Feature Flags: 89 of Them
    3. 13.3 Dead-Code Elimination: Why It's Not Just an “if”
    4. 13.4 Compile-Time Macros
    5. 13.5 Runtime Feature Flags: A Separate System
    6. 13.6 Versions and Updates
    7. 13.7 Architectural Constraints Inferred from the Build

10 · 终端界面层

146 个界面组件、87 个状态管理单元、50 个定制版框架文件。这一层占了整个代码库体量的很大一块,但在架构讨论里几乎从不被提及。这一章补上。

10.1 用 React 写终端界面

先解释这件事本身:Ink 是一个让你用 React 语法写终端界面的框架。

你写的: Ink 做的: <Box flexDirection="column"> 计算布局(用 Flexbox 算法) <Text color="green"> 把结果渲染成一大块字符串 Hello (含 ANSI 转义序列来控制颜色和位置) </Text> </Box> 把字符串写到终端

好处是可以复用 React 的整套心智模型:组件化、状态驱动重渲染、钩子。代价是你在和一个只能显示等宽字符的、没有像素概念的、还会被用户随时改变尺寸的「画布」打交道

Claude Code 自己 fork 了一份 Ink

src/ink/ 目录有 50 个文件,是他们定制的 Ink 版本。从文件名能看出他们改了什么:

文件做什么
bidi.ts双向文本处理 —— 阿拉伯语、希伯来语这类从右往左书写的文字,和英文混排时的排版规则
line-width-cache.ts行宽缓存 —— 计算一行字符占多少列是个昂贵操作(中文占 2 列、emoji 占 2 列、组合字符更复杂),必须缓存
measure-text.ts / measure-element.ts文本和元素的尺寸测量
hit-test.ts命中测试 —— 判断鼠标点击落在哪个元素上(终端也支持鼠标)
log-update.ts原地更新已输出的内容 —— 这是流式界面的基础
Ansi.tsx / colorize.tsANSI 转义序列处理(终端的颜色和格式控制码)
frame.ts帧管理
focus.ts焦点管理 —— Tab 键在哪些元素之间跳转

为什么要 fork 而不是用上游版本?因为上游 Ink 是一个通用框架,性能取舍面向的是「偶尔更新的小界面」。而 Claude Code 的场景是模型流式输出时每秒重渲染几十次、消息列表有几千条、终端窗口可能很大

line-width-cache.ts 这个文件的存在就是证据:字符宽度计算被拿出来单独优化了。在一个每秒重渲染几十次的界面里,这个函数会被调用几十万次。

10.2 最大的四个组件

组件大小它复杂在哪
PromptInput.tsx347 KB输入框。见 10.3
Settings/Config.tsx265 KB设置界面。几十个配置项,每个都要有输入控件、校验、说明文字
LogSelector.tsx196 KB会话选择器(--resume 时的那个列表)。要读取所有历史会话、显示摘要、支持搜索和键盘导航
VirtualMessageList.tsx145 KB虚拟消息列表。见 10.4

10.3 输入框为什么有 347 KB

一个「输入框」听起来应该很简单。但这个输入框要处理:

功能复杂度来源
多行编辑终端里没有原生的多行输入控件。光标移动、换行、自动折行全部要自己实现
Vim 模式src/vim/ 有 7 个文件。要实现普通模式 / 插入模式 / 可视模式,以及 dwciw 这类组合键
斜杠命令补全/ 时弹出候选列表,实时过滤,方向键选择
@ 文件提及@ 时弹出文件路径补全,要实时搜索工作目录
图片粘贴从剪贴板读图片(NATIVE_CLIPBOARD_IMAGE 特性开关),转成模型能接受的格式
历史回溯上下方向键翻之前发过的消息(useArrowKeyHistory.tsx
输入队列模型正在思考时用户又敲了一句,要排队而不是丢弃(useCommandQueue.ts
粘贴大块文本粘贴几千行时不能逐字符处理(会卡死),要特殊路径
双向文本阿拉伯语等从右往左的文字,光标位置和视觉位置不一致
快捷键keybindings/ 有 16 个文件,用户可以自定义所有快捷键

这解释了一个常见的错觉:看架构图时,「界面层」通常只是最上面一个小方块。但在真实项目里,界面往往是代码量最大的部分 —— 因为它要处理人类行为的全部混乱性,而人类行为没有规范文档。

10.4 虚拟消息列表

一场长会话可能有几千条消息。如果每次重渲染都遍历全部消息、计算它们的布局,界面会卡到不可用。

「虚拟化」的意思是:只渲染当前视口里能看到的那几条,其余的只记住它们占多高。

相关的几个组件:

  • VirtualMessageList.tsx(145 KB)—— 虚拟化列表本体
  • Messages.tsx(144 KB)—— 消息渲染的分发逻辑
  • ScrollKeybindingHandler.tsx(146 KB)—— 滚动和键盘导航

难点在于:终端里的「一条消息占多高」不是固定的。它取决于终端宽度(窗口一改变,所有消息的高度全变)、内容是否折行、是否有代码块、是否被折叠。所以要缓存高度、在宽度变化时批量重算。

10.5 工具结果的六种渲染状态

回顾第 4.1 节,Tool 接口有 10 多个渲染方法。它们对应工具调用的不同状态:

模型开始输出工具调用 └─ renderToolUseMessage(部分参数) ★ 注意"部分参数"——参数还在流式输入中,可能只到一半 ↓ 排队等待执行 └─ renderToolUseQueuedMessage() ↓ 执行中 └─ renderToolUseProgressMessage(进度消息数组) · getActivityDescription() → 加载动画旁的文字("正在读取 src/foo.ts") ↓ ├─ 成功 → renderToolResultMessage(输出) │ · isResultTruncated(输出) → 决定要不要显示"点击展开" │ · getToolUseSummary(输入) → 紧凑视图下的一行摘要 ├─ 被拒 → renderToolUseRejectedMessage(输入) │ 例如文件编辑被拒时,显示被拒绝的差异对比 └─ 出错 → renderToolUseErrorMessage(错误内容) 另外:多个并行调用可以合并显示 └─ renderGroupedToolUse(调用数组)

renderToolUseMessage 接收「部分参数」这一点值得注意:

/**
 * Render the tool use message. Note that `input` is partial because we render
 * the message as soon as possible, possibly before tool parameters have fully
 * streamed in.
 */
renderToolUseMessage(input: Partial<z.infer<Input>>, options): React.ReactNode

为了让用户尽早看到「智能体开始做什么了」,界面在参数还没流完时就开始渲染。所以每个渲染函数都必须能处理「字段可能不存在」的情况。

10.6 折叠:避免刷屏

/**
 * Returns information about whether this tool use is a search or read operation
 * that should be collapsed into a condensed display in the UI. Examples include
 * file searching (Grep, Glob), file reading (Read), and bash commands like find,
 * grep, wc, etc.
 *
 * - `isSearch: true` for search operations (grep, find, glob patterns)
 * - `isRead: true` for read operations (cat, head, tail, file read)
 * - `isList: true` for directory-listing operations (ls, tree, du)
 */
isSearchOrReadCommand?(input): { isSearch: boolean; isRead: boolean; isList?: boolean }

智能体在探索代码库时可能连续读 20 个文件。如果每次读取都完整显示内容,用户的屏幕会被刷满,真正重要的信息(模型的思考和结论)会被淹没

所以这类操作被折叠成一行,比如「Read 20 files」。而且判断依据是「这次调用的具体内容」而不是「工具类型」 —— 同样是 Bash 工具,跑 grep 要折叠,跑 npm test 不能折叠(用户需要看到测试输出)。

10.7 87 个状态管理单元

hooks/ 目录下是 React 的自定义钩子(和第 9 章的「用户钩子」是完全不同的东西,只是英文都叫 hook)。从名字能看出界面要管理多少种状态:

钩子管什么
useCanUseTool.tsx权限确认的界面流程(这个是连接界面层和权限层的桥
useCommandQueue.ts用户在模型思考时输入的消息队列
useCancelRequest.tsCtrl+C 的处理
useArrowKeyHistory.tsx方向键翻历史
useTypeahead.tsx(208 KB)补全提示(最大的一个钩子)
useDiffData.ts / useDiffInIDE.ts差异对比的数据与在编辑器里打开
useDoublePress.ts双击检测(比如连按两次 Esc)
useBlink.ts光标闪烁
useCopyOnSelect.ts选中即复制
useDeferredHookMessages.ts延迟显示钩子消息(避免快钩子闪烁)
useBackgroundTaskNavigation.ts在多个后台任务之间切换查看
useAwaySummary.ts用户离开一段时间回来后的摘要

10.8 界面和内核的接口:ToolUseContext 里的回调

第 3 章讲的主循环完全不知道界面的存在。它们之间的接口是 ToolUseContext 里的一组可选回调函数:

setToolJSX?: SetToolJSXFn                    // 让工具往界面上插入自定义组件
addNotification?: (notif: Notification) => void
appendSystemMessage?: (msg) => void          // 追加一条仅界面可见的系统消息
sendOSNotification?: (opts) => void          // 操作系统级通知(iTerm2/Kitty/铃声)
setInProgressToolUseIDs: (f) => void         // 哪些工具正在执行(画加载动画)
setHasInterruptibleToolInProgress?: (v) => void
setResponseLength: (f) => void
setStreamMode?: (mode: SpinnerMode) => void  // 加载动画的形态
onCompactProgress?: (event: CompactProgressEvent) => void
setSDKStatus?: (status: SDKStatus) => void
openMessageSelector?: () => void
requestPrompt?: (sourceName, summary) => (request) => Promise<PromptResponse>

全部是可选的(带 ?)。这是关键 —— 无头模式下这些回调都不存在,内核照常工作,只是不产生任何界面副作用。

其中一个回调的注释解释了这种设计的边界:

/** Append a UI-only system message to the REPL message list. Stripped at the
 *  normalizeMessagesForAPI boundary — the Exclude<> makes that type-enforced. */
appendSystemMessage?: (msg: Exclude<SystemMessage, SystemLocalCommandMessage>) => void

译:往交互界面的消息列表里追加一条「仅界面可见」的系统消息。它会在「规范化成接口格式」的边界处被剥离 —— 那个 Exclude 类型让这一点在类型层面被强制。

「仅界面可见的消息」是一个必要但危险的概念。必要是因为很多信息(「已切换到备用模型」「压缩完成,省了 3 万 token」)只对人有意义,塞给模型是浪费。

危险是因为一旦某条界面消息漏进了发给模型的数组,它就成了污染。所以 Claude Code 用类型系统强制:这个回调只接受特定类型的消息,而那个类型在转换成接口格式时会被静态排除。不是靠「记得过滤」,是靠「编译不过」。

10.9 一个有趣的细节:ANSI 转 PNG

utils/ansiToPng.ts,209.9 KB —— 是 utils/ 目录下最大的文件。

它做的事情是:把终端的输出(带 ANSI 颜色控制码的文本)渲染成一张 PNG 图片。

用途是「分享」功能 —— 用户想把一段对话发给同事看时,纯文本会丢失所有颜色和格式。转成图片就能完整保留终端的视觉效果。

为什么这么大?因为要自己实现一个字体渲染器:解析 ANSI 序列 → 计算每个字符的位置 → 把字形绘制到像素画布上 → 处理中文/emoji 的宽度 → 编码成 PNG。这些在浏览器里是免费的(浏览器帮你做了),在一个命令行程序里全部要自己写。

10 · The Terminal UI Layer

146 UI components, 87 state-management units, 50 files of a customized framework. This layer accounts for a large slice of the codebase's bulk, yet it almost never comes up in architecture discussions. This chapter fills that gap.

10.1 Writing a Terminal UI in React

First, the thing itself: Ink is a framework that lets you write terminal UIs in React syntax.

What you write: What Ink does: <Box flexDirection="column"> Computes the layout (using the Flexbox algorithm) <Text color="green"> Renders the result into one big string Hello (with ANSI escape sequences for color and position) </Text> </Box> Writes the string to the terminal

The upside is that you reuse React's entire mental model: components, state-driven re-rendering, hooks. The cost is that you're dealing with a “canvas” that can only show monospaced characters, has no concept of pixels, and can be resized by the user at any moment.

Claude Code forked its own copy of Ink

The src/ink/ directory has 50 files — their customized version of Ink. The filenames show what they changed:

FileWhat it does
bidi.tsBidirectional text handling — the layout rules for right-to-left scripts like Arabic and Hebrew when mixed with English
line-width-cache.tsLine-width cache — computing how many columns a line of characters occupies is expensive (Chinese takes 2 columns, emoji take 2, combining characters are worse) and has to be cached
measure-text.ts / measure-element.tsSize measurement for text and elements
hit-test.tsHit testing — figuring out which element a mouse click landed on (terminals support the mouse too)
log-update.tsUpdating already-printed output in place — the foundation of a streaming UI
Ansi.tsx / colorize.tsANSI escape-sequence handling (the terminal's color and formatting control codes)
frame.tsFrame management
focus.tsFocus management — which elements the Tab key cycles through

Why fork instead of using upstream? Because upstream Ink is a general-purpose framework whose performance trade-offs target “small UIs that update occasionally.” Claude Code's scenario is dozens of re-renders per second while the model streams output, message lists thousands of entries long, and terminal windows that can be very large.

The existence of line-width-cache.ts is the evidence: character-width computation was pulled out and optimized on its own. In a UI that re-renders dozens of times a second, that function gets called hundreds of thousands of times.

10.2 The Four Biggest Components

ComponentSizeWhere the complexity is
PromptInput.tsx347 KBThe input box. See 10.3
Settings/Config.tsx265 KBThe settings UI. Dozens of config options, each needing an input control, validation, and help text
LogSelector.tsx196 KBThe session picker (the list you see with --resume). Has to read every past session, show summaries, and support search and keyboard navigation
VirtualMessageList.tsx145 KBThe virtualized message list. See 10.4

10.3 Why the Input Box Is 347 KB

An “input box” sounds like it should be simple. But this one has to handle:

FeatureSource of complexity
Multi-line editingTerminals have no native multi-line input control. Cursor movement, line breaks, and word wrap all have to be implemented by hand
Vim modesrc/vim/ has 7 files. Normal / insert / visual modes, plus key combos like dw and ciw
Slash-command completionTyping / pops up a candidate list, filtered live, selectable with the arrow keys
@ file mentionsTyping @ pops up file-path completion, which has to search the working directory live
Image pasteReads images from the clipboard (the NATIVE_CLIPBOARD_IMAGE feature flag) and converts them to a format the model accepts
History navigationUp/down arrows scroll through previously sent messages (useArrowKeyHistory.tsx)
Input queueIf the user types another line while the model is thinking, it has to be queued rather than dropped (useCommandQueue.ts)
Pasting large textPasting thousands of lines can't be processed character by character (it would freeze); it needs a special path
Bidirectional textFor right-to-left scripts like Arabic, cursor position and visual position don't line up
Keyboard shortcutskeybindings/ has 16 files; users can customize every shortcut

This explains a common illusion: on an architecture diagram, the “UI layer” is usually just a small box at the top. But in real projects, the UI is often the largest part of the code — because it has to handle the full messiness of human behavior, and human behavior has no spec.

10.4 The Virtualized Message List

A long session can have thousands of messages. If every re-render walked all of them and computed their layout, the UI would freeze into uselessness.

“Virtualization” means: render only the few messages visible in the current viewport, and for the rest, remember only how tall they are.

The related components:

  • VirtualMessageList.tsx (145 KB) — the virtualized list itself
  • Messages.tsx (144 KB) — the dispatch logic for rendering messages
  • ScrollKeybindingHandler.tsx (146 KB) — scrolling and keyboard navigation

The hard part: in a terminal, “how tall a message is” isn't fixed. It depends on terminal width (resize the window and every message's height changes), whether the content wraps, whether it contains code blocks, whether it's collapsed. So heights have to be cached and recomputed in bulk when the width changes.

10.5 Six Rendering States for Tool Results

Recall from section 4.1 that the Tool interface has more than 10 rendering methods. They correspond to the different states of a tool call:

The model starts emitting a tool call └─ renderToolUseMessage(partial input) ★ Note "partial input": the arguments are still streaming in and may be only half there ↓ Queued, waiting to run └─ renderToolUseQueuedMessage() ↓ Running └─ renderToolUseProgressMessage(progress messages) · getActivityDescription() → the text next to the spinner ("Reading src/foo.ts") ↓ ├─ success → renderToolResultMessage(output) │ · isResultTruncated(output) → decides whether to show "click to expand" │ · getToolUseSummary(input) → the one-line summary in compact view ├─ rejected → renderToolUseRejectedMessage(input) │ e.g. when a file edit is rejected, show the rejected diff └─ error → renderToolUseErrorMessage(error) Also: several parallel calls can be displayed merged └─ renderGroupedToolUse(calls)

That renderToolUseMessage receives “partial input” is worth noting:

/**
 * Render the tool use message. Note that `input` is partial because we render
 * the message as soon as possible, possibly before tool parameters have fully
 * streamed in.
 */
renderToolUseMessage(input: Partial<z.infer<Input>>, options): React.ReactNode

To let the user see “what the agent has started doing” as early as possible, the UI starts rendering before the arguments have finished streaming. So every render function has to cope with “this field might not exist yet.”

10.6 Collapsing: Avoiding Screen Flood

/**
 * Returns information about whether this tool use is a search or read operation
 * that should be collapsed into a condensed display in the UI. Examples include
 * file searching (Grep, Glob), file reading (Read), and bash commands like find,
 * grep, wc, etc.
 *
 * - `isSearch: true` for search operations (grep, find, glob patterns)
 * - `isRead: true` for read operations (cat, head, tail, file read)
 * - `isList: true` for directory-listing operations (ls, tree, du)
 */
isSearchOrReadCommand?(input): { isSearch: boolean; isRead: boolean; isList?: boolean }

An agent exploring a codebase might read 20 files in a row. If every read displayed its full content, the user's screen would be flooded and the information that actually matters (the model's reasoning and conclusions) would be buried.

So these operations collapse into one line, something like “Read 20 files.” And the criterion is “the specific content of this call,” not “the tool type” — it's the same Bash tool, but running grep should collapse while running npm test must not (the user needs to see the test output).

10.7 87 State-Management Units

The hooks/ directory holds React custom hooks (something entirely different from the “user hooks” of chapter 9; they just share the English word). The names show how many kinds of state the UI has to manage:

HookWhat it manages
useCanUseTool.tsxThe UI flow for permission confirmation (this is the bridge between the UI layer and the permission layer)
useCommandQueue.tsThe queue of messages the user typed while the model was thinking
useCancelRequest.tsCtrl+C handling
useArrowKeyHistory.tsxArrow-key history navigation
useTypeahead.tsx (208 KB)Completion suggestions (the biggest hook)
useDiffData.ts / useDiffInIDE.tsDiff data, and opening diffs in the editor
useDoublePress.tsDouble-press detection (say, hitting Esc twice)
useBlink.tsCursor blinking
useCopyOnSelect.tsCopy on select
useDeferredHookMessages.tsDeferred display of hook messages (avoids flicker for fast hooks)
useBackgroundTaskNavigation.tsSwitching the view between multiple background tasks
useAwaySummary.tsA summary for when the user comes back after being away for a while

10.8 The Interface Between UI and Kernel: Callbacks in ToolUseContext

The main loop from chapter 3 has no idea the UI exists. The interface between them is a set of optional callbacks in ToolUseContext:

setToolJSX?: SetToolJSXFn                    // lets a tool insert custom components into the UI
addNotification?: (notif: Notification) => void
appendSystemMessage?: (msg) => void          // append a UI-only system message
sendOSNotification?: (opts) => void          // OS-level notification (iTerm2/Kitty/bell)
setInProgressToolUseIDs: (f) => void         // which tools are running (draws the spinner)
setHasInterruptibleToolInProgress?: (v) => void
setResponseLength: (f) => void
setStreamMode?: (mode: SpinnerMode) => void  // the spinner's form
onCompactProgress?: (event: CompactProgressEvent) => void
setSDKStatus?: (status: SDKStatus) => void
openMessageSelector?: () => void
requestPrompt?: (sourceName, summary) => (request) => Promise<PromptResponse>

All of them are optional (marked with ?). That's the key — in headless mode none of these callbacks exist, and the kernel works as usual, just without producing any UI side effects.

One callback's comment explains the boundary of this design:

/** Append a UI-only system message to the REPL message list. Stripped at the
 *  normalizeMessagesForAPI boundary — the Exclude<> makes that type-enforced. */
appendSystemMessage?: (msg: Exclude<SystemMessage, SystemLocalCommandMessage>) => void

Put plainly: append a “UI-only” system message to the interactive UI's message list. It gets stripped at the “normalize into API format” boundary — and that Exclude type enforces this at the type level.

“UI-only messages” are a necessary but dangerous concept. Necessary because lots of information (“switched to the fallback model,” “compaction done, saved 30,000 tokens”) only means something to a human; feeding it to the model is waste.

Dangerous because the moment a UI message leaks into the array sent to the model, it becomes contamination. So Claude Code enforces it with the type system: the callback accepts only a specific message type, and that type is statically excluded when converting to API format. Not “remember to filter it” — “it won't compile.”

10.9 A Fun Detail: ANSI to PNG

utils/ansiToPng.ts, 209.9 KB — the largest file in the utils/ directory.

What it does: renders terminal output (text with ANSI color control codes) into a PNG image.

It serves the “share” feature — when a user wants to send a stretch of conversation to a colleague, plain text loses all the colors and formatting. Converting to an image preserves the terminal's visual look intact.

Why so big? Because it has to implement its own font renderer: parse the ANSI sequences → compute each character's position → draw glyphs onto a pixel canvas → handle the widths of Chinese characters and emoji → encode as PNG. All of that is free in a browser (the browser does it for you); in a command-line program, every bit has to be written by hand.