Claude Code v2.1.88 源码分析 - 终端 UI 渲染引擎架构
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 布局引擎移植 |
reconciler.ts 使用 react-reconciler 库创建自定义 React 渲染器。这是将 React 连接到终端 DOM 的桥梁。
宿主元素类型 (ElementNames):
ink-root- 根节点,拥有 FocusManagerink-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
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-text、ink-link、ink-progress不创建 Yoga 节点(无需独立布局)
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
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
log-update.ts 的 LogUpdate 类实现帧间差异:
主屏幕模式 (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, ...)
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
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
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 属性。
布局在 React commit 阶段执行,通过 resetAfterCommit → onComputeLayout:
onComputeLayout():
rootNode.yogaNode.setWidth(terminalColumns)
rootNode.yogaNode.calculateLayout(terminalColumns)
这确保 useLayoutEffect hooks 可以访问新鲜的布局数据。性能计数器追踪:
yogaVisited: 递归访问的节点数yogaMeasured: 调用 measureFunc 的次数yogaCacheHits: 缓存命中次数yogaLive: 存活 Node 实例数(增长 = 泄漏)
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: 其他
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 // 鼠标悬停
}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):
- hitTest 找到最深目标节点
- 向上遍历 parentNode 找到最近的 focusable 节点,调用
handleClickFocus - 创建
ClickEvent(col, row),向上冒泡调用 onClick 处理器 - 支持
stopImmediatePropagation终止冒泡
悬停分发 (dispatchHover):
- 维护
hoveredNodes: Set<DOMElement> - 每次鼠标移动: hitTest → 收集路径上有 hover handler 的节点 → diff 新旧集合
- 退出的节点触发
onMouseLeave,进入的节点触发onMouseEnter - 不冒泡: 子节点间移动不触发父节点重新 enter/leave
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- 精确坐标,无上限
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 化
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 → 同步更新 (防闪烁)
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, 终端原子渲染
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 会触发蜂鸣。
OSC 8 → 超链接 (href URL)
OSC 9;4 → 进度报告 (iTerm2 3.6.6+, Ghostty 1.2.0+)
OSC 52 → 剪贴板设置 (SSH 场景, 通过 pty 到达客户端)
OSC 777 → Growl 通知 (部分终端)
剪贴板策略 (setClipboard):
native: pbcopy 等原生工具 (高置信度)tmux-buffer: tmux load-buffer (依赖 set-clipboard 设置)osc52: OSC 52 序列直接写 stdout (最佳努力)
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 (命令确认)
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 → 终端焦点状态
components/design-system/
→ Dialog, Badge, Divider, Tabs 等标准 UI 组件
components/ui/
→ 更高层业务 UI 组件
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)。
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: 快速滑动时分帧渲染中间状态
components/diff/
→ 文件变更差异展示
components/StructuredDiff.tsx
→ 结构化 diff 视图 (行级/文件级)
components/FileEditToolDiff.tsx
→ 文件编辑工具的 diff 展示
Diff 渲染使用 ink-raw-ansi 节点: ColorDiff 预先计算好 ANSI 着色的字符串(包含换行),设置 rawWidth / rawHeight 跳过 Yoga 测量。这避免了对大型 diff 进行逐字符 stringWidth 计算。
components/permissions/
PermissionRequest.tsx → 主权限请求组件
WorkerPendingPermission.tsx → Swarm worker 权限等待
权限对话框在 AI 需要执行文件编辑、Shell 命令等操作时弹出。它显示:
- 工具名称和参数
- 文件 diff 预览(对于 FileEditTool)
- 命令内容(对于 BashTool)
- Yes/No/Always 选择按钮
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
}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 → 配置模板生成
快捷键按上下文分组,每个上下文在特定 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', // 插件对话框
]
支持多键组合(类似 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 可以解绑默认快捷键。
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" 绑定永远无法匹配
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 命令
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} // >>/<<
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 |
+--------------+ +---------------------------------+
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
type PersistentState = {
lastChange: RecordedChange | null // dot-repeat (.)
lastFind: { type, char } | null // ; 和 , 重复
register: string // yank 寄存器
registerIsLinewise: boolean // 行级 vs 字符级
}Dot-Repeat (.): 记录上一次修改操作的完整信息(类型、motion、count、文本),重放时不重新解析。
components/VimTextInput.tsx 包装 BaseTextInput,集成 Vim 状态机:
- 在 NORMAL 模式下拦截所有按键,路由到状态机
- 在 INSERT 模式下透传给 BaseTextInput,同时记录 insertedText
- 显示模式指示器 (-- INSERT -- / -- NORMAL --)
- 光标样式: INSERT 模式用竖线, NORMAL 模式用块状
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 统计
| 技术 | 位置 | 效果 |
|---|---|---|
| 字符串池化 (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 状态在当帧可用 |