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 虚拟消息列表

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

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

相关的几个组件:

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

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。这些在浏览器里是免费的(浏览器帮你做了),在一个命令行程序里全部要自己写。