Skip to content

Latest commit

 

History

History
1001 lines (800 loc) · 32.5 KB

File metadata and controls

1001 lines (800 loc) · 32.5 KB

06 - UI 渲染系统

Claude Code v2.1.88 源码分析 - 终端 UI 渲染引擎架构

Architecture Diagram

UI Rendering Architecture

目录

  1. 架构总览
  2. Ink 渲染引擎
  3. Yoga 布局引擎
  4. 事件系统
  5. 终端 I/O 抽象层
  6. 组件层次结构
  7. 关键组件分析
  8. 快捷键系统
  9. Vim 模式集成

1. 架构总览

Claude Code 的 UI 建立在深度定制的 Ink 渲染引擎之上,实现了完整的终端 GUI 框架。其核心思路是将 Web 领域的 React 虚拟 DOM + Flexbox 布局模型移植到终端环境,同时针对终端特性做了大量优化。

渲染管线总览

  React JSX 组件树
       |
       v
  +-------------------+
  | React Reconciler  |  react-reconciler (Fiber)
  | (reconciler.ts)   |  创建/更新/删除 DOM 节点
  +-------------------+
       |
       v
  +-------------------+
  | 自定义 DOM 层     |  dom.ts: DOMElement / TextNode
  | (ink-box/ink-text) |  类似浏览器 DOM 的轻量树
  +-------------------+
       |
       v  resetAfterCommit → onComputeLayout
  +-------------------+
  | Yoga 布局引擎     |  native-ts/yoga-layout/index.ts
  | (纯 TS 移植)      |  Flexbox 计算: 位置、尺寸
  +-------------------+
       |
       v  onRender (节流 16ms)
  +-------------------+
  | 渲染器            |  renderer.ts → render-node-to-output.ts
  | DOM → Screen 缓冲 |  遍历 DOM 树, 写入 Screen 二维数组
  +-------------------+
       |
       v
  +-------------------+
  | Screen Diff       |  log-update.ts
  | 双缓冲 + 增量差异 |  比较 front/back frame
  +-------------------+
       |
       v
  +-------------------+
  | 终端输出          |  terminal.ts → writeDiffToTerminal
  | ANSI 序列 → stdout|  BSU/ESU 同步更新
  +-------------------+

关键源文件索引

文件 行数 职责
ink/ink.tsx ~6000 Ink 主类 - 协调所有子系统
ink/reconciler.ts ~510 React reconciler 配置
ink/dom.ts ~485 自定义 DOM 节点实现
ink/renderer.ts ~180 渲染器工厂 - DOM 到 Screen
ink/output.ts ~600+ Output 类 - 操作收集器
ink/screen.ts ~1200+ Screen 缓冲区 + CharPool/StylePool
ink/log-update.ts ~650+ 帧差异计算 + Patch 生成
ink/render-node-to-output.ts ~1500+ DOM 树遍历与绘制逻辑
native-ts/yoga-layout/index.ts ~2000+ 纯 TS Yoga 布局引擎移植

2. Ink 渲染引擎

2.1 Reconciler 配置

reconciler.ts 使用 react-reconciler 库创建自定义 React 渲染器。这是将 React 连接到终端 DOM 的桥梁。

宿主元素类型 (ElementNames):

  • ink-root - 根节点,拥有 FocusManager
  • ink-box - 容器元素(类似 <div>)
  • ink-text - 文本容器(类似 <span>)
  • ink-virtual-text - 嵌套文本(Text 内的 Text)
  • ink-link - 超链接
  • ink-progress - 进度条
  • ink-raw-ansi - 预渲染的 ANSI 字符串

Reconciler 关键回调:

createInstance(type, props)
  → createNode(type)          // 创建 DOMElement
  → applyProp(node, k, v)     // 设置 style/attributes/events

commitUpdate(node, type, oldProps, newProps)
  → diff(oldProps, newProps)   // 浅比较找出变更
  → setStyle / setAttribute   // 应用变更, markDirty

resetAfterCommit(rootNode)
  → rootNode.onComputeLayout()  // Yoga 重新布局
  → rootNode.onRender()         // 触发渲染帧 (节流)

Props Diff 算法: Reconciler 自实现了一个 O(n) 的浅层属性比较。React 19 中 commitUpdate 直接接收 oldProps/newProps,不再需要 prepareUpdate 返回 UpdatePayload。对 style 属性做额外的浅比较,避免 React 每次渲染创建新对象导致的无效 dirty 标记。

脏标记传播 (markDirty):

markDirty(node):
  current = node
  while current:
    current.dirty = true
    if current is ink-text/ink-raw-ansi:
      current.yogaNode.markDirty()  // 触发文本重新测量
    current = current.parentNode

2.2 自定义 DOM

dom.ts 定义了一套轻量 DOM 树结构,对标浏览器 DOM 但针对终端做了大幅简化:

type DOMElement = {
  nodeName: ElementNames        // 元素类型
  attributes: Record<string, DOMNodeAttribute>
  childNodes: DOMNode[]         // 子节点
  parentNode: DOMElement | undefined
  yogaNode?: LayoutNode         // Yoga 布局节点
  style: Styles                 // Flexbox 样式
  dirty: boolean                // 脏标记
  // 滚动状态
  scrollTop?: number
  pendingScrollDelta?: number
  stickyScroll?: boolean
  scrollAnchor?: { el: DOMElement; offset: number }
  // 事件处理器 (与 attributes 分离存储)
  _eventHandlers?: Record<string, unknown>
  // 调试
  debugOwnerChain?: string[]
}

设计要点:

  • 事件处理器与属性分离:handler identity 变化不触发 dirty,避免每帧 blit 失效
  • 文本节点 (TextNode) 不拥有 Yoga 节点,由父级 ink-text 统一测量
  • ink-virtual-textink-linkink-progress 不创建 Yoga 节点(无需独立布局)

2.3 双缓冲渲染

Ink 实例维护两个 Frame 缓冲区: frontFrame(当前显示帧)和 backFrame(下一帧渲染目标):

Frame = {
  screen: Screen      // 二维字符缓冲区
  viewport: Size      // 终端尺寸
  cursor: Cursor      // 光标位置
  scrollHint?: ScrollHint  // DECSTBM 滚动优化
}

渲染流程:

onRender():
  1. renderer({frontFrame, backFrame, ...})
     → Yoga 计算布局
     → renderNodeToOutput: DOM 树 → Output 操作队列
     → output.get(): 操作 → Screen 缓冲区

  2. log.render(frame, prevFrame)
     → 逐行比较 frontScreen vs backScreen
     → 生成 Patch[] (cursorMove + styleStr + stdout)

  3. optimize(patches)
     → 合并相邻的 stdout patch
     → 消除冗余的光标移动

  4. writeDiffToTerminal(terminal, diff)
     → BSU (Begin Synchronized Update)
     → 将所有 patch 序列化为单个字符串
     → ESU (End Synchronized Update)
     → stdout.write(buffer)

  5. 交换 frontFrame ↔ backFrame

2.4 Screen 缓冲区

screen.ts 实现了高性能的二维字符存储:

字符串池化 (CharPool):

CharPool:
  - strings: string[]     // 按 ID 存储
  - ascii: Int32Array      // ASCII 快速路径 (charCode → id)
  - stringMap: Map         // 非 ASCII 慢路径

  intern(" ") → 0          // 空格永远是 ID 0
  intern("") → 1           // 空字符串是 ID 1
  intern("a") → ascii[97]  // ASCII 直接索引

样式池化 (StylePool): 样式码 (AnsiCode[]) 被 intern 为整数 ID。Cell 只存 styleId,diff 时直接比较整数而非字符串。StylePool 是 session 级生命周期,永不重置。

超链接池化 (HyperlinkPool): OSC 8 超链接 URL 被 intern 为整数。每 5 分钟重置(防止内存增长),setCellAt 每帧重新 intern(Map.get, 成本低)。

Cell 结构:

Screen 内部用 TypedArray 存储每个 cell:
  charId: number      // CharPool 中的 ID
  styleId: number     // StylePool 中的 ID
  width: CellWidth    // 0=正常, 1=宽字符尾部(spacer)
  hyperlinkId: number // HyperlinkPool 中的 ID

2.5 增量 Diff 算法

log-update.tsLogUpdate 类实现帧间差异:

主屏幕模式 (scrollback):

  • 逐行比较 prevScreen 和 nextScreen
  • 检测高度变化:新增行直接追加,减少行用 eraseLines
  • 对每行使用 diffEach 找出变化的 cell 区间
  • 生成最小化的光标移动 + 样式 + 文本 patch

备用屏幕模式 (alt-screen):

  • DECSTBM 滚动优化:当内容整体上移(消息流),使用硬件滚动区域而非重绘
  • 光标位置钳制在视口内,防止 LF 导致内容滚出

Blit 优化: render-node-to-output.ts 中的 blit 机制:如果一个节点未标记 dirty 且在 nodeCache 中有上一帧的位置/尺寸,直接从 prevScreen 复制该区域到 backScreen。这使得稳态帧(只有 spinner 动画)的渲染接近 O(changed) 而非 O(total)。

renderNodeToOutput(node, output, {prevScreen}):
  if !node.dirty && nodeCache.has(node) && prevScreen:
    blitRegion(prevScreen → backScreen, rect)  // 直接复制
    return
  // 否则完整渲染子树
  for child in node.childNodes:
    renderNodeToOutput(child, ...)

3. Yoga 布局引擎

3.1 纯 TypeScript 移植

Claude Code 将 Facebook 的 Yoga 布局引擎(原 C++ / WASM)移植为纯 TypeScript 实现,位于 native-ts/yoga-layout/index.ts

移植范围 - 覆盖 Ink 实际使用的 Flexbox 子集:

  • flex-direction (row/column + reverse)
  • flex-grow / flex-shrink / flex-basis
  • align-items / align-self (stretch, flex-start, center, flex-end)
  • justify-content (全部六种值)
  • margin / padding / border / gap
  • width / height / min / max (point, percent, auto)
  • position: relative / absolute
  • display: flex / none
  • measure functions (文本测量)
  • flex-wrap: wrap / wrap-reverse (多行 flex)
  • margin: auto
  • display: contents
  • baseline alignment

未移植 (Ink 不使用):

  • aspect-ratio
  • box-sizing: content-box
  • RTL direction

3.2 适配器层

layout/ 目录实现了布局引擎的抽象接口:

layout/node.ts     → LayoutNode 接口定义 (50+ 方法)
layout/engine.ts   → createLayoutNode() 工厂
layout/yoga.ts     → YogaLayoutNode 适配器
layout/geometry.ts → Point, Size, Rectangle 几何类型

LayoutNode 接口 定义了完整的 Flexbox API:

type LayoutNode = {
  // 树操作
  insertChild(child, index): void
  removeChild(child): void

  // 布局计算
  calculateLayout(width?, height?): void
  setMeasureFunc(fn): void
  markDirty(): void

  // 读取计算结果
  getComputedLeft(): number
  getComputedTop(): number
  getComputedWidth(): number
  getComputedHeight(): number

  // 样式设置 (50+ setter 方法)
  setWidth / setHeight / setFlexDirection / ...
}

YogaLayoutNode 将 LayoutNode 接口映射到实际 Yoga Node 的 API,包括枚举值转换:

LayoutFlexDirection.Column → FlexDirection.Column
LayoutMeasureMode.Exactly → MeasureMode.Exactly
LayoutEdge.Left → Edge.Left

3.3 文本测量

ink-text 节点通过 setMeasureFunc 注册文本测量回调:

measureTextNode(node, width, widthMode):
  rawText = squashTextNodes(node)  // 收集所有子文本
  text = expandTabs(rawText)        // Tab → 空格
  dimensions = measureText(text, width)

  if dimensions.width <= width:
    return dimensions               // 不需要换行

  if widthMode === Undefined && text.includes('\n'):
    return measureText(text, max(width, dimensions.width))

  wrappedText = wrapText(text, width, textWrap)
  return measureText(wrappedText, width)

ink-raw-ansi 节点跳过所有测量:由生产者预设 rawWidth / rawHeight 属性。

3.4 布局时机

布局在 React commit 阶段执行,通过 resetAfterCommitonComputeLayout:

onComputeLayout():
  rootNode.yogaNode.setWidth(terminalColumns)
  rootNode.yogaNode.calculateLayout(terminalColumns)

这确保 useLayoutEffect hooks 可以访问新鲜的布局数据。性能计数器追踪:

  • yogaVisited: 递归访问的节点数
  • yogaMeasured: 调用 measureFunc 的次数
  • yogaCacheHits: 缓存命中次数
  • yogaLive: 存活 Node 实例数(增长 = 泄漏)

4. 事件系统

4.1 事件调度器

events/dispatcher.ts 实现了类 DOM 的捕获/冒泡事件模型:

事件传播路径:

  root (capture ↓)     root (bubble ↑)
    |                    ^
    v                    |
  parent (capture ↓)   parent (bubble ↑)
    |                    ^
    v                    |
  target (at_target)   target (at_target)

收集顺序:
  [root-cap, parent-cap, target-cap, target-bub, parent-bub, root-bub]

Dispatcher 类:

class Dispatcher {
  currentEvent: TerminalEvent | null
  currentUpdatePriority: number

  dispatch(target, event):       // 标准 capture+bubble
  dispatchDiscrete(target, event): // 离散优先级 (键盘/点击)
  dispatchContinuous(target, event): // 连续优先级 (滚动/resize)
}

事件优先级映射 (对齐 react-dom):

DiscreteEventPriority:   keydown, keyup, click, focus, blur, paste
ContinuousEventPriority: resize, scroll, mousemove
DefaultEventPriority:    其他

4.2 事件类型

events/
  event.ts               → TerminalEvent 基类
  keyboard-event.ts      → KeyboardEvent (key, modifiers)
  click-event.ts         → ClickEvent (col, row, localCol, localRow)
  focus-event.ts         → FocusEvent (relatedTarget)
  input-event.ts         → InputEvent (stdin 原始输入解析)
  terminal-event.ts      → EventTarget 接口定义
  terminal-focus-event.ts → 终端窗口焦点 (FocusIn/FocusOut)
  emitter.ts             → EventEmitter (简化版)

事件处理器 Props:

type EventHandlerProps = {
  onKeyDown / onKeyDownCapture    // 键盘
  onFocus / onFocusCapture        // 焦点获得
  onBlur / onBlurCapture          // 焦点失去
  onPaste / onPasteCapture        // 粘贴
  onResize                        // 终端大小变化
  onClick                         // 鼠标点击
  onMouseEnter / onMouseLeave     // 鼠标悬停
}

4.3 Hit-Test 与鼠标事件

hit-test.ts 实现了基于屏幕坐标的点击检测:

hitTest(root, col, row):
  rect = nodeCache.get(node)    // 渲染时缓存的屏幕矩形
  if (col, row) outside rect: return null
  // 后序遍历: 后面的兄弟节点在上层(最后绘制的优先)
  for i = children.length-1 downto 0:
    hit = hitTest(children[i], col, row)
    if hit: return hit
  return node

点击分发 (dispatchClick):

  1. hitTest 找到最深目标节点
  2. 向上遍历 parentNode 找到最近的 focusable 节点,调用 handleClickFocus
  3. 创建 ClickEvent(col, row),向上冒泡调用 onClick 处理器
  4. 支持 stopImmediatePropagation 终止冒泡

悬停分发 (dispatchHover):

  • 维护 hoveredNodes: Set<DOMElement>
  • 每次鼠标移动: hitTest → 收集路径上有 hover handler 的节点 → diff 新旧集合
  • 退出的节点触发 onMouseLeave,进入的节点触发 onMouseEnter
  • 不冒泡: 子节点间移动不触发父节点重新 enter/leave

4.4 输入解析

parse-keypress.ts 解析 stdin 字节流为结构化事件:

parseMultipleKeypresses(data: Buffer):
  → ParsedInput[] = (ParsedKey | ParsedMouse)[]

ParsedKey = {
  name: string       // 'a', 'return', 'up', 'f1' ...
  ctrl, shift, meta, super: boolean
  sequence: string    // 原始 ANSI 序列
}

ParsedMouse = {
  type: 'mouse'
  button: number      // 0=左, 1=中, 2=右, 3=释放
  col, row: number    // 0-based 坐标
  motion: boolean     // 移动事件
  ctrl, shift, meta: boolean
}

支持的终端协议:

  • xterm 标准: ESC[A (上), ESC[B (下), ESCOH (Home) 等
  • Kitty 键盘协议: CSI >1u - 精确修饰键 + 按下/释放分离
  • xterm modifyOtherKeys: CSI >4;2m - ctrl+shift+letter 消歧
  • SGR 鼠标格式: CSI < btn;col;row M/m - 精确坐标,无上限

5. 终端 I/O 抽象层

5.1 ANSI 序列分层

termio/
  ansi.ts       → ESC, BEL, SEP 基础常量
  csi.ts        → CSI 序列生成 (光标移动/擦除/滚动)
  dec.ts        → DEC 私有模式 (alt-screen/mouse/paste)
  esc.ts        → ESC 序列 (字符集/设备控制)
  osc.ts        → OSC 序列 (超链接/剪贴板/Tab标题)
  sgr.ts        → SGR 颜色/样式序列
  types.ts      → Action/Color/Event 类型定义
  parser.ts     → 输入流 ANSI 解析状态机
  tokenize.ts   → 输出 ANSI token 化

5.2 DEC 私有模式

DEC.CURSOR_VISIBLE  = 25     → 光标显隐
DEC.ALT_SCREEN_CLEAR= 1049   → 备用屏幕 (保存+切换+清屏)
DEC.MOUSE_NORMAL    = 1000   → 鼠标点击/释放/滚轮
DEC.MOUSE_BUTTON    = 1002   → 鼠标拖拽 (button-motion)
DEC.MOUSE_ANY       = 1003   → 全鼠标移动 (无按钮, 用于 hover)
DEC.MOUSE_SGR       = 1006   → SGR 格式鼠标报告
DEC.FOCUS_EVENTS    = 1004   → 终端焦点事件 (CSI I / CSI O)
DEC.BRACKETED_PASTE = 2004   → 粘贴括号 (区分输入 vs 粘贴)
DEC.SYNCHRONIZED_UPDATE = 2026 → 同步更新 (防闪烁)

5.3 同步更新 (DEC 2026)

terminal.ts 检测终端是否支持同步输出:

支持 BSU/ESU 的终端:
  iTerm2, WezTerm, Warp, Ghostty, Contour, VS Code (alacritty),
  Kitty, foot, Windows Terminal, VTE >= 0.68 (GNOME Terminal)

不支持:
  tmux (解析但不实现, chunking 破坏原子性)

writeDiffToTerminal 将所有 patch 拼接为单个字符串, 用 BSU/ESU 包裹:

BSU = CSI ?2026h   // Begin Synchronized Update
ESU = CSI ?2026l   // End Synchronized Update

buffer = BSU + patch1 + patch2 + ... + patchN + ESU
stdout.write(buffer)   // 单次 write, 终端原子渲染

5.4 终端能力检测

XTVERSION 探测: 通过 CSI > 0 q 发送查询, 终端回复 DCS > | name ST。用于 SSH 场景下检测 VS Code 终端(TERM_PROGRAM 不会通过 SSH 转发)。

Kitty 键盘协议: 仅在白名单终端启用:

EXTENDED_KEYS_TERMINALS = [
  'iTerm.app', 'kitty', 'WezTerm', 'ghostty', 'tmux', 'windows-terminal'
]

OSC 序列终止符: Kitty 使用 ST (ESC \) 而非 BEL (\x07),因为 BEL 会触发蜂鸣。

5.5 OSC 功能

OSC 8   → 超链接 (href URL)
OSC 9;4 → 进度报告 (iTerm2 3.6.6+, Ghostty 1.2.0+)
OSC 52  → 剪贴板设置 (SSH 场景, 通过 pty 到达客户端)
OSC 777 → Growl 通知 (部分终端)

剪贴板策略 (setClipboard):

  1. native: pbcopy 等原生工具 (高置信度)
  2. tmux-buffer: tmux load-buffer (依赖 set-clipboard 设置)
  3. osc52: OSC 52 序列直接写 stdout (最佳努力)

6. 组件层次结构

6.1 组件树总览

ink.tsx (Ink 实例)
  └── ink/components/App.tsx (根应用组件)
       ├── AppContext.Provider
       ├── StdinContext.Provider
       ├── ClockProvider (全局时钟)
       ├── TerminalSizeContext.Provider
       ├── TerminalFocusProvider
       ├── CursorDeclarationContext.Provider
       └── ErrorOverview (错误边界)
            └── {children}   ← 注入的业务组件

screens/REPL.tsx (主界面)
  └── AlternateScreen
       └── FullscreenLayout
            ├── Messages / VirtualMessageList (消息区)
            │    ├── MessageRow
            │    │    ├── Message (AI/用户消息)
            │    │    ├── ToolUseLoader (工具执行中)
            │    │    └── FileEditToolDiff (文件编辑 diff)
            │    └── CompactSummary
            ├── StatusLine (状态栏)
            │    ├── Stats (token/cost 统计)
            │    └── StatusNotices
            ├── PromptInput (输入框)
            │    ├── TextInput / VimTextInput
            │    ├── ContextSuggestions (上下文建议)
            │    └── PromptInputQueuedCommands
            └── PermissionRequest (权限对话框)
                 ├── diff/StructuredDiff (文件变更)
                 └── shell/ShellPermission (命令确认)

6.2 Ink 内部组件

ink/components/
  App.tsx              → 根组件, stdin 读取, 事件分发
  Box.tsx              → <Box> - 对应 ink-box, Flexbox 容器
  Text.tsx             → <Text> - 对应 ink-text, 带样式的文本
  ScrollBox.tsx        → 带虚拟滚动的容器 (overflow: scroll)
  AlternateScreen.tsx  → 备用屏幕管理
  Button.tsx           → 可交互按钮 (onClick + 焦点)
  Link.tsx             → OSC 8 超链接
  RawAnsi.tsx          → 预渲染 ANSI 字符串嵌入
  NoSelect.tsx         → 文本选择排除区域
  Newline.tsx          → 换行符
  Spacer.tsx           → 弹性间隔
  ClockContext.tsx      → 全局时钟 (统一 spinner/timer tick)
  TerminalSizeContext.tsx → 终端尺寸上下文
  TerminalFocusContext.tsx → 终端焦点状态

6.3 设计系统组件

components/design-system/
  → Dialog, Badge, Divider, Tabs 等标准 UI 组件
components/ui/
  → 更高层业务 UI 组件

7. 关键组件分析

7.1 PromptInput

components/PromptInput/PromptInput.tsx (~700 行) 是用户输入的核心组件。

结构:

PromptInput
  ├── TextInput / VimTextInput (根据 vim 模式切换)
  │    ├── BaseTextInput (底层光标管理)
  │    └── useDeclaredCursor (原生光标定位)
  ├── ContextSuggestions (Tab 补全)
  ├── PromptInputQueuedCommands (排队命令预览)
  ├── HistorySearchDialog (Ctrl+R 历史搜索)
  └── ModelPicker / ThemePicker (模态选择器)

关键 hooks:

  • useArrowKeyHistory - 上/下箭头浏览历史
  • useTypeahead - 输入预测
  • useInputBuffer - 批量输入缓冲(粘贴优化)
  • useHistorySearch - 增量搜索历史
  • useKeybinding / useKeybindings - 快捷键绑定

原生光标定位: useDeclaredCursor hook 声明光标应该在文本输入的某个位置。Ink 在每帧结束时读取此声明,将终端物理光标移到对应位置。这使得 CJK 输入法的预编辑文本出现在正确位置(终端模拟器在物理光标处渲染 IME)。

7.2 消息渲染

components/Messages.tsx (~3500 行) 管理消息列表:

Messages
  └── VirtualMessageList (虚拟化)
       ├── 使用 useVirtualScroll 管理可视窗口
       ├── 只渲染视口内的消息 + 缓冲区
       └── MessageRow[]
            ├── Message (AI回复)
            │    ├── Markdown 渲染
            │    ├── HighlightedCode (语法高亮)
            │    └── MarkdownTable
            ├── ToolUseLoader (工具执行)
            │    └── Spinner
            └── 各类工具结果组件

VirtualMessageList.tsx (~3500 行) 实现了终端环境下的虚拟滚动:

  • 基于 ScrollBox 的 overflow:scroll 容器
  • 动态计算可视范围,按需挂载/卸载消息
  • stickyScroll: 新消息到达时自动滚到底部
  • scrollAnchor: 元素级滚动定位(Yoga 布局时延迟读取坐标)
  • pendingScrollDelta: 快速滑动时分帧渲染中间状态

7.3 Diff 视图

components/diff/
  → 文件变更差异展示
components/StructuredDiff.tsx
  → 结构化 diff 视图 (行级/文件级)
components/FileEditToolDiff.tsx
  → 文件编辑工具的 diff 展示

Diff 渲染使用 ink-raw-ansi 节点: ColorDiff 预先计算好 ANSI 着色的字符串(包含换行),设置 rawWidth / rawHeight 跳过 Yoga 测量。这避免了对大型 diff 进行逐字符 stringWidth 计算。

7.4 权限对话框

components/permissions/
  PermissionRequest.tsx  → 主权限请求组件
  WorkerPendingPermission.tsx → Swarm worker 权限等待

权限对话框在 AI 需要执行文件编辑、Shell 命令等操作时弹出。它显示:

  • 工具名称和参数
  • 文件 diff 预览(对于 FileEditTool)
  • 命令内容(对于 BashTool)
  • Yes/No/Always 选择按钮

7.5 ScrollBox

ink/components/ScrollBox.tsx (~800 行) 实现终端虚拟滚动容器:

核心能力:

  • overflow: scroll 样式属性触发滚动行为
  • stickyScroll: 内容增长时自动保持底部对齐
  • 分帧渲染: pendingScrollDelta 限制每帧最大滚动行数,快速翻页显示中间帧
  • scrollAnchor: 元素级锚点滚动,在 render 阶段读取 Yoga 坐标(与 scrollHeight 同一 Yoga pass)
  • scrollClampMin/Max: 虚拟滚动边界钳制,防止 scrollTo 超过已挂载内容范围

ScrollBoxHandle 暴露给父组件:

type ScrollBoxHandle = {
  scrollTo(y): void
  scrollBy(dy): void
  scrollToElement(el, offset?): void
  scrollToBottom(): void
  getScrollTop(): number
  getScrollHeight(): number
  getViewportHeight(): number
  isSticky(): boolean
  subscribe(listener): unsubscribe
  setScrollClamp(min?, max?): void
}

8. 快捷键系统

8.1 架构

keybindings/
  schema.ts              → Zod schema: 上下文/动作定义
  parser.ts              → 按键字符串解析 (ctrl+k → ParsedKeystroke)
  match.ts               → 按键匹配逻辑 (Ink Key → target)
  resolver.ts            → 动作解析 (key → action, 含 chord 支持)
  defaultBindings.ts     → 默认绑定配置
  loadUserBindings.ts    → 用户自定义绑定加载
  validate.ts            → 配置校验
  KeybindingContext.tsx   → React Context Provider
  KeybindingProviderSetup.tsx → 绑定初始化
  useKeybinding.ts       → 组件级快捷键 hook
  reservedShortcuts.ts   → 系统保留快捷键
  shortcutFormat.ts      → 快捷键显示格式
  template.ts            → 配置模板生成

8.2 上下文系统

快捷键按上下文分组,每个上下文在特定 UI 状态下激活:

KEYBINDING_CONTEXTS = [
  'Global',          // 全局,始终活跃
  'Chat',            // 聊天输入框焦点
  'Autocomplete',    // 自动补全菜单可见
  'Confirmation',    // 确认/权限对话框
  'Help',            // 帮助面板打开
  'Transcript',      // 查看会话记录
  'HistorySearch',   // Ctrl+R 历史搜索
  'Task',            // 任务/Agent 前台运行
  'ThemePicker',     // 主题选择器
  'Settings',        // 设置面板
  'Tabs',            // Tab 导航
  'Attachments',     // 图片附件导航
  'Footer',          // 底部指示器
  'MessageSelector', // 消息选择器 (rewind)
  'DiffDialog',      // Diff 对话框
  'ModelPicker',     // 模型选择器
  'Select',          // 列表选择组件
  'Plugin',          // 插件对话框
]

8.3 Chord 序列

支持多键组合(类似 VS Code 的 chord):

解析: "ctrl+k ctrl+s" → [
  {key:'k', ctrl:true, alt:false, shift:false, meta:false, super:false},
  {key:'s', ctrl:true, alt:false, shift:false, meta:false, super:false}
]

Chord 解析状态机 (resolveKeyWithChordState):

状态: pending = null | ParsedKeystroke[]

输入按键 →
  1. Escape 且 pending != null → chord_cancelled
  2. 构建 testChord = pending + currentKeystroke
  3. 检查是否为更长 chord 的前缀
     → 是: chord_started, 等待更多按键
  4. 检查精确匹配
     → 匹配: match, 执行 action
     → null: unbound (用户显式解绑)
  5. 无匹配且 pending != null → chord_cancelled
  6. 无匹配且 pending == null → none (不处理)

后入优先: 用户绑定在默认绑定之后加载,匹配时取最后一个("last one wins"),实现覆盖语义。action: null 可以解绑默认快捷键。

8.4 修饰键处理

Ctrl       → key.ctrl
Shift      → key.shift
Alt / Opt  → key.meta (终端无法区分 Alt 和 Meta)
Super / Cmd→ key.super (仅 Kitty 协议终端支持)

特殊情况:
  Escape → key.escape=true, 但 Ink 设 key.meta=true (历史行为)
         → 匹配时忽略 meta, 否则 "escape" 绑定永远无法匹配

8.5 动作定义 (Action)

app:interrupt, app:exit, app:toggleTodos, app:globalSearch, ...
history:search, history:previous, history:next
chat:submit, chat:newline, chat:cancel, chat:modelPicker, ...
autocomplete:accept, autocomplete:dismiss, ...
confirm:yes, confirm:no, confirm:toggle, ...
transcript:toggleShowAll, transcript:exit
diff:dismiss, diff:previousFile, diff:nextFile
voice:pushToTalk
...

支持 command: 前缀: "command:help" → 执行 /help 命令

9. Vim 模式集成

9.1 状态机设计

vim/types.ts 定义了完整的 Vim 输入状态机:

VimState = INSERT | NORMAL

INSERT:
  insertedText: string    // 记录输入文本 (用于 dot-repeat)

NORMAL:
  command: CommandState   // 命令解析状态机

CommandState (所有可能状态):
  idle                    // 等待输入
  count {digits}          // 数字前缀 (如 3dw 中的 3)
  operator {op, count}    // 等待 motion (如 d 后等待 w)
  operatorCount {op, count, digits}  // operator + 数字
  operatorFind {op, count, find}     // operator + f/F/t/T
  operatorTextObj {op, count, scope} // operator + i/a
  find {find, count}      // f/F/t/T 等待字符
  g {count}               // g 前缀 (gg, ge 等)
  operatorG {op, count}   // operator + g (dge 等)
  replace {count}         // r 等待替换字符
  indent {dir, count}     // >>/<<

9.2 状态转换图

                 VimState
  +--------------+  +---------------------------------+
  | INSERT       |  | NORMAL (CommandState)           |
  | Esc → NORMAL |  |                                 |
  |              |  | idle ─┬─ d/c/y ──► operator     |
  |              |  |       ├─ 1-9 ────► count        |
  |              |  |       ├─ f/F/t/T ► find         |
  |              |  |       ├─ g ──────► g            |
  |              |  |       ├─ r ──────► replace      |
  |              |  |       ├─ >/<  ───► indent       |
  |              |  |       ├─ x ──────► [execute]    |
  |              |  |       ├─ p/P ────► [paste]      |
  |              |  |       ├─ u ──────► [undo]       |
  |              |  |       └─ i/a/o/I/A/O ► INSERT   |
  |              |  |                                 |
  |              |  | operator ─┬─ motion ► [execute] |
  |              |  |           ├─ 0-9 ──► opCount   |
  |              |  |           ├─ i/a ──► opTextObj |
  |              |  |           └─ f/F/t/T► opFind   |
  +--------------+  +---------------------------------+

9.3 Motion 与 Operator

Operator (动词): d(delete), c(change), y(yank)

Simple Motions: h l j k w b e W B E 0 ^ $

Text Objects: iw iW i" i' i( i[ i{ i< aw aW a" ...

Find: f{char} F{char} t{char} T{char}

组合执行 (operators.ts):

executeOperatorMotion(op, motion, count, ctx):
  range = resolveMotion(motion, count, ctx.text, ctx.cursor)
  applyOperator(op, range, ctx)

applyOperator:
  delete → 删除范围文本, 存入 register
  change → 删除范围文本, 进入 INSERT 模式
  yank   → 复制范围文本到 register

9.4 持久状态

type PersistentState = {
  lastChange: RecordedChange | null  // dot-repeat (.)
  lastFind: { type, char } | null   // ; 和 , 重复
  register: string                   // yank 寄存器
  registerIsLinewise: boolean        // 行级 vs 字符级
}

Dot-Repeat (.): 记录上一次修改操作的完整信息(类型、motion、count、文本),重放时不重新解析。

9.5 VimTextInput 组件

components/VimTextInput.tsx 包装 BaseTextInput,集成 Vim 状态机:

  • 在 NORMAL 模式下拦截所有按键,路由到状态机
  • 在 INSERT 模式下透传给 BaseTextInput,同时记录 insertedText
  • 显示模式指示器 (-- INSERT -- / -- NORMAL --)
  • 光标样式: INSERT 模式用竖线, NORMAL 模式用块状

附录 A: 帧渲染性能分析

FrameEvent.phases 记录每帧各阶段耗时:

phases = {
  renderer: number    // DOM → Screen 缓冲
  diff: number        // Screen 差异计算
  optimize: number    // Patch 优化
  write: number       // ANSI 序列输出
  patches: number     // Patch 数量 (变化量代理)
  yoga: number        // Yoga 布局耗时
  commit: number      // React reconcile 耗时
  yogaVisited: number // Yoga 节点访问数
  yogaMeasured: number// 测量回调调用数
  yogaCacheHits: number
  yogaLive: number    // 存活 Yoga 节点数
}

渲染节流: scheduleRender 使用 lodash throttle,间隔 FRAME_INTERVAL_MS(16ms),leading+trailing。实际渲染通过 queueMicrotask 延迟到 React layout effects 之后,确保 useDeclaredCursor 等 layout effect 的状态在当帧可用。

调试工具:

  • CLAUDE_CODE_DEBUG_REPAINTS: 记录每个节点的 React 组件堆栈,归因闪烁源头
  • CLAUDE_CODE_COMMIT_LOG: 写入每次 commit 的耗时和 Yoga 统计

附录 B: 关键优化技术总结

技术 位置 效果
字符串池化 (CharPool) screen.ts cell 比较用整数代替字符串
样式池化 (StylePool) screen.ts 样式转换缓存, 零分配
Blit 优化 render-node-to-output.ts 未变化子树直接复制
dirty 标记 dom.ts 跳过未变化子树的渲染
TypedArray Screen screen.ts 内存紧凑, 缓存友好
同步更新 (DEC 2026) terminal.ts 防止中间状态闪烁
分帧滚动 ScrollBox.tsx 快速翻页显示中间帧
ink-raw-ansi dom.ts + RawAnsi.tsx 大 diff 跳过测量
虚拟消息列表 VirtualMessageList.tsx 只渲染视口内消息
microtask 延迟渲染 ink.tsx layout effect 状态在当帧可用