全文目录Contents
- 0 · The Project at a Glance, and a Map of the Code
- 1 · The Entry Layer and Startup
- 2 · The Session Layer: QueryEngine
- 2.1 The problem it solves
- 2.2 What state it holds
- 2.3 The full flow of one submitMessage
- 2.4 Why the user message must hit disk first
- 2.5 Consuming the main loop’s output: one big switch
- 2.6 The compact boundary: proactively releasing memory
- 2.7 Three kinds of exit result
- 2.8 ask(): a convenience wrapper for one-shot calls
- 3 · The Agent Main Loop ★
- 3.1 The skeleton of the loop
- 3.2 State: centralizing cross-iteration state
- 3.3 transition: a field that exists purely for testability
- 3.4 The seven transition edges, one by one
- 3.5 The error-withholding mechanism
- 3.6 Interrupt handling
- 3.7 Model fallback: three actions
- 3.8 The three laws of thinking blocks
- 3.9 Other mechanisms in the loop
- 3.10 Every exit point of the loop
- 4 · The Tool Model
- 4.1 The Tool interface: seven orthogonal capability groups
- 4.2 Why the “safety predicates” deserve their own group
- 4.3 Fail-safe defaults
- 4.4 The 40 built-in tools, by category
- 4.5 Progressive tool loading
- 4.6 Tool list assembly: a hidden constraint about caching
- 4.7 backfillObservableInput: an extreme example of cache protection
- 5 · Tool Execution
- 5.1 The execution pipeline at a glance
- 5.2 Concurrency partitioning: a greedy algorithm
- 5.3 Context modifications are queued until the batch ends
- 5.4 A single execution: the full flow of runToolUse
- 5.5 The streaming tool executor
- 5.6 The sibling abort controller: the most elegant design in the file
- 5.7 The discard mechanism
- 5.8 “Tombstone” messages
- 5.9 Final processing of results
- 6 · Context Management ★
- 7 · The Permission System
- 8 · Subagents
- 9 · The Extension System
- 10 · The Terminal UI Layer
- 10.1 Writing a Terminal UI in React
- 10.2 The Four Biggest Components
- 10.3 Why the Input Box Is 347 KB
- 10.4 The Virtualized Message List
- 10.5 Six Rendering States for Tool Results
- 10.6 Collapsing: Avoiding Screen Flood
- 10.7 87 State-Management Units
- 10.8 The Interface Between UI and Kernel: Callbacks in ToolUseContext
- 10.9 A Fun Detail: ANSI to PNG
- 11 · Persistence and Resume
- 12 · The Observability System
- 13 · Build and Distribution
10 · 终端界面层
146 个界面组件、87 个状态管理单元、50 个定制版框架文件。这一层占了整个代码库体量的很大一块,但在架构讨论里几乎从不被提及。这一章补上。
10.1 用 React 写终端界面
先解释这件事本身:Ink 是一个让你用 React 语法写终端界面的框架。
好处是可以复用 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.ts | ANSI 转义序列处理(终端的颜色和格式控制码) |
frame.ts | 帧管理 |
focus.ts | 焦点管理 —— Tab 键在哪些元素之间跳转 |
为什么要 fork 而不是用上游版本?因为上游 Ink 是一个通用框架,性能取舍面向的是「偶尔更新的小界面」。而 Claude Code 的场景是模型流式输出时每秒重渲染几十次、消息列表有几千条、终端窗口可能很大。
line-width-cache.ts 这个文件的存在就是证据:字符宽度计算被拿出来单独优化了。在一个每秒重渲染几十次的界面里,这个函数会被调用几十万次。
10.2 最大的四个组件
| 组件 | 大小 | 它复杂在哪 |
|---|---|---|
PromptInput.tsx | 347 KB | 输入框。见 10.3 |
Settings/Config.tsx | 265 KB | 设置界面。几十个配置项,每个都要有输入控件、校验、说明文字 |
LogSelector.tsx | 196 KB | 会话选择器(--resume 时的那个列表)。要读取所有历史会话、显示摘要、支持搜索和键盘导航 |
VirtualMessageList.tsx | 145 KB | 虚拟消息列表。见 10.4 |
10.3 输入框为什么有 347 KB
一个「输入框」听起来应该很简单。但这个输入框要处理:
| 功能 | 复杂度来源 |
|---|---|
| 多行编辑 | 终端里没有原生的多行输入控件。光标移动、换行、自动折行全部要自己实现 |
| Vim 模式 | src/vim/ 有 7 个文件。要实现普通模式 / 插入模式 / 可视模式,以及 dw、ciw 这类组合键 |
| 斜杠命令补全 | 敲 / 时弹出候选列表,实时过滤,方向键选择 |
| @ 文件提及 | 敲 @ 时弹出文件路径补全,要实时搜索工作目录 |
| 图片粘贴 | 从剪贴板读图片(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 接收「部分参数」这一点值得注意:
/**
* 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.ts | Ctrl+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.
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:
| File | What it does |
|---|---|
bidi.ts | Bidirectional text handling — the layout rules for right-to-left scripts like Arabic and Hebrew when mixed with English |
line-width-cache.ts | Line-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.ts | Size measurement for text and elements |
hit-test.ts | Hit testing — figuring out which element a mouse click landed on (terminals support the mouse too) |
log-update.ts | Updating already-printed output in place — the foundation of a streaming UI |
Ansi.tsx / colorize.ts | ANSI escape-sequence handling (the terminal's color and formatting control codes) |
frame.ts | Frame management |
focus.ts | Focus 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
| Component | Size | Where the complexity is |
|---|---|---|
PromptInput.tsx | 347 KB | The input box. See 10.3 |
Settings/Config.tsx | 265 KB | The settings UI. Dozens of config options, each needing an input control, validation, and help text |
LogSelector.tsx | 196 KB | The session picker (the list you see with --resume). Has to read every past session, show summaries, and support search and keyboard navigation |
VirtualMessageList.tsx | 145 KB | The 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:
| Feature | Source of complexity |
|---|---|
| Multi-line editing | Terminals have no native multi-line input control. Cursor movement, line breaks, and word wrap all have to be implemented by hand |
| Vim mode | src/vim/ has 7 files. Normal / insert / visual modes, plus key combos like dw and ciw |
| Slash-command completion | Typing / pops up a candidate list, filtered live, selectable with the arrow keys |
| @ file mentions | Typing @ pops up file-path completion, which has to search the working directory live |
| Image paste | Reads images from the clipboard (the NATIVE_CLIPBOARD_IMAGE feature flag) and converts them to a format the model accepts |
| History navigation | Up/down arrows scroll through previously sent messages (useArrowKeyHistory.tsx) |
| Input queue | If the user types another line while the model is thinking, it has to be queued rather than dropped (useCommandQueue.ts) |
| Pasting large text | Pasting thousands of lines can't be processed character by character (it would freeze); it needs a special path |
| Bidirectional text | For right-to-left scripts like Arabic, cursor position and visual position don't line up |
| Keyboard shortcuts | keybindings/ 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 itselfMessages.tsx(144 KB) — the dispatch logic for rendering messagesScrollKeybindingHandler.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:
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:
| Hook | What it manages |
|---|---|
useCanUseTool.tsx | The UI flow for permission confirmation (this is the bridge between the UI layer and the permission layer) |
useCommandQueue.ts | The queue of messages the user typed while the model was thinking |
useCancelRequest.ts | Ctrl+C handling |
useArrowKeyHistory.tsx | Arrow-key history navigation |
useTypeahead.tsx (208 KB) | Completion suggestions (the biggest hook) |
useDiffData.ts / useDiffInIDE.ts | Diff data, and opening diffs in the editor |
useDoublePress.ts | Double-press detection (say, hitting Esc twice) |
useBlink.ts | Cursor blinking |
useCopyOnSelect.ts | Copy on select |
useDeferredHookMessages.ts | Deferred display of hook messages (avoids flicker for fast hooks) |
useBackgroundTaskNavigation.ts | Switching the view between multiple background tasks |
useAwaySummary.ts | A 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.