职责概述

解决的问题:CC 跑在终端里,但终端只有纯文本。需要把模型的流式回复(带格式)、工具执行进度、权限确认弹窗、错误提示等,全部渲染成好看且可交互的终端界面,同时处理键盘输入和鼠标事件。

应用场景:① 模型回复时实时渲染 Markdown 格式(代码高亮、表格、列表)② 工具执行时显示旋转动画和进度条 ③ 权限请求时弹出"允许/拒绝"选择界面 ④ 用户在输入框中编辑多行文本时的光标移动和自动补全 ⑤ 上下滚动查看历史对话的虚拟列表。

一句话理解:就像终端版的 ChatGPT 网页——虽然跑在命令行里,但该有的格式、动画、交互一个不少。

架构设计

应用层
screens/REPL.tsx — 主屏幕(~2000 行)
screens/Doctor.tsx — 诊断面板
screens/ResumeConversation.tsx — 会话恢复
组件层
PromptInput.tsx — 用户输入
VirtualMessageList.tsx — 消息列表
Messages.tsx — 消息渲染
design-system/ — ThemedText/ThemedBox/ThemeProvider
messages/ — 30+ 消息类型组件
permissions/ — 权限请求对话框
渲染引擎
ink/ink.tsx — Ink 核心类(~1722 行)
ink/reconciler.ts — React reconciler
ink/renderer.ts — DOM→Screen 渲染器
ink/layout/yoga.ts — Yoga 布局引擎适配
ink/screen.ts — Screen 缓冲区
基础设施层
ink/events/ — 事件分发器
ink/termio/ — ANSI 转义序列
ink/terminal.ts — 终端能力检测
ink/parse-keypress.ts — 按键解析

核心数据流

渲染管线

1. 用户输入 / 状态变更
按键、消息到达、状态更新触发 React 重渲染
2. React Reconcile
reconciler.ts 使用 react-reconciler(LegacyRoot 同步模式)将 React 组件树转换为 DOM 树(DOMElement 节点)
3. Yoga Layout
layout/yoga.ts 调用 Yoga 引擎计算每个节点的精确位置和尺寸(类 Flexbox)
4. Paint to Screen
renderer.ts 遍历 DOM 树,将每个节点渲染为 Screen 缓冲区的字符单元(含样式、超链接池)
5. Diff & Output
LogUpdate 对比前后两帧 Screen 缓冲区,生成 ANSI 补丁序列,通过 stdout 写入终端。使用 BSU/ESU 同步输出防止闪烁

事件处理流程

stdin 原始字节
终端在 raw mode 下发送按键字节流
parse-keypress.ts
解析为 ParsedInput(字符)+ ParsedKey(修饰键标志)+ ParsedMouse(鼠标坐标)
App.tsx 事件分发
App 组件接收 InputEvent,通过 EventEmitter 通知订阅者
Dispatcher 双阶段派发
events/dispatcher.ts 模拟 DOM 标准:capture(根→目标)→ bubble(目标→根)。查找匹配的 DOM 元素并执行处理器

关键类型与接口

Ink 核心类

// ink/ink.tsx:76-120 — Ink 主类关键结构
export default class Ink {
  private readonly log: LogUpdate       // 差量输出管理器
  private readonly terminal: Terminal    // 终端能力适配
  private readonly container: FiberRoot  // React reconciler 容器
  private rootNode: DOMElement           // 虚拟 DOM 根节点
  readonly focusManager: FocusManager    // 焦点管理
  private renderer: Renderer             // DOM → Screen 渲染器
  private readonly stylePool: StylePool  // 样式对象池
  private charPool: CharPool             // 字符对象池
  private hyperlinkPool: HyperlinkPool   // 超链接对象池
  private frontFrame: Frame              // 前帧缓冲区
  private backFrame: Frame               // 后帧缓冲区
}

Frame 数据结构

// ink/frame.ts:12-20
export type Frame = {
  readonly screen: Screen       // 渲染后的屏幕缓冲区
  readonly viewport: Size       // 视口尺寸
  readonly cursor: Cursor       // 光标位置与可见性
  readonly scrollHint?: ScrollHint | null  // DECSTBM 滚动优化提示
  readonly scrollDrainPending?: boolean    // 是否有待处理的滚动增量
}

DOMElement — Ink 虚拟 DOM 节点

// ink/dom.ts:31-80
export type DOMElement = {
  nodeName: ElementNames  // 'ink-root' | 'ink-box' | 'ink-text' | ...
  attributes: Record<string, DOMNodeAttribute>
  childNodes: DOMNode[]
  style: Styles
  yogaNode?: LayoutNode   // Yoga 布局节点
  dirty: boolean           // 是否需要重渲染
  _eventHandlers?: Record<string, unknown>  // 事件处理器
  // 滚动状态(overflow: 'scroll' 的 Box)
  scrollTop?: number
  scrollHeight?: number
  scrollViewportHeight?: number
  stickyScroll?: boolean
}

ThemeProvider — 主题系统

// components/design-system/ThemeProvider.tsx:8-16
type ThemeContextValue = {
  themeSetting: ThemeSetting      // 用户偏好(可以是 'auto')
  setThemeSetting: (s) => void
  setPreviewTheme: (s) => void    // 主题选择器预览
  savePreview: () => void
  cancelPreview: () => void
  currentTheme: ThemeName         // 解析后的实际主题(永远不是 'auto')
}

ThemedText — 主题感知文本组件

// components/design-system/ThemedText.tsx:12-61
export type Props = {
  color?: keyof Theme | Color     // 接受主题键或原始颜色值
  backgroundColor?: keyof Theme
  dimColor?: boolean              // 使用主题的 inactive 颜色
  bold?: boolean; italic?: boolean
  underline?: boolean; strikethrough?: boolean
  inverse?: boolean
  wrap?: Styles['textWrap']       // 'wrap' | 'truncate' | 'truncate-start' | ...
}
// resolveColor() 自动将主题键(如 'primary')解析为实际 RGB/ANSI 颜色值

事件双阶段派发

// ink/events/dispatcher.ts:46-79
function collectListeners(target, event): DispatchListener[] {
  // 从目标到根遍历:
  // capture 处理器 unshift(根优先)
  // bubble 处理器 push(目标优先)
  // 结果: [root-cap, ..., parent-cap, target-cap, target-bub, parent-bub, ..., root-bub]
  let node = target
  while (node) {
    const captureHandler = getHandler(node, event.type, true)
    const bubbleHandler = getHandler(node, event.type, false)
    // ...收集到 listeners 数组
    node = node.parentNode
  }
  return listeners
}

设计模式与亮点

1. React-for-CLI 架构

Ink 将 Web React 的声明式组件模型完美移植到终端。开发者用 JSX 编写 UI,reconciler 将组件树映射到虚拟 DOM(DOMElement), Yoga 引擎做 Flexbox 布局,最终输出为 ANSI 字符流。这让复杂的终端 UI(选择器、对话框、进度条)可以用 React 组件的方式构建。

2. 双缓冲差量渲染

Ink 使用 frontFrame/backFrame 双缓冲策略。每帧渲染时,backFrame 接收新内容,然后与 frontFrame 做 cell-by-cell 对比, 只输出变化的 ANSI 序列。这避免了每帧全屏刷新的闪烁问题。StylePool/CharPool/HyperlinkPool 使用对象池模式减少 GC 压力。

3. 虚拟列表优化

VirtualMessageList.tsx 实现了虚拟滚动:只渲染视口内的消息组件,配合 OffscreenFreeze 冻结不可见组件。 在长会话(2800+ 消息)中,通过 LogoHeader memo 化防止不必要的子树 dirty 级联——这是经过性能调优的关键模式: 如果 Logo 未 memo,每次 re-render 会导致所有 MessageRow 放弃 blit 优化(150K+ writes/frame)。

4. 终端能力自适应

terminal.ts 检测终端能力并自适应:

// ink/terminal.ts — 能力检测示例
function isSynchronizedOutputSupported(): boolean {
  // tmux 不支持 DEC 2026 — 跳过
  if (process.env.TMUX) return false
  // 已知支持 DEC 2026 的终端
  if (['iTerm.app', 'WezTerm', 'ghostty', 'kitty'].includes(termProgram))
    return true
  return false
}
function isProgressReportingAvailable(): boolean {
  // OSC 9;4 进度报告:Ghostty 1.2+、iTerm2 3.6.6+
}

5. Design System 主题层

ThemedText/ThemedBox 封装了 Ink 的基础 Text/Box 组件,添加主题解析层。颜色属性接受 keyof Theme(语义键)或原始 ANSI 值, resolveColor() 在运行时自动解析。ThemeProvider 还支持 'auto' 模式——通过 OSC 11 查询终端背景色自动切换明暗主题。

6. 按键序列解析

termio/ 目录实现了完整的终端转义序列解析栈:

开发者实践指南

使用 Ink 开发终端 UI 的核心原则:像写 React Web 一样写 CLI 组件,但要注意终端的性能约束。

创建新的 UI 组件

所有 CC 组件使用 ink.ts 导出的封装 API:

// ink.ts — 项目统一入口(自动包裹 ThemeProvider)
import { Box, Text, render, createRoot } from '../ink.js'

// 创建带主题的根节点
const root = await createRoot({ stdout: process.stdout })
root.render(<Box flexDirection="column">
  <Text color="primary" bold>标题</Text>
  <Text dimColor>次要信息</Text>
</Box>)

PromptInput 集成

PromptInput 是 CC 最复杂的组件(~80 行导入、数十个 hook)。要集成新功能:

性能优化技巧

架构师决策指南

为什么选择 Ink(React for CLI)而非直接 ANSI

CC 选择 Ink 的核心原因是组件复用声明式状态管理。一个权限对话框(PermissionRequest) 需要:列表导航、确认/拒绝按钮、模式切换、实时更新——用纯 ANSI 实现需要数百行状态机代码。 用 Ink,这就是一个 React 组件,状态通过 hooks 管理,渲染自动响应。

渲染性能的极限与应对

终端渲染的瓶颈不在 Yoga 布局(~1ms/帧),而在 ANSI diff + stdout 写入(~5-15ms/帧)。 Ink 的优化策略:

组件架构的权衡

优势:30+ 消息类型组件(messages/)各司其职,新消息类型只需添加一个文件。 设计系统层(design-system/)确保主题一致性。
挑战:REPL.tsx 作为主屏幕承担了过多职责(~2000 行),状态管理分散在 AppState、useCallback、useEffect 中。 未来可考虑拆分为更细粒度的子组件。

事件系统的设计取舍

Ink 的事件系统模拟了 DOM 的 capture/bubble 双阶段模型。这在功能上很强大(支持 onKeyDownCapture 等场景), 但增加了复杂度。在 CLI 场景中,绝大多数事件处理都是简单的 useInputuseKeybinding, 双阶段派发只在鼠标点击 hit-test 和嵌套焦点管理中使用。

UI 组件层级图

App(顶层包装器)
FpsMetricsProvider
StatsProvider
AppStateProvider
REPL(主屏幕)
ThemeProvider
PromptInput — 用户输入区
VirtualMessageList — 消息滚动区
PermissionRequest — 权限对话框
StatusLine — 状态栏
SpinnerWithVerb — 加载动画
消息渲染层
Messages.tsx — 消息列表管理
MessageRow.tsx — 单条消息包装
AssistantTextMessage / AssistantToolUseMessage
UserTextMessage / UserCommandMessage
StreamingMarkdown — Markdown 实时渲染
基础组件
ThemedText / ThemedBox — 主题感知容器
BaseText / BaseBox — Ink 原始组件
ScrollBox — 可滚动容器
Button / Link / Newline
Spacer — 弹性空白

可视化处理拓扑图

setState / props 变更用户输入、流式消息、状态更新
1. React 组件树构建 — REPL 入口screens/REPL.tsx:572 — 根组件AppStateProvider + StatsProvider + FpsMetricsProvider
Messages消息列表组件
TextInput输入框组件
StatusLine状态栏组件
ToolConfirm工具确认弹窗
React 调度 → Fiber 树 diff ↓
2. 自定义 Reconciler — React → 虚拟 DOMink/reconciler.ts — react-reconciler 宿主环境createInstance L331 → DOMElement | commitUpdate L426 → 属性差异
resetAfterCommit → deferredRenderink/reconciler.ts:247 — queueMicrotask 触发渲染同一事件循环内多次 setState 只触发一次渲染
虚拟 DOM 树就绪DOMElement 节点树,含样式属性
3. Yoga 布局计算 — Flexbox → 终端坐标ink/layout/yoga.ts — DOMElement → YogaNode计算每个节点的行列坐标,回写到 yi 字段
4. DOM → Screen 渲染 — renderer.tsink/renderer.ts:31 — createRenderer 闭包遍历 DOM 树 → renderNodeToOutput → Output 对象Output → Screen 缓冲区(rows x cols 二维字符数组)
5. 双缓冲 Blit — Ink.onRender()ink/ink.tsx:420-789 — 核心渲染管线prev 帧(已写入终端) vs back 帧(当前计算结果)
逐行对比 prev vs back差异行 → ANSI 转义序列
变化
writeRaw → stdout光标移动 + SGR 样式 + OSC
交换帧 ↓
prev = back; back = nextFrame
Messages 组件渲染Messages.tsx:341 — 消息列表管理
6. shouldRenderStatically 判断Messages.tsx:779-833 — 静态渲染缓存已完成消息可跳过 React 渲染和 Yoga 布局
静态渲染(缓存命中)
直接复用 Screen 缓冲区跳过 React + Yoga
动态渲染(活跃消息)
完整 React 渲染周期
7. Message 组件 — 内容块类型分发Message.tsx:58 — 处理多种 content block
text文本内容
tool_use工具调用
thinking思维过程
tool_result工具结果
虚拟滚动 — VirtualMessageList
VirtualMessageList.tsx:289 — 全屏模式下启用
useVirtualScroll 按需渲染可见区域消息
computeSliceStart(Messages.tsx:315)计算可视窗口起始位置
③ 避免渲染历史消息 → 5000 行 REPL 中仅遍历活跃消息
性能关键:将每帧 DOM 树遍历范围压缩到流式输出中的活跃消息
stdin 按键事件
useTextInputhooks/useTextInput.ts:73
mapKey → 编辑操作光标/删除/Kill ring
handleEnter提交 vs 换行判断
setState 更新触发 React 渲染
双缓冲 Blitdiff → ANSI 输出
writeRaw → stdout终端显示更新
双缓冲 Blit 是性能核心:终端渲染瓶颈是 I/O 带宽而非 CPU。80x24 终端全量输出约 100KB ANSI 序列,双缓冲压缩到实际变化的几十到几百字节。配合静态渲染缓存(已完成消息跳过 React + Yoga),5000 行 REPL 的每帧渲染仅遍历活跃消息。

核心处理流程详解

Claude Code 的 UI 渲染管线是一个精心设计的多层架构:从 React 组件树出发,经过自定义 Reconciler 转换为虚拟 DOM,再通过 Yoga 布局引擎计算终端坐标,最终由 Screen 缓冲区和双缓冲 blit 机制将内容写入终端。整个流程在每帧(约 60fps)内完成,同时支持搜索高亮、文本选择、虚拟滚动等高级特性。

1. React 组件树构建 — REPL 入口
REPL(screens/REPL.tsx:572)作为根组件,组合 Messages(消息列表)、TextInput(输入框)、StatusLine(状态栏)等组件。通过 useAppState 订阅全局状态,状态变更触发 React 调度。App(components/App.tsx:19)提供 AppStateProviderStatsProviderFpsMetricsProvider 三层上下文。
2. 自定义 Reconciler — React → 虚拟 DOM
reconciler.ts(ink/reconciler.ts)使用 react-reconciler 创建自定义宿主环境。createInstance(L331)将 React 元素转为 DOMElement 节点,commitUpdate(L426)处理属性差异更新。resetAfterCommit(L247)在每次提交后触发 Ink.onRender,启动渲染管线。
3. Yoga 布局计算 — Flexbox → 终端坐标
ink/layout/yoga.ts 将虚拟 DOM 树映射到 Yoga 节点树。每个 DOMElement 携带一个 Yoga 节点,通过 Flexbox 属性(width/height/flex/padding 等)计算出每个节点的终端行列坐标。布局结果回写到 DOMElement 的 yi(Yoga info)字段。
4. DOM → Screen 渲染 — renderer.ts
createRenderer(ink/renderer.ts:31)创建渲染闭包。遍历 DOM 树,对每个节点调用 renderNodeToOutput 将文本/样式写入 Output 对象。Output 最终转换为 Screen 缓冲区——一个二维字符数组(rows x cols),每个单元格存储字符、前景色、背景色、超链接等样式信息。
5. 双缓冲 Blit — Ink.onRender()
Ink.onRender(ink/ink.tsx:420-789)是渲染管线的核心。维护 prevback 两个 Frame 缓冲区。每帧对比 prev 和 back 的差异(blit),只将变化的行写入终端。这避免了全屏刷新的闪烁问题。关键优化:queueMicrotask(L212)确保同一事件循环内的多次 setState 只触发一次渲染。
6. 消息列表渲染 — Messages / VirtualMessageList
Messages.tsx(L341)管理消息的渲染、折叠和上下文窗口。通过 computeSliceStart(L315)计算可视窗口的起始位置,避免渲染历史消息。VirtualMessageList.tsx(L289)在全屏模式下启用虚拟滚动,使用 useVirtualScroll hook 实现按需渲染。消息组件 Message.tsx(L58)处理 tool_use、thinking、text 等多种内容块的渲染。
7. 输入处理 — useTextInput → stdin
useTextInput(hooks/useTextInput.ts:73)处理所有键盘输入:多行编辑、历史导航、Kill/Yank ring、粘贴检测。mapKey(L318)将按键映射到编辑操作,handleEnter(L247)区分提交与换行。TextInput.tsx(components/TextInput.tsx:37)将 hook 与 Ink 的 useAnimationFrame 集成,实现光标闪烁。
8. 终端输出 — writeRaw → ANSI 序列
Ink.writeRaw(ink/ink.tsx:1433)将 blit 差异转换为 ANSI 转义序列(光标移动 + SGR 样式 + OSC 超链接),通过 process.stdout.write 写入终端。支持 256 色、真彩色、OSC 52 剪贴板、DEC 2026 模式等终端能力。Alt Screen 模式(L357)通过保存/恢复终端状态实现全屏 UI。
整个渲染管线的性能关键在于双缓冲 blit:只传输变化的行到终端。在 5000 行的 REPL 组件中,通过 shouldRenderStatically(Messages.tsx:779)对已完成的消息使用静态渲染缓存,跳过 React 渲染和 Yoga 布局,将每帧的 DOM 树遍历范围压缩到仅包含流式输出中的活跃消息。

设计精华

1. 自定义 React Reconciler 实现终端渲染

Claude Code 没有使用传统的终端 UI 库(如 blessed、ncurses),而是复用了 React 的组件模型和调度器。通过 react-reconciler(ink/reconciler.ts)创建自定义宿主环境,将 React 组件树映射到终端屏幕。这意味着开发者可以使用 React 的 hooks、context、memo 等所有特性来构建终端 UI,与 Web 开发体验完全一致。

自定义 Reconciler 核心
将 React Fiber 树映射到虚拟 DOM 节点,commit 阶段触发渲染
// ink/reconciler.ts:331-359
createInstance(originalType, newProps, _root, hostContext) {
  const node = dom.createNode(originalType)
  for (const [key, value] of Object.entries(newProps)) {
    if (key === 'children') continue
    applyProp(node, key, value) // 样式 → Yoga 节点属性
  }
  return node
}

// ink/reconciler.ts:247-315
resetAfterCommit(rootNode) {
  // 每次 React 提交后触发一次渲染
  deferredRender() // queueMicrotask → Ink.onRender
}
这个设计的深层洞察:React 的调度器本身就解决了终端 UI 中最难的"何时重绘"问题。通过 queueMicrotask(L212),同一事件循环中的多次 setState 被自动批处理,避免了对同一帧的重复渲染。React 的优先级调度还确保用户输入(高优先级)不会被长消息渲染(低优先级)阻塞。

2. 双缓冲 Blit 渲染策略

Ink.onRender(ink/ink.tsx:420)维护两个 Frame:prev(上一帧已写入终端的内容)和 back(当前帧的新内容)。Blit 对比两者差异,只输出变化的行。这种策略避免了全屏刷新导致的闪烁,同时将终端 I/O 降到最低。

双缓冲差异输出
prev 缓冲区记录已写入终端的状态,back 是当前帧的计算结果
// ink/ink.tsx:420 — onRender 核心流程
onRender() {
  // 1. Yoga 布局计算
  const nextFrame = renderer({ terminal, ... })
  // 2. 差异对比 + ANSI 输出
  for (let y = 0; y < rows; y++) {
    if (!prev[y].equals(back[y])) {
      // 只输出变化的行
      writeRaw(moveCursor(y) + back[y].toAnsi())
    }
  }
  // 3. 交换缓冲区
  prev = back; back = nextFrame
}
终端渲染的瓶颈不是 CPU 计算,而是 I/O 带宽。一个 80x24 的终端有 1920 个单元格,每帧全量输出需要约 100KB ANSI 序列。双缓冲将这个量级降到实际变化的几十到几百字节。在快速流式输出场景(如模型响应),这意味着终端体验接近原生应用的流畅度。

3. 静态渲染缓存 — shouldRenderStatically

在 Messages.tsx 中,shouldRenderStatically(L779-833)判断已完成的消息是否可以跳过重新渲染。判断条件包括:消息不是正在流式输出、没有活跃的 tool_use、没有兄弟 tool_use、屏幕尺寸未变等。符合条件的消息直接使用缓存的渲染结果,避免重复的 React 渲染和 Yoga 布局计算。

静态渲染缓存
已完成消息跳过 React 渲染周期,直接复用 Screen 缓冲区内容
// Messages.tsx:779-833
export function shouldRenderStatically(
  message, streamingToolUseIDs, inProgressToolUseIDs,
  siblingToolUseIDs, screen, lookup) {
  // 非活跃消息:无流式/进行中/兄弟 tool_use
  const isActive = streamingToolUseIDs.has(message.uuid)
    || inProgressToolUseIDs.has(message.uuid)
  if (isActive) return false
  // 屏幕尺寸未变 + 无 pending 操作
  return screen === lookup.screen
}
这个优化是必要的,因为 REPL 的消息列表可能包含数百条消息。在流式输出期间,如果每帧都遍历所有历史消息进行 React 渲染和 Yoga 布局,延迟会随消息数线性增长。静态缓存将每帧的工作量从 O(n) 降到 O(1)(仅当前流式消息),这是在 5000+ 行 REPL 组件中保持 60fps 的关键。

4. 消息切片窗口 — computeSliceStart

computeSliceStart(Messages.tsx:315-340)实现了上下文窗口裁剪:只渲染最近 N 条消息,历史消息被折叠为 "N messages collapsed"。这不是简单的固定数量裁剪——它考虑了折叠边界(不在 tool_use 和 tool_result 之间切断)、锚点消息(保持最近用户消息可见)和增量更新。

上下文窗口裁剪与静态渲染缓存互补:窗口决定"哪些消息需要渲染",静态缓存决定"哪些已渲染的消息可以跳过"。两者结合实现了恒定时间的渲染性能,无论会话中积累了多少历史消息。

Agent 实践借鉴 — 客服 Agent 面板设计

本节回答:如果你在设计一个客服 Agent 助手的坐席面板,Claude Code 的 UI 渲染系统有哪些值得抄、哪些不需要抄?

1. 场景映射:客服坐席面板的实时需求

客服坐席面板和 Claude Code 的终端有一个共同的核心需求:实时流式渲染。坐席不能等 agent 想完 5 秒才看到结果,客户的消息也不能有延迟。不同之处在于,客服面板是 Web 应用,不需要终端的黑魔法:

坐席面板的四个核心需求
实时性、组件化、多会话、状态指示
// 坐席面板的实时渲染需求
// 1. Agent 回复必须流式显示 — token 到达即渲染
//    客户等 5 秒才看到第一个字 → 差评
//
// 2. 工具结果需要不同渲染样式:
//    /查订单 → 订单卡片(商品名、金额、状态)
//    /查物流 → 时间线(已发货→运输中→派送中)
//    /退单   → 进度条(审核中→退款中→已完成)
//
// 3. 多个客户同时在线,面板需要切换会话
//    坐席同时服务 3-5 个客户
//
// 4. 工具调用状态实时指示
//    "正在查询订单..." / "查询完成" / "查询失败"
CC 借鉴点映射
CC 的终端渲染 → 客服的 Web 面板
// Claude Code                 →  客服 Agent 面板
// ──────────────────────────────────────────────
流式 token 渲染        →  WebSocket/SSE 实时追加
工具结果注册表          →  订单→卡片、物流→时间线
双缓冲差异输出          →  不需要(浏览器 DOM 天然增量)
Yoga 布局引擎          →  不需要(CSS flexbox 就行)
React Reconciler       →  不需要(标准 Web React/Vue)
消息窗口裁剪            →  虚拟滚动列表(成熟的 Web 方案)

2. 借鉴 CC + 客服改造:伪代码实现

以下是客服面板的核心实现,借鉴 CC 的流式渲染和工具结果注册表,但用 Web 原生方案替代终端黑魔法:

流式消息渲染组件
借鉴 CC 的 token 到达即渲染,用 Web SSE 实现打字效果
// 流式消息组件 — 借鉴 CC 的 StreamingText,用 Web 原生实现
function StreamingMessage({ sessionId }: { sessionId: string }) {
  const [tokens, setTokens] = useState<string[]>([])
  const [status, setStatus] = useState<"streaming"|"done"|"error">("streaming")

  useEffect(() => {
    // SSE 连接 — 每个 token 到达就追加渲染
    const eventSource = new EventSource(`/api/agent/stream/${sessionId}`)

    eventSource.onmessage = (event) => {
      const data = JSON.parse(event.data)
      if (data.type === "token") {
        setTokens(prev => [...prev, data.content])  // 追加渲染
      } else if (data.type === "done") {
        setStatus("done")
        eventSource.close()
      } else if (data.type === "error") {
        setStatus("error")
        eventSource.close()
      }
    }

    return () => eventSource.close()
  }, [sessionId])

  return (
    <div className="agent-message">
      <div className="message-content">
        {tokens.join("")}
        {status === "streaming" && <span className="cursor">|</span>}
      </div>
      {status === "error" && <div className="error-hint">生成中断,点击重试</div>}
    </div>
  )
}
工具结果渲染器注册表
借鉴 CC 的 toolRenderers Map,每种工具注册自己的渲染组件
// 工具结果渲染注册表 — 借鉴 CC 的 toolRenderers.set() 模式
const toolResultRenderers = new Map<string, React.ComponentType<ToolResultProps>>()

// 注册:每种工具有自己的渲染组件
toolResultRenderers.set("query_order", OrderCardRenderer)
toolResultRenderers.set("query_logistics", TimelineRenderer)
toolResultRenderers.set("process_refund", ProgressRenderer)
toolResultRenderers.set("issue_coupon", CouponDetailRenderer)
toolResultRenderers.set("knowledge_search", ArticleListRenderer)

// 通用渲染器 — 根据工具名查找对应组件
function ToolResultDisplay({ toolUse, result }: ToolResultProps) {
  const Renderer = toolResultRenderers.get(toolUse.name) ?? DefaultTextRenderer
  return <Renderer input={toolUse.input} output={result} />
}

// 订单卡片渲染器示例
function OrderCardRenderer({ output }: { output: OrderResult }) {
  return (
    <div className="order-card">
      <div className="order-header">订单号:{output.orderId}</div>
      <div className="order-items">
        {output.items.map(item => (
          <div key={item.sku}>{item.name} x{item.qty} ¥{item.price}</div>
        ))}
      </div>
      <div className="order-status">状态:{output.statusText}</div>
    </div>
  )
}
会话切换管理 + 工具调用状态指示器
多客户并行,坐席切换会话时状态不丢失
// 会话管理器 — 多客户并行
function SessionManager() {
  const [sessions, setSessions] = useState<Map<string, Session>>(new Map())
  const [activeSessionId, setActiveSessionId] = useState<string>(null)

  // 切换会话时,保存当前会话状态(流式输出不中断)
  const switchSession = (targetId: string) => {
    // 后台继续接收其他会话的 SSE 消息
    setActiveSessionId(targetId)
  }

  return (
    <div className="session-container">
      {/* 左侧:客户列表 */}
      <div className="session-list">
        {Array.from(sessions.values()).map(s => (
          <SessionTab key={s.id} session={s}
            isActive={s.id === activeSessionId}
            onClick={() => switchSession(s.id)} />
        ))}
      </div>
      {/* 右侧:当前会话的消息区 */}
      <div className="message-area">
        {sessions.get(activeSessionId) &&
          <MessageList messages={sessions.get(activeSessionId).messages} />}
      </div>
    </div>
  )
}

// 工具调用状态指示器
function ToolCallIndicator({ status, toolName }: { status: string, toolName: string }) {
  const statusConfig = {
    pending:  { icon: "⏳", text: `正在${toolName}...`, className: "pending" },
    complete: { icon: "✓",  text: `${toolName}完成`, className: "complete" },
    failed:   { icon: "✗",  text: `${toolName}失败`, className: "failed" },
  }
  const cfg = statusConfig[status]
  return <div className={`tool-indicator ${cfg.className}`}>{cfg.icon} {cfg.text}</div>
}

3. 落地清单:必须抄 vs 不需要抄

必须抄:
  • 流式渲染 — token 到达即显示,不等完整响应。CC 用 Observable,客服用 SSE/WebSocket,本质相同
  • 工具结果渲染器注册表 — 每种工具注册独立渲染组件,新增工具不改核心逻辑。CC 用 Map,客服也用 Map
  • 截断策略 — 超长输出必须截断,保留头尾 N 行 + 折叠中间。CC 在终端做,客服在 Web 做更简单
不需要抄:
  • 自定义 React Reconciler — CC 为了在终端渲染 React 组件造了整个 reconciler,客服面板是 Web 应用,直接用标准 React/Vue
  • Ink 组件模型 + Yoga 布局引擎 — 终端没有 CSS,CC 用 Yoga 实现 flexbox。客服面板有原生 CSS,完全不需要
  • 双缓冲差异输出 — CC 为了避免终端闪烁。浏览器 DOM 天然支持增量更新,不需要手动管理帧缓冲

4. 常见坑

坑 1:SSE 流式渲染用 innerHTML 追加,导致 XSS。直接用 div.innerHTML += token 在 token 包含用户输入时会产生 XSS 注入。解决方案:用 document.createTextNode(token) 或 React 的 setState(prev => [...prev, token]),框架自动转义。
坑 2:工具结果渲染器返回的组件越来越慢。订单卡片渲染了 200 条商品明细,页面卡顿。解决方案:渲染器内部必须做分页或虚拟滚动,注册表只负责路由到正确的组件,不负责性能优化。
坑 3:切换会话时丢失流式状态。坐席从客户 A 切到客户 B,再切回 A,发现 A 的 agent 回复中断了。解决方案:每个会话独立维护 SSE 连接和消息缓冲区,切换只改变 activeSessionId,不销毁后台连接。
坑 4:所有消息组件全量重渲染。新 token 到达时,整个消息列表 500+ 条消息全部重渲染。解决方案:借鉴 CC 的 static 标记——已完成的消息标记为 static,React.memo 跳过重渲染,只对流式中的消息做更新。

代码索引

文件行数说明
ink.ts~86Ink 统一导出入口,封装 ThemeProvider
ink/ink.tsx~1722Ink 核心类:双缓冲渲染、事件循环、终端 I/O
ink/reconciler.ts~200React reconciler 适配器(react-reconciler)
ink/renderer.ts~150DOM 树 → Screen 缓冲区渲染器
ink/render-to-screen.ts~120独立渲染(搜索高亮等场景)
ink/layout/yoga.ts~309Yoga 布局引擎适配(Flexbox → 终端坐标)
ink/dom.ts~200虚拟 DOM 节点定义和操作
ink/screen.ts~300Screen 缓冲区、StylePool/CharPool
ink/frame.ts~90Frame 数据结构(screen + viewport + cursor)
ink/terminal.ts~150终端能力检测(DEC 2026、OSC 9;4 等)
ink/events/dispatcher.ts~120双阶段事件派发器
ink/parse-keypress.ts~300按键序列解析状态机
ink/components/App.tsx~250顶层组件:stdin 事件接收与分发
ink/components/Box.tsx~200基础布局容器(映射到 Yoga 节点)
ink/components/Text.tsx~150基础文本组件
ink/components/ScrollBox.tsx~300可滚动容器组件
ink/hooks/use-input.ts~80键盘输入 hook
ink/hooks/use-selection.ts~200文本选择 hook
ink/termio/~600ANSI 序列库(CSI/DEC/OSC/SGR)
screens/REPL.tsx~2000主 REPL 屏幕
components/PromptInput.tsx~1500用户输入组件
components/VirtualMessageList.tsx~500虚拟消息列表
components/Messages.tsx~800消息列表管理与渲染
components/design-system/~1000设计系统(ThemeProvider/ThemedText/ThemedBox 等 14 个文件)
components/messages/~300030+ 消息类型组件