s-blog

深扒 Claude Code 源码(五):终端 UI 渲染管线与多 Agent 编排

魔改 Ink fork 的差分渲染与虚拟滚动 / AgentTool 四派生路径与文件邮箱 IPC

ssssmy · 2026-06-11 · 10 min · LLM

本文是《深扒 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 宽字符/软换行ScreennoSelect/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 共享的字符串驻留池:CharPoolHyperlinkPoolStylePool。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/权限请求)、overlaybottomFloat(绝对定位浮在右下不被裁掉)、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-purposestatusline-setup(基础)、Explore/Planclaude-code-guideverification。差异化设计:

  • 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 结尾。

一次性内置代理省略 trailerONE_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-processdetectAndGetBackend() 优先级选择(非交互 -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