深扒 Claude Code 源码(五):终端 UI 渲染管线与多 Agent 编排
魔改 Ink fork 的差分渲染与虚拟滚动 / AgentTool 四派生路径与文件邮箱 IPC
本文是《深扒 Claude Code 源码》系列第五篇,聚焦终端渲染与多 Agent 编排。承接第四篇·配置与认证。本篇两章极密集,已浓缩极细节、保留全部小节与核心设计。
10. 终端 UI 渲染管线
Claude Code 的终端 UI 不直接用 npm 上的 Ink,而是把 Ink 整体 fork 进 src/ink/,并把原本依赖 WASM/NAPI 的 Yoga 布局引擎用纯 TypeScript 重写进 src/native-ts/yoga-layout/。
10.1 为何 fork Ink 而非用 npm 包
全仓库唯一一处 from 'ink' 出现在 use-input.ts:25 的 JSDoc 注释里,仅作文档示例;没有任何运行时模块从 npm 的 ink import。fork 的根本原因是这套 UI 需要的能力远超上游 Ink 的设计边界,且深度耦合在渲染核心里:
- 单元格级双缓冲 + damage 差分:上游用字符串行 diff,无法表达"只重绘屏幕中一个 rect"。
screen.ts是一套打包成Int32Array的单元格缓冲。 - alt-screen 文本选择/搜索高亮/超链接/CJK 宽字符/软换行:
Screen带noSelect/softWrap/damage这些上游没有的字段。 - kitty keyboard / SGR 鼠标 / 终端能力探测:
parse-keypress.ts是一个完整的终端协议栈。 - 纯 TS Yoga:避免 WASM 启动开销,并能加入针对自身工作负载的缓存。
值得专门指出 326 个 UI 文件是 React Compiler 的编译产物形态:grep 显示 395 个文件 import react/compiler-runtime,函数体里满是 const $ = _c(7)、if ($[0] !== ...) 的手写记忆化槽位。这些 .tsx 不是人写的原始形态,而是 React Compiler 自动插入 memo cache 后、再经 source map 还原出的代码。
10.2 screen / frame / optimizer:差分渲染管线
单元格池化与打包:每个屏幕单元格压成 2 个 Int32(charId + 打包的 styleId/hyperlinkId/width)。整块屏幕是一个 ArrayBuffer,同时挂 Int32Array(逐字访问)和 BigInt64Array(批量 fill/copyWithin)。注释算账:200×120 屏幕这样避免分配 24000 个 Cell 对象。空单元格正好等于零初始化的 ArrayBuffer,所以"从未写过"和"显式清空"在打包数组里不可区分,diff 可直接整型比较无需归一化。
三个跨 screen 共享的字符串驻留池:CharPool、HyperlinkPool、StylePool。StylePool 在 id 的 bit 0 编码"该样式在空格上是否可见",让渲染器用一次位掩码就能跳过不可见空格;transition(fromId,toId) 缓存预序列化的 ANSI 转换串,warmup 后零分配。
diff 引擎 diffEach 是生产热路径(diff() 只给测试用,因为它分配数组)。先把扫描区域收敛到 prev.damage ∪ next.damage,两个复用的 Cell 对象在整个 diff 过程中被反复覆写,回调禁止持有引用——这是把每帧的对象分配压到接近零的关键。
frame 双缓冲:Ink 持有 frontFrame/backFrame,每帧算完 diff 后做指针交换。optimizer patch 合并 单趟扫描合并相邻 patch(合并 cursorMove、坍缩 cursorTo、拼接 styleStr);有个精到的正确性判断:styleStr 是转换差量而非 setter,只能拼接不能丢弃,否则丢掉背景重置会因 BCE 漏进后续清屏。整条管线带逐阶段计时,每 5 分钟 resetPools 防池无界增长,渲染 throttle 到约 60fps。
10.3 reconciler ConcurrentRoot + 纯 TS Yoga
reconciler 用 createReconciler 配出宿主。关键点:commit 即布局即绘制——resetAfterCommit 在 React 提交后触发 Yoga calculateLayout 再触发整条 diff/write 管线。这是 AlternateScreen 用 useInsertionEffect 而非 useLayoutEffect 的原因:insertion effect 在 resetAfterCommit 之前触发,保证 ENTER_ALT_SCREEN 先于第一帧到达终端。
纯 TS Yoga(2579 行)文件头坦白这是 Meta C++ Yoga 的简化单趟移植,并列出实现/未实现矩阵(未实现 aspect-ratio、box-sizing content-box、RTL)。性能上针对自身负载做了多层缓存:快路径标志位(1000 节点 bench 里 ~67% 调用作用在全 undefined 的 edge 上)、多槽布局缓存、代次戳(让虚拟滚动里新挂载的 dirty 节点也能在同一趟内命中,把 1593 节点树的 105k 次访问降到 ~10k)、flex-basis 缓存。
10.4 parse-keypress:kitty / SGR 鼠标 / 终端探测
parse-keypress.ts 是把终端字节流翻译成 ParsedKey | ParsedMouse | ParsedResponse 的完整协议栈。识别 DECRPM、DA1/DA2、kitty flags、DECXCPR、OSC、XTVERSION(比 TERM_PROGRAM 更可靠因为走 pty 而非环境变量过 SSH);kitty keyboard protocol(CSI u) 按 1 + shift + alt*2 + ctrl*4 + super*8 解出修饰位;xterm modifyOtherKeys 参数顺序和 CSI u 相反;SGR/X10 鼠标 但滚轮留作 ParsedKey 让 keybinding 路由到 ScrollBox。还有对"孤儿鼠标尾巴"的修复——重渲染阻塞超过 50ms flush 定时器时缓冲的 ESC 被当孤立 Escape 刷出,这里重新拼上 ESC 前缀让滚动事件仍能触发。
10.5 REPL.tsx:FullscreenLayout 五插槽 + focusedInputDialog 焦点裁决
FullscreenLayout 定义五个插槽:scrollable(转录内容)、bottom(钉底的 spinner/prompt/权限请求)、overlay、bottomFloat(绝对定位浮在右下不被裁掉)、modal(底部锚定、盖住 ScrollBox)。
focusedInputDialog 焦点裁决 用一个 ref 在回调里读取避免闭包陈旧。它驱动三件事:暂停/恢复(tool-permission 时立即捕获 pause)、取消语义路由(onCancel 按 dialog 类型分派)、海量条件渲染(覆盖 sandbox-permission、elicitation、cost、ultraplan 等十几种)。普通 prompt 输入只在无 dialog 时渲染。
10.6 Messages 流水线 + 虚拟滚动 + Diff 缓存
Messages 折叠/分组流水线 分成两个 useMemo 刻意拆开:昂贵的"filter→reorder→group→collapse"全 O(n)(27k 条消息)放一个 memo,便宜的 renderRange 切片放另一个——注释记录不拆开会导致每次滚动重建 6 个 Map ≈ 50ms 分配 → 100-173ms stop-the-world。
VirtualMessageList + useVirtualScroll(722 行)是最复杂的部分。动机:ScrollBox 已在输出层做视口裁剪,但 React fiber + Yoga 节点仍全量分配,每个 MessageRow ~250KB RSS,1000 条 ≈250MB 只增不减。所以这个 hook 只挂"视口+overscan"内的项,其余用 spacer Box 以 O(1) fiber 撑住滚动高度。关键机制:高度缓存(不用 setState,靠版本号在渲染期惰性重建 offsets 避免双 commit 闪烁)、offsets 用 Float64Array、滚动量化(SCROLL_QUANTUM=40,小滚动 React 跳过整个 commit+Yoga+diff,视觉滚动仍流畅因为 Ink 从 DOM 读真实 scrollTop)、二分查 start(替换 27k 次线性扫描)、时间切片(useDeferredValue 把 62ms 挂载阻塞变成可中断)、resize 时高度按比例缩放而非清空。
StructuredDiff 渲染缓存 用模块级 WeakMap。动机:REPL 在两个不相交的树位置渲染 <Messages>,ctrl+o 切换会整树 unmount/remount 丢失 React memo cache。把 NAPI(Rust ColorDiff)结果和预切好的 gutter/content 两列都缓存在模块级,remount 时只剩一次 WeakMap 查找而非重跑语法高亮。gutter 列用 <NoSelect> 包住让点击拖拽得到干净可复制代码。
10.7 useTextInput:readline / kill-ring
useTextInput 实现一套 Emacs/readline 风格行编辑,状态封在不可变的 Cursor 里。handleCtrl:a/e=行首尾、k=kill 到行尾、u=kill 到行首、w=kill 前一词、y=yank。kill-ring 连续 kill 拼接、非 kill 键重置累积;yank-pop 用上一个 kill 替换刚 yank 的文本。边界处理很密集:SSH 合并的 Enter 用 lookbehind 剥尾 \r、SSH/tmux 下裸 \x7f 批量当退格、Esc 双击清空保存历史。
11. 多 Agent 编排
多 Agent 编排不是一个单独的子系统,而是围绕 AgentTool(对模型暴露的 Agent 工具,旧名 Task)这一个工具入口,按运行环境与特性门控分叉出四条派生路径,并复用同一套 runAgent() / query() 内核。
11.1 AgentTool 四条派生路径与递归 fork 防护
call() 按顺序判定走哪条路径:
- 命名队友:
teamName && name同时存在时调spawnTeammate()。两道护栏:嵌套队友会让 roster 失去 provenance(抛错);in-process 队友生命周期绑在 leader 进程上无法后台化(抛错)。 - fork-subagent:省略
subagent_type且 fork 实验开启时选用合成的FORK_AGENT。 - 远程隔离:ant 构建时走
teleportToRemote()。 - 普通子代理 / worktree 隔离:默认路径,worktree 时建临时 git worktree。
Fork 路径的字节级 prompt cache 共享是最精巧的部分。FORK_AGENT 是个合成定义:getSystemPrompt: () => ''(故意为空),fork 子代理不用自己的 system prompt,而是继承父代理已渲染的字节——避免重新调用 getSystemPrompt() 因 GrowthBook 冷→热翻转导致字节漂移而击穿缓存。buildForkedMessages() 克隆父代理那条完整 assistant 消息,为每个 tool_use 生成完全相同占位文本的 tool_result,只在末尾追加每个子代理不同的 directive——只有最后一个 text 块不同,最大化跨 fork 子代理的缓存命中。fork 路径还设 useExactTools: true 直接用父代理工具数组而非重建(重建会让工具定义序列化发散击穿缓存)。
递归 fork 防护用 querySource === 'agent:builtin:fork' 做主检测(能抗 autocompact,因为它改写的是 messages 而非 context.options),消息扫描 <FORK_BOILERPLATE_TAG> 做兜底(autocompact 会替换样板消息所以单靠它不够)。
11.2 六个内置代理与一次性代理省略 trailer
六个内置代理按门控装配:general-purpose、statusline-setup(基础)、Explore/Plan、claude-code-guide、verification。差异化设计:
- Explore:只读搜索专家,外部用
haiku,设omitClaudeMd——丢掉 CLAUDE.md,注释称跨 3400 万次 Explore spawn 每周省 5-15 Gtok;还丢掉 session-start 的 gitStatus(可达 40KB),每周再省 1-3 Gtok。 - verification:对抗式验证专家,
background: true,禁写工具,用criticalSystemReminder_EXPERIMENTAL注入强约束要求以VERDICT: PASS/FAIL/PARTIAL结尾。
一次性内置代理省略 trailer:ONE_SHOT_BUILTIN_AGENT_TYPES = {Explore, Plan} 永不被 SendMessage continue,所以省去约 135 字符的 <usage> trailer——注释算账"135 字符 × 每周 3400 万次 Explore ≈ 每周 1-2 Gtok",且遥测走单独事件不解析这个文本块,所以删它安全。
11.3 coordinator 模式:INTERNAL_WORKER_TOOLS 与 scratchpad
coordinator 模式的系统提示核心是"每条消息都发给用户,worker 结果是内部信号不是对话方,绝不感谢它们"。INTERNAL_WORKER_TOOLS = {TeamCreate, TeamDelete, SendMessage, SyntheticOutput} 用于向 coordinator 描述 worker 能用什么工具(认知模型而非真正裁剪)——worker 不应看到编排层工具,因为编排是 coordinator 自己的职责。scratchpad 是跨 worker 持久知识区,scratchpadDir 经依赖注入而非 import filesystem.ts(避免循环依赖)。fork 与 coordinator 互斥。
11.4 swarm:AsyncLocalStorage 隔离、文件邮箱 IPC、500ms 轮询
三后端 tmux / iterm2 / in-process 由 detectAndGetBackend() 优先级选择(非交互 -p 强制 in-process)。
in-process 的 AsyncLocalStorage 隔离是单进程内并发跑多代理的根基。有两套 ALS:TeammateContext(运行身份)和 AgentContext(遥测归因)。注释点明为什么不用 AppState:代理被 ctrl+b 后台化时多个代理并发跑在同一进程,AppState 是单一共享态会被覆盖;ALS 隔离每条异步执行链。AppState 里 task.messages 有 TEAMMATE_MESSAGES_UI_CAP = 50 封顶——注释引用 BQ 分析"鲸鱼 session 2 分钟启 292 代理达 36.8GB",所以 UI 镜像只留 50 条。
文件邮箱 IPC:每个 teammate 的 inbox 是 ~/.claude/teams/{team}/inboxes/{agent}.json,按 agent name 键控,并发写用 proper-lockfile 加锁、锁内重读拿最新态。邮箱不只传自然语言,还承载一整套结构化协议消息(permission_request/response、shutdown_request、plan_approval 等),isStructuredProtocolMessage() 把它们路由到专用队列而非当成 LLM 上下文塞进 attachments。
500ms 权限轮询两处:in-process teammate 优先用 leader 的对话框,桥不可用时退回邮箱、每 500ms 读自己 inbox 找匹配的 permission_response;以及保活轮询每 500ms 检查 pending 消息、mailbox 新消息、abort 信号。
11.5 七类 TaskState 判别联合
TaskState 是七个具体类型的判别联合:LocalShell / LocalAgent / RemoteAgent / InProcessTeammate / LocalWorkflow / MonitorMcp / Dream。InProcessTeammateTaskState 特别丰富:两个 abortController(杀整个 teammate vs 只 abort 当前轮,Escape 用后者)、独立 cycle 的 permissionMode、pendingUserMessages 队列、isIdle/shutdownRequested。这套联合让 UI 统一处理异构任务,具体行为靠 type 判别窄化。
11.6 子代理复用同一 query() 生成器、靠 agentId 区分
所有派生路径——普通子代理、fork 子代理、in-process teammate——最终都收敛到 runAgent(),内部唯一驱动是 for await query()。没有"子代理专用的推理循环",仅以参数差异化。
区分各代理的核心是 agentId,它贯穿整个生命周期——transcript 子目录键、Perfetto trace 节点(parentId 形成层级)、metadata 写入键、以及 finally 块里所有资源回收的键。finally 按 agentId 清理 MCP servers、session hooks、prompt cache tracking、AppState.todos[agentId](注释指出每个调过 TodoWrite 的子代理会永久留一个 key,鲸鱼 session 累积成泄漏)、killShellTasksForAgent(防 run_in_background 的 shell 变成 PPID=1 僵尸)。
下一篇(六,完结):Remote Control 与远程会话、系统提示/持久化/记忆、遥测与特性开关体系。
原文链接:https://www.ssssmy.com/notes/claude-code-deep-dive-ui-agents