146 个界面组件、87 个状态管理单元、50 个定制版框架文件。这一层占了整个代码库体量的很大一块,但在架构讨论里几乎从不被提及。这一章补上。
先解释这件事本身:Ink 是一个让你用 React 语法写终端界面的框架。
好处是可以复用 React 的整套心智模型:组件化、状态驱动重渲染、钩子。代价是你在和一个只能显示等宽字符的、没有像素概念的、还会被用户随时改变尺寸的「画布」打交道。
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 这个文件的存在就是证据:字符宽度计算被拿出来单独优化了。在一个每秒重渲染几十次的界面里,这个函数会被调用几十万次。
| 组件 | 大小 | 它复杂在哪 |
|---|---|---|
PromptInput.tsx | 347 KB | 输入框。见 10.3 |
Settings/Config.tsx | 265 KB | 设置界面。几十个配置项,每个都要有输入控件、校验、说明文字 |
LogSelector.tsx | 196 KB | 会话选择器(--resume 时的那个列表)。要读取所有历史会话、显示摘要、支持搜索和键盘导航 |
VirtualMessageList.tsx | 145 KB | 虚拟消息列表。见 10.4 |
一个「输入框」听起来应该很简单。但这个输入框要处理:
| 功能 | 复杂度来源 |
|---|---|
| 多行编辑 | 终端里没有原生的多行输入控件。光标移动、换行、自动折行全部要自己实现 |
| Vim 模式 | src/vim/ 有 7 个文件。要实现普通模式 / 插入模式 / 可视模式,以及 dw、ciw 这类组合键 |
| 斜杠命令补全 | 敲 / 时弹出候选列表,实时过滤,方向键选择 |
| @ 文件提及 | 敲 @ 时弹出文件路径补全,要实时搜索工作目录 |
| 图片粘贴 | 从剪贴板读图片(NATIVE_CLIPBOARD_IMAGE 特性开关),转成模型能接受的格式 |
| 历史回溯 | 上下方向键翻之前发过的消息(useArrowKeyHistory.tsx) |
| 输入队列 | 模型正在思考时用户又敲了一句,要排队而不是丢弃(useCommandQueue.ts) |
| 粘贴大块文本 | 粘贴几千行时不能逐字符处理(会卡死),要特殊路径 |
| 双向文本 | 阿拉伯语等从右往左的文字,光标位置和视觉位置不一致 |
| 快捷键 | keybindings/ 有 16 个文件,用户可以自定义所有快捷键 |
这解释了一个常见的错觉:看架构图时,「界面层」通常只是最上面一个小方块。但在真实项目里,界面往往是代码量最大的部分 —— 因为它要处理人类行为的全部混乱性,而人类行为没有规范文档。
一场长会话可能有几千条消息。如果每次重渲染都遍历全部消息、计算它们的布局,界面会卡到不可用。
「虚拟化」的意思是:只渲染当前视口里能看到的那几条,其余的只记住它们占多高。
相关的几个组件:
VirtualMessageList.tsx(145 KB)—— 虚拟化列表本体Messages.tsx(144 KB)—— 消息渲染的分发逻辑ScrollKeybindingHandler.tsx(146 KB)—— 滚动和键盘导航难点在于:终端里的「一条消息占多高」不是固定的。它取决于终端宽度(窗口一改变,所有消息的高度全变)、内容是否折行、是否有代码块、是否被折叠。所以要缓存高度、在宽度变化时批量重算。
回顾第 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
为了让用户尽早看到「智能体开始做什么了」,界面在参数还没流完时就开始渲染。所以每个渲染函数都必须能处理「字段可能不存在」的情况。
/**
* 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 不能折叠(用户需要看到测试输出)。
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 | 用户离开一段时间回来后的摘要 |
第 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 用类型系统强制:这个回调只接受特定类型的消息,而那个类型在转换成接口格式时会被静态排除。不是靠「记得过滤」,是靠「编译不过」。
utils/ansiToPng.ts,209.9 KB —— 是 utils/ 目录下最大的文件。
它做的事情是:把终端的输出(带 ANSI 颜色控制码的文本)渲染成一张 PNG 图片。
用途是「分享」功能 —— 用户想把一段对话发给同事看时,纯文本会丢失所有颜色和格式。转成图片就能完整保留终端的视觉效果。
为什么这么大?因为要自己实现一个字体渲染器:解析 ANSI 序列 → 计算每个字符的位置 → 把字形绘制到像素画布上 → 处理中文/emoji 的宽度 → 编码成 PNG。这些在浏览器里是免费的(浏览器帮你做了),在一个命令行程序里全部要自己写。