Claude Code 源码分析Claude Code 源码分析
首页
源码统计
系统架构
UML 图表
工具系统
CodeGraph
首页
源码统计
系统架构
UML 图表
工具系统
CodeGraph
  • 概览

    • Claude Code 源码分析
    • 源码统计
    • CodeGraph 图谱
  • 架构

    • 系统架构
    • UML 图表索引
    • 查询引擎
    • 核心流程
    • 消息系统
    • 状态管理
  • 功能模块

    • 工具系统
    • 斜杠命令
    • 服务层
    • MCP 协议
    • Skills 技能
    • 子代理系统
  • 分层深度

    • 入口层
    • UI / Ink 层
    • utils 基础设施
    • 桥接 / 远程
    • 上下文压缩
  • 原理与安全

    • 底层原理
    • 技术难点
    • 权限与安全
    • 内部机制
    • 遥测与分析
  • 深度专题

    • Hooks 系统
    • 插件系统
    • 记忆系统
    • API 通信层
    • Ink 终端 UI
    • 认证系统
    • 构建与发布
    • 术语表
  • 调用分析

    • 调用链分析
    • 核心文件索引
  • 模块详解

    • utils

      • 模块: utils
      • messages · 消息工厂与规范化
      • session-storage · JSONL 会话持久化
      • permissions · 工具权限决策
      • shell-hooks · 用户 Shell Hook 系统
    • components

      • 模块: components
      • REPL · 主屏编排
      • messages · 消息行渲染
      • PermissionRequest · 权限弹窗
      • PromptInput · 底部输入
    • services

      • 模块: services
      • api-claude · Anthropic API 流式与重试
      • mcp-client · MCP 连接与工具调用
      • compact · 上下文压缩与自动触发
      • analytics · GrowthBook、Datadog 与 1P 事件
    • tools

      • 模块: tools
      • tool-interface · Tool 契约与注册表
      • bash-tool · Shell 执行与权限
      • streaming-executor · 流式工具并发调度
      • agent-tool · 子 Agent 委派
    • commands

      • 模块: commands
      • command-registry · commands.ts 注册与分派
      • model-command · /model 模型选择
      • mcp-commands · /mcp 服务器管理
      • compact-memory-commands · /compact 与 /memory
    • ink

      • 模块: ink
      • Ink 渲染管线 · Screen 与终端输出
      • 终端事件 · resize、paste、stdin
      • Ink Hooks · 输入、搜索、终端状态
      • Ink 组件 · Box、Text、ScrollBox 原语
    • hooks

      • 模块: hooks
      • useCanUseTool · 权限 UI 接缝
      • 输入与快捷键 Hook
      • 合并态 Hook(MCP + 本地)
      • notifs 通知 Hook
    • bridge

      • 模块: bridge
      • repl-bridge · REPL 桥初始化与传输
      • bridge-messaging · 桥消息路由与入站处理
      • remote-bridge-core · env-less 核心与守护主循环
      • bridge-permissions-ui · 权限、API 与 TUI
    • cli

      • 模块: cli
      • Structured IO · NDJSON SDK 协议
      • CLI Transports · Session Ingress 传输层
      • CLI Handlers · 子命令懒加载实现
      • Update & Upload · 自更新与串行上传原语
    • screens

      • 模块: screens
      • REPL 屏 · Screen 类型与顶层路由
      • ResumeConversation · 会话恢复选择器
      • Doctor · 安装诊断全屏
    • entrypoints

      • 模块: entrypoints
      • cli-entrypoint · Bootstrap 与快路径
      • sdk-types · core / control / runtime 类型体系
      • mcp-entrypoint · MCP stdio 服务器
      • sandbox-types · 沙箱配置单一真相源
    • skills

      • 模块: skills
      • skills-loading · 磁盘加载与 bundled 注册表
      • bundled-skills · 内置 skill 与 initBundledSkills
      • mcp-skills · MCP prompt 转 skill
      • skill-tool-integration · SkillTool 与命令注册
    • types

      • 模块: types
      • message-types · Message 联合与 content blocks
      • tool-permission-types · Tool、Permission、Command 类型
      • api-sdk-types · API 与 Hooks 协议类型
      • misc-types · ids、plugin、generated 与其余类型
    • tasks

      • 模块: tasks
      • local-agent-task · 本地 Agent 与主会话后台化
      • remote-agent-task · 远程 CCR 与 In-Process Teammate
      • shell-workflow-tasks · Bash 后台、Workflow 与 stopTask
      • dream-monitor-tasks · Dream、Monitor MCP 与 pill 文案
    • keybindings

      • 模块: keybindings
      • keybinding-registry · 注册、Provider 与 useKeybinding
      • default-bindings · 默认键位表与平台差异
      • command-bindings · command:* 动态斜杠命令绑定
      • vim-bindings · Vim 模式与 keybindings 边界
    • memdir

      • 模块: memdir
      • memdir-core · 路径、加载与 MEMORY.md
      • memory-extraction · extractMemories 与 SessionMemory
      • memdir-commands · /memory、/remember 与命令集成
    • state

      • 模块: state
      • app-state-core · store、AppState 类型与 Provider
      • app-state-selectors · selectors 与 onChangeAppState
      • teammate-state · 队友视图与 swarm 状态
      • state-boundaries · bootstrap、sessionStorage、FileStateCache
    • query

      • 模块: query
      • query config 与 deps · 配置快照与依赖注入
      • query tokenBudget · +500k 自动续跑
      • query transitions · Continue / Terminal 状态机
      • query stopHooks · Stop 事件与 turn 结束编排
  • 模块详解(扩展)

    • messages · 消息工厂与规范化
    • session-storage · JSONL 会话持久化
    • permissions · 工具权限决策
    • shell-hooks · 用户 Shell Hook 系统
    • REPL · 主屏编排
    • messages · 消息行渲染
    • PermissionRequest · 权限弹窗
    • PromptInput · 底部输入
    • api-claude · Anthropic API 流式与重试
    • mcp-client · MCP 连接与工具调用
    • compact · 上下文压缩与自动触发
    • analytics · GrowthBook、Datadog 与 1P 事件
    • tool-interface · Tool 契约与注册表
    • bash-tool · Shell 执行与权限
    • streaming-executor · 流式工具并发调度
    • agent-tool · 子 Agent 委派
    • command-registry · commands.ts 注册与分派
    • model-command · /model 模型选择
    • mcp-commands · /mcp 服务器管理
    • compact-memory-commands · /compact 与 /memory
    • Ink 渲染管线 · Screen 与终端输出
    • 终端事件 · resize、paste、stdin
    • Ink Hooks · 输入、搜索、终端状态
    • Ink 组件 · Box、Text、ScrollBox 原语
    • useCanUseTool · 权限 UI 接缝
    • 输入与快捷键 Hook
    • 合并态 Hook(MCP + 本地)
    • notifs 通知 Hook
    • repl-bridge · REPL 桥初始化与传输
    • bridge-messaging · 桥消息路由与入站处理
    • remote-bridge-core · env-less 核心与守护主循环
    • bridge-permissions-ui · 权限、API 与 TUI
    • Structured IO · NDJSON SDK 协议
    • CLI Transports · Session Ingress 传输层
    • CLI Handlers · 子命令懒加载实现
    • Update & Upload · 自更新与串行上传原语
    • REPL 屏 · Screen 类型与顶层路由
    • ResumeConversation · 会话恢复选择器
    • Doctor · 安装诊断全屏
    • cli-entrypoint · Bootstrap 与快路径
    • sdk-types · core / control / runtime 类型体系
    • mcp-entrypoint · MCP stdio 服务器
    • sandbox-types · 沙箱配置单一真相源
    • skills-loading · 磁盘加载与 bundled 注册表
    • bundled-skills · 内置 skill 与 initBundledSkills
    • mcp-skills · MCP prompt 转 skill
    • skill-tool-integration · SkillTool 与命令注册
    • message-types · Message 联合与 content blocks
    • tool-permission-types · Tool、Permission、Command 类型
    • api-sdk-types · API 与 Hooks 协议类型
    • misc-types · ids、plugin、generated 与其余类型
    • local-agent-task · 本地 Agent 与主会话后台化
    • remote-agent-task · 远程 CCR 与 In-Process Teammate
    • shell-workflow-tasks · Bash 后台、Workflow 与 stopTask
    • dream-monitor-tasks · Dream、Monitor MCP 与 pill 文案
    • keybinding-registry · 注册、Provider 与 useKeybinding
    • default-bindings · 默认键位表与平台差异
    • command-bindings · command:* 动态斜杠命令绑定
    • vim-bindings · Vim 模式与 keybindings 边界
    • memdir-core · 路径、加载与 MEMORY.md
    • memory-extraction · extractMemories 与 SessionMemory
    • memdir-commands · /memory、/remember 与命令集成
    • app-state-core · store、AppState 类型与 Provider
    • app-state-selectors · selectors 与 onChangeAppState
    • teammate-state · 队友视图与 swarm 状态
    • state-boundaries · bootstrap、sessionStorage、FileStateCache
    • query config 与 deps · 配置快照与依赖注入
    • query tokenBudget · +500k 自动续跑
    • query transitions · Continue / Terminal 状态机
    • query stopHooks · Stop 事件与 turn 结束编排
  • 工具详解

    • tool-interface · Tool 契约与注册表
    • tool-permission-types · Tool、Permission、Command 类型
    • 工具: Bash
    • 工具: PowerShell
    • 工具: Agent
    • 工具: LSP
    • 工具: FileEdit
    • 工具: FileRead
    • 工具: Skill
    • 工具: WebFetch
    • 工具: MCP
    • 工具: SendMessage
    • 工具: FileWrite
    • 工具: Config
    • 工具: Grep
    • 工具: Brief
    • 工具: ExitPlanMode
    • 工具: ToolSearch
    • 工具: NotebookEdit
    • 工具: TaskOutput
    • 工具: WebSearch
    • 工具: ScheduleCron

本章总览

Claude Code 每一帧终端画面的本质是:React 提交 → Yoga 布局 → 把 DOM 树光栅化到 Screen 二维单元格数组 → 与上一帧 diff → 把 patch 序列写到 stdout。本章从 Ink 类(ink.tsx)的 onRender 入手,串联 render-node-to-output、Output 收集器、LogUpdate 与 writeDiffToTerminal,并说明 alt-screen 与 main-screen 在光标、擦屏、同步输出上的分歧。

学完本章你应该能

  • 解释 scheduleRender 节流与 reconciler resetAfterCommit 的触发关系
  • 说明 frontFrame/backFrame 双缓冲与 damage 矩形如何缩小 diff 成本
  • 理解 Output 的 write/blit/clip 操作如何落到 Screen
  • 掌握 LogUpdate 在 resize、flicker、DECSTBM 滚动 hint 下的行为
  • 能根据 layoutShifted / scrollDrainPending 判断为何会全帧重绘或 drain 帧

核心概念(先读懂这些)

渲染分两段:光栅化与 diff

光栅化阶段遍历 Yoga 布局后的 DOM,调用 renderNodeToOutput 向 Output 实例排队(write、blit、clear、clip 等),最后 Output.get() 重置并填充传入的 Screen。diff 阶段 LogUpdate 比较 prevFrame 与 frame 的 Screen(及 cursor、viewport),产出 stdout patch 数组,再经 optimizer 合并连续写操作。搜索高亮与选择叠加在 Screen 上完成,仍走同一套 damage 机制。

alt-screen 光标锚定

主屏滚动区光标 y 可落在 scrollback 之外,CSI H 无法复位;alt-screen 每帧把 prev.cursor 视为 (0,0),diff 前逻辑上锚定,写完后 park 到末行或 useDeclaredCursor 声明的 IME 位置。needsEraseBeforePaint 在 resize 后推迟 ERASE_SCREEN 到 BSU/ESU 原子块内,避免 handleResize 同步擦屏造成 80ms 空白。

池化与 5 分钟重置

StylePool 会话级不重置;CharPool 与 HyperlinkPool 每 5 分钟 migrate 重置,防止长会话 Map 无限增长。ClusteredChar 缓存按「唯一文本行」复用,热循环只做 setCellAt 与 styleId 读取。

建议学习步骤

  1. 阅读 Ink 构造:throttle scheduleRender、双 Frame 初始化
  2. 跟踪 onRender:renderNodeToOutput → applySearchHighlight → log.render
  3. 阅读 output.ts 的 ClusteredChar 与 write 操作语义
  4. 阅读 log-update.ts 的 diffEach 与 scroll hint
  5. 对照 render-node-to-output 的 layoutShifted 与 scrollHint

常见误区

注意

onRender 尾部禁止直接 scheduleRender(throttle 重入);scroll drain 用 setTimeout

注意

prevFrameContaminated 标记上一帧是否写过选择/高亮,影响 blit 安全

注意

非 TTY 路径已移除 string 输出,仅保留最后一帧 renderPreviousOutput_DEPRECATED

帧调度:从 React 提交到 onRender

Ink 在构造时创建 custom reconciler 的 FiberRoot,并把 DOM 根节点的 onRender 设为节流后的 scheduleRender(默认 FRAME_INTERVAL_MS,约 60fps 上限)。

触发渲染的典型路径:

  1. render(node) → updateContainerSync + flushSyncWork 同步刷 reconciler
  2. 宿主 commit 后 resetAfterCommit → rootNode.onRender()
  3. 用户输入、选择变化、scroll drain 等也会间接 markDirty 再提交

scheduleRender 使用 lodash throttle trailing 边缘;ScrollBox 在 scrollDrainPending 时用更短的 setTimeout(约 FRAME_INTERVAL_MS/4)追加 drain 帧,且 不得 在 onRender 内再调 scheduleRender,否则 lodash 会立即 leading 双渲染。

阅读 ink.tsx 时把 onRender 当作单帧主函数:其内完成布局计时、光栅化、高亮/选择 overlay、diff、写终端、交换 front/back buffer。

源码引用: src/ink/ink.tsx · 第 200–250 行(共 2006 行)

 200|     positions: MatchPosition[]
 201|     rowOffset: number
 202|     currentIdx: number
 203|   } | null = null
 204|   // React-land subscribers for selection state changes (useHasSelection).
 205|   // Fired alongside the terminal repaint whenever the selection mutates
 206|   // so UI (e.g. footer hints) can react to selection appearing/clearing.
 207|   private readonly selectionListeners = new Set<() => void>()
 208|   // DOM nodes currently under the pointer (mode-1003 motion). Held here
 209|   // so App.tsx's handleMouseEvent is stateless — dispatchHover diffs
 210|   // against this set and mutates it in place.
 211|   private readonly hoveredNodes = new Set<dom.DOMElement>()
 212|   // Set by <AlternateScreen> via setAltScreenActive(). Controls the
 213|   // renderer's cursor.y clamping (keeps cursor in-viewport to avoid
 214|   // LF-induced scroll when screen.height === terminalRows) and gates
 215|   // alt-screen-aware SIGCONT/resize/unmount handling.
 216|   private altScreenActive = false
 217|   // Set alongside altScreenActive so SIGCONT resume knows whether to
 218|   // re-enable mouse tracking (not all <AlternateScreen> uses want it).
 219|   private altScreenMouseTracking = false
 220|   // True when the previous frame's screen buffer cannot be trusted for
 221|   // blit — selection overlay mutated it, resetFramesForAltScreen()
 222|   // replaced it with blanks, or forceRedraw() reset it to 0×0. Forces
 223|   // one full-render frame; steady-state frames after clear it and regain
 224|   // the blit + narrow-damage fast path.
 225|   private prevFrameContaminated = false
 226|   // Set by handleResize: prepend ERASE_SCREEN to the next onRender's patches
 227|   // INSIDE the BSU/ESU block so clear+paint is atomic. Writing ERASE_SCREEN
 228|   // synchronously in handleResize would leave the screen blank for the ~80ms
 229|   // render() takes; deferring into the atomic block means old content stays
 230|   // visible until the new frame is fully ready.
 231|   private needsEraseBeforePaint = false
 232|   // Native cursor positioning: a component (via useDeclaredCursor) declares
 233|   // where the terminal cursor should be parked after each frame. Terminal
 234|   // emulators render IME preedit text at the physical cursor position, and
 235|   // screen readers / screen magnifiers track it — so parking at the text
 236|   // input's caret makes CJK input appear inline and lets a11y tools follow.
 237|   private cursorDeclaration: CursorDeclaration | null = null
 238|   // Main-screen: physical cursor position after the declared-cursor move,
 239|   // tracked separately from frame.cursor (which must stay at content-bottom
 240|   // for log-update's relative-move invariants). Alt-screen doesn't need
 241|   // this — every frame begins with CSI H. null = no move emitted last frame.
 242|   private displayCursor: { x: number; y: number } | null = null
 243| 
 244|   constructor(private readonly options: Options) {
 245|     autoBind(this)
 246| 
 247|     if (this.options.patchConsole) {
 248|       this.restoreConsole = this.patchConsole()
 249|       this.restoreStderr = this.patchStderr()
 250|     }

源码引用: src/ink/ink.tsx · 第 745–760 行(共 2006 行)

 745|     for (const patch of diff) {
 746|       if (patch.type === 'clearTerminal') {
 747|         flickers.push({
 748|           desiredHeight: frame.screen.height,
 749|           availableHeight: frame.viewport.height,
 750|           reason: patch.reason,
 751|         })
 752|         if (isDebugRepaintsEnabled() && patch.debug) {
 753|           const chain = dom.findOwnerChainAtRow(
 754|             this.rootNode,
 755|             patch.debug.triggerY,
 756|           )
 757|           logForDebugging(
 758|             `[REPAINT] full reset · ${patch.reason} · row ${patch.debug.triggerY}\n` +
 759|               `  prev: "${patch.debug.prevLine}"\n` +
 760|               `  next: "${patch.debug.nextLine}"\n` +

reconciler:React 宿主与 Yoga DOM

reconciler.ts 用 react-reconciler 实现自定义宿主:

  • createInstance → dom.createNode,挂载 Yoga 节点与样式
  • commitUpdate → diff props,setStyle / setAttribute,事件处理器写入 node._eventHandlers
  • commitMount → FocusManager autoFocus、markDirty
  • removeChild → cleanupYogaNode 释放 WASM,防止 use-after-free

开发模式可动态 import devtools.js 连接 React DevTools。事件属性映射在 events/event-handlers.ts(如 onClick → click 事件)。

Dispatcher(同文件导出)为键盘等离散事件提供 discreteUpdates,与 App.tsx 里批量 processKeysInBatch 配合,避免粘贴时多次 setState 导致「Maximum update depth exceeded」。

源码引用: src/ink/reconciler.ts · 第 108–180 行(共 513 行)

 108| type Props = Record<string, unknown>
 109| 
 110| type HostContext = {
 111|   isInsideText: boolean
 112| }
 113| 
 114| function setEventHandler(node: DOMElement, key: string, value: unknown): void {
 115|   if (!node._eventHandlers) {
 116|     node._eventHandlers = {}
 117|   }
 118|   node._eventHandlers[key] = value
 119| }
 120| 
 121| function applyProp(node: DOMElement, key: string, value: unknown): void {
 122|   if (key === 'children') return
 123| 
 124|   if (key === 'style') {
 125|     setStyle(node, value as Styles)
 126|     if (node.yogaNode) {
 127|       applyStyles(node.yogaNode, value as Styles)
 128|     }
 129|     return
 130|   }
 131| 
 132|   if (key === 'textStyles') {
 133|     node.textStyles = value as TextStyles
 134|     return
 135|   }
 136| 
 137|   if (EVENT_HANDLER_PROPS.has(key)) {
 138|     setEventHandler(node, key, value)
 139|     return
 140|   }
 141| 
 142|   setAttribute(node, key, value as DOMNodeAttribute)
 143| }
 144| 
 145| // --
 146| 
 147| // react-reconciler's Fiber shape — only the fields we walk. The 5th arg to
 148| // createInstance is the Fiber (`workInProgress` in react-reconciler.dev.js).
 149| // _debugOwner is the component that rendered this element (dev builds only);
 150| // return is the parent fiber (always present). We prefer _debugOwner since it
 151| // skips past Box/Text wrappers to the actual named component.
 152| type FiberLike = {
 153|   elementType?: { displayName?: string; name?: string } | string | null
 154|   _debugOwner?: FiberLike | null
 155|   return?: FiberLike | null
 156| }
 157| 
 158| export function getOwnerChain(fiber: unknown): string[] {
 159|   const chain: string[] = []
 160|   const seen = new Set<unknown>()
 161|   let cur = fiber as FiberLike | null | undefined
 162|   for (let i = 0; cur && i < 50; i++) {
 163|     if (seen.has(cur)) break
 164|     seen.add(cur)
 165|     const t = cur.elementType
 166|     const name =
 167|       typeof t === 'function'
 168|         ? (t as { displayName?: string; name?: string }).displayName ||
 169|           (t as { displayName?: string; name?: string }).name
 170|         : typeof t === 'string'
 171|           ? undefined // host element (ink-box etc) — skip
 172|           : t?.displayName || t?.name
 173|     if (name && name !== chain[chain.length - 1]) chain.push(name)
 174|     cur = cur._debugOwner ?? cur.return
 175|   }
 176|   return chain
 177| }
 178| 
 179| let debugRepaints: boolean | undefined
 180| export function isDebugRepaintsEnabled(): boolean {

源码引用: src/ink/reconciler.ts · 第 95–104 行(共 513 行)

  95| const cleanupYogaNode = (node: DOMElement | TextNode): void => {
  96|   const yogaNode = node.yogaNode
  97|   if (yogaNode) {
  98|     yogaNode.unsetMeasureFunc()
  99|     // Clear all references BEFORE freeing to prevent other code from
 100|     // accessing freed WASM memory during concurrent operations
 101|     clearYogaNodeReferences(node)
 102|     yogaNode.freeRecursive()
 103|   }
 104| }

render-node-to-output:树到 Output 队列

render-node-to-output.ts 是光栅化核心(1400+ 行),职责包括:

  • 遍历 DOMElement/TextNode,按 Yoga 计算的边框盒写入 Output
  • 文本:squashTextNodes → wrap(wrap-text.ts)→ colorize / bidi 重排
  • 边框:render-border.ts;图片/填充:blit 已有 Screen 区域
  • ScrollBox:按 scrollTop 视口裁剪子节点,记录 scrollHint(DECSTBM 区域 + delta)供 log-update 发 SU/SD
  • 绝对定位:维护 absoluteRectsPrev/Cur,滚动时 third-pass 修复残留

模块级标志位:

  • layoutShifted:任一节点位置/尺寸相对缓存变化 → ink.tsx 可能扩成全屏 damage
  • followScroll:粘底滚动时选择区随内容上移(consumeFollowScroll)
  • scrollDrainNode:本帧未 drain 完的 ScrollBox,下一帧 markDirty 继续

xterm.js 集成终端(VS Code/Cursor)有单独滚轮曲线检测,与 ScrollBox drain 策略一致。

源码引用: src/ink/render-node-to-output.ts · 第 27–98 行(共 1463 行)

  27| // Per-frame scratch: set when any node's yoga position/size differs from
  28| // its cached value, or a child was removed. Read by ink.tsx to decide
  29| // whether the full-damage sledgehammer (PR #20120) is needed this frame.
  30| // Applies on both alt-screen and main-screen. Steady-state frames
  31| // (spinner tick, clock tick, text append into a fixed-height box) don't
  32| // shift layout → narrow damage bounds → O(changed cells) diff instead of
  33| // O(rows×cols).
  34| let layoutShifted = false
  35| 
  36| export function resetLayoutShifted(): void {
  37|   layoutShifted = false
  38| }
  39| 
  40| export function didLayoutShift(): boolean {
  41|   return layoutShifted
  42| }
  43| 
  44| // DECSTBM scroll optimization hint. When a ScrollBox's scrollTop changes
  45| // between frames (and nothing else moved), log-update.ts can emit a
  46| // hardware scroll (DECSTBM + SU/SD) instead of rewriting the whole
  47| // viewport. top/bottom are 0-indexed inclusive screen rows; delta > 0 =
  48| // content moved up (scrollTop increased, CSI n S).
  49| export type ScrollHint = { top: number; bottom: number; delta: number }
  50| let scrollHint: ScrollHint | null = null
  51| 
  52| // Rects of position:absolute nodes from the PREVIOUS frame, used by
  53| // ScrollBox's blit+shift third-pass repair (see usage site). Recorded at
  54| // three paths — full-render nodeCache.set, node-level blit early-return,
  55| // blitEscapingAbsoluteDescendants — so clean-overlay consecutive scrolls
  56| // still have the rect.
  57| let absoluteRectsPrev: Rectangle[] = []
  58| let absoluteRectsCur: Rectangle[] = []
  59| 
  60| export function resetScrollHint(): void {
  61|   scrollHint = null
  62|   absoluteRectsPrev = absoluteRectsCur
  63|   absoluteRectsCur = []
  64| }
  65| 
  66| export function getScrollHint(): ScrollHint | null {
  67|   return scrollHint
  68| }
  69| 
  70| // The ScrollBox DOM node (if any) with pendingScrollDelta left after this
  71| // frame's drain. renderer.ts calls markDirty(it) post-render so the NEXT
  72| // frame's root blit check fails and we descend to continue draining.
  73| // Without this, after the scrollbox's dirty flag is cleared (line ~721),
  74| // the next frame blits root and never reaches the scrollbox — drain stalls.
  75| let scrollDrainNode: DOMElement | null = null
  76| 
  77| export function resetScrollDrainNode(): void {
  78|   scrollDrainNode = null
  79| }
  80| 
  81| export function getScrollDrainNode(): DOMElement | null {
  82|   return scrollDrainNode
  83| }
  84| 
  85| // At-bottom follow scroll event this frame. When streaming content
  86| // triggers scrollTop = maxScroll, the ScrollBox records the delta +
  87| // viewport bounds here. ink.tsx consumes it post-render to translate any active
  88| // text selection by -delta so the highlight stays anchored to the TEXT
  89| // (native terminal behavior — the selection walks up the screen as content
  90| // scrolls, eventually clipping at the top). The frontFrame screen buffer
  91| // still holds the old content at that point — captureScrolledRows reads
  92| // from it before the front/back swap to preserve the text for copy.
  93| export type FollowScroll = {
  94|   delta: number
  95|   viewportTop: number
  96|   viewportBottom: number
  97| }
  98| let followScroll: FollowScroll | null = null

源码引用: src/ink/render-node-to-output.ts · 第 44–83 行(共 1463 行)

  44| // DECSTBM scroll optimization hint. When a ScrollBox's scrollTop changes
  45| // between frames (and nothing else moved), log-update.ts can emit a
  46| // hardware scroll (DECSTBM + SU/SD) instead of rewriting the whole
  47| // viewport. top/bottom are 0-indexed inclusive screen rows; delta > 0 =
  48| // content moved up (scrollTop increased, CSI n S).
  49| export type ScrollHint = { top: number; bottom: number; delta: number }
  50| let scrollHint: ScrollHint | null = null
  51| 
  52| // Rects of position:absolute nodes from the PREVIOUS frame, used by
  53| // ScrollBox's blit+shift third-pass repair (see usage site). Recorded at
  54| // three paths — full-render nodeCache.set, node-level blit early-return,
  55| // blitEscapingAbsoluteDescendants — so clean-overlay consecutive scrolls
  56| // still have the rect.
  57| let absoluteRectsPrev: Rectangle[] = []
  58| let absoluteRectsCur: Rectangle[] = []
  59| 
  60| export function resetScrollHint(): void {
  61|   scrollHint = null
  62|   absoluteRectsPrev = absoluteRectsCur
  63|   absoluteRectsCur = []
  64| }
  65| 
  66| export function getScrollHint(): ScrollHint | null {
  67|   return scrollHint
  68| }
  69| 
  70| // The ScrollBox DOM node (if any) with pendingScrollDelta left after this
  71| // frame's drain. renderer.ts calls markDirty(it) post-render so the NEXT
  72| // frame's root blit check fails and we descend to continue draining.
  73| // Without this, after the scrollbox's dirty flag is cleared (line ~721),
  74| // the next frame blits root and never reaches the scrollbox — drain stalls.
  75| let scrollDrainNode: DOMElement | null = null
  76| 
  77| export function resetScrollDrainNode(): void {
  78|   scrollDrainNode = null
  79| }
  80| 
  81| export function getScrollDrainNode(): DOMElement | null {
  82|   return scrollDrainNode
  83| }

Output 与 Screen:单元格模型

output.ts 的 Output 类收集本帧操作,get() 时 reset Screen 并依次 apply:

操作作用
write在 (x,y) 写 ANSI 文本,支持 per-line softWrap 位图
clip / unclip矩形裁剪,子树写入被 intersect
blit从源矩形复制单元格(ScrollBox 优化)
clear区域置空
shift行块平移(配合滚动修复)
noSelect标记 gutter 不可选(行号、树线)

ClusteredChar 把 grapheme 宽度、styleId、hyperlink 预计算缓存到行级,避免每帧 stringWidth。

screen.ts 定义 Cell(char、width、styleId、hyperlink)、CellWidth(SpacerHead/Tail 表示宽字符占两列)、StylePool intern ANSI 样式数组。diff 默认 diffEach 逐格比较;宽度变化时走 fallback 全行重写。

源码引用: src/ink/output.ts · 第 28–84 行(共 798 行)

  28| /**
  29|  * A grapheme cluster with precomputed terminal width, styleId, and hyperlink.
  30|  * Built once per unique line (cached via charCache), so the per-char hot loop
  31|  * is just property reads + setCellAt — no stringWidth, no style interning,
  32|  * no hyperlink extraction per frame.
  33|  *
  34|  * styleId is safe to cache: StylePool is session-lived (never reset).
  35|  * hyperlink is stored as a string (not interned ID) since hyperlinkPool
  36|  * resets every 5 min; setCellAt interns it per-frame (cheap Map.get).
  37|  */
  38| type ClusteredChar = {
  39|   value: string
  40|   width: number
  41|   styleId: number
  42|   hyperlink: string | undefined
  43| }
  44| 
  45| /**
  46|  * Collects write/blit/clear/clip operations from the render tree, then
  47|  * applies them to a Screen buffer in `get()`. The Screen is what gets
  48|  * diffed against the previous frame to produce terminal updates.
  49|  */
  50| 
  51| type Options = {
  52|   width: number
  53|   height: number
  54|   stylePool: StylePool
  55|   /**
  56|    * Screen to render into. Will be reset before use.
  57|    * For double-buffering, pass a reusable screen. Otherwise create a new one.
  58|    */
  59|   screen: Screen
  60| }
  61| 
  62| export type Operation =
  63|   | WriteOperation
  64|   | ClipOperation
  65|   | UnclipOperation
  66|   | BlitOperation
  67|   | ClearOperation
  68|   | NoSelectOperation
  69|   | ShiftOperation
  70| 
  71| type WriteOperation = {
  72|   type: 'write'
  73|   x: number
  74|   y: number
  75|   text: string
  76|   /**
  77|    * Per-line soft-wrap flags, parallel to text.split('\n'). softWrap[i]=true
  78|    * means line i is a continuation of line i-1 (the `\n` before it was
  79|    * inserted by word-wrap, not in the source). Index 0 is always false.
  80|    * Undefined means the producer didn't track wrapping (e.g. fills,
  81|    * raw-ansi) — the screen's per-row bitmap is left untouched.
  82|    */
  83|   softWrap?: boolean[]
  84| }

源码引用: src/ink/output.ts · 第 46–59 行(共 798 行)

  46|  * Collects write/blit/clear/clip operations from the render tree, then
  47|  * applies them to a Screen buffer in `get()`. The Screen is what gets
  48|  * diffed against the previous frame to produce terminal updates.
  49|  */
  50| 
  51| type Options = {
  52|   width: number
  53|   height: number
  54|   stylePool: StylePool
  55|   /**
  56|    * Screen to render into. Will be reset before use.
  57|    * For double-buffering, pass a reusable screen. Otherwise create a new one.
  58|    */
  59|   screen: Screen

LogUpdate:Screen diff 与 flicker

log-update.ts 的 render(prevFrame, frame, altScreenActive, syncOutput) 返回 Diff:{ type: 'stdout', content }、clearTerminal、carriageReturn 等。

关键策略:

  • 相对光标移动:假设 prev.cursor 与物理光标一致;alt-screen 每帧先 CSI H
  • DECSTBM 滚动 hint:当 ScrollBox 仅 scrollTop 变化且无 layout shift,用硬件滚动代替全视口重写
  • resize:frame.ts 检测 viewport 尺寸变化 → flicker reason resize,可能 fullResetSequence(故意标注 CAUSES_FLICKER 便于审计)
  • hyperlink / style diff:行末关闭 OSC 8,样式用 diffAnsiCodes 最小化 SGR 序列

optimizer.ts 合并相邻 stdout patch,减少 write 调用。写终端前 alt-screen 可 unshift ERASE_THEN_HOME 或 CURSOR_HOME,push altScreenParkPatch 把光标停在末行,减轻 iTerm2 光标导览抖动。

源码引用: src/ink/log-update.ts · 第 43–100 行(共 774 行)

  43| export class LogUpdate {
  44|   private state: State
  45| 
  46|   constructor(private readonly options: Options) {
  47|     this.state = {
  48|       previousOutput: '',
  49|     }
  50|   }
  51| 
  52|   renderPreviousOutput_DEPRECATED(prevFrame: Frame): Diff {
  53|     if (!this.options.isTTY) {
  54|       // Non-TTY output is no longer supported (string output was removed)
  55|       return [NEWLINE]
  56|     }
  57|     return this.getRenderOpsForDone(prevFrame)
  58|   }
  59| 
  60|   // Called when process resumes from suspension (SIGCONT) to prevent clobbering terminal content
  61|   reset(): void {
  62|     this.state.previousOutput = ''
  63|   }
  64| 
  65|   private renderFullFrame(frame: Frame): Diff {
  66|     const { screen } = frame
  67|     const lines: string[] = []
  68|     let currentStyles: AnsiCode[] = []
  69|     let currentHyperlink: Hyperlink = undefined
  70|     for (let y = 0; y < screen.height; y++) {
  71|       let line = ''
  72|       for (let x = 0; x < screen.width; x++) {
  73|         const cell = cellAt(screen, x, y)
  74|         if (cell && cell.width !== CellWidth.SpacerTail) {
  75|           // Handle hyperlink transitions
  76|           if (cell.hyperlink !== currentHyperlink) {
  77|             if (currentHyperlink !== undefined) {
  78|               line += LINK_END
  79|             }
  80|             if (cell.hyperlink !== undefined) {
  81|               line += oscLink(cell.hyperlink)
  82|             }
  83|             currentHyperlink = cell.hyperlink
  84|           }
  85|           const cellStyles = this.options.stylePool.get(cell.styleId)
  86|           const styleDiff = diffAnsiCodes(currentStyles, cellStyles)
  87|           if (styleDiff.length > 0) {
  88|             line += ansiCodesToString(styleDiff)
  89|             currentStyles = cellStyles
  90|           }
  91|           line += cell.char
  92|         }
  93|       }
  94|       // Close any open hyperlink before resetting styles
  95|       if (currentHyperlink !== undefined) {
  96|         line += LINK_END
  97|         currentHyperlink = undefined
  98|       }
  99|       // Reset styles at end of line so trimEnd doesn't leave dangling codes
 100|       const resetCodes = diffAnsiCodes(currentStyles, [])

源码引用: src/ink/ink.tsx · 第 578–651 行(共 2006 行)

 578|       // captureScrolledRows and shift* are a pair: capture grabs rows about
 579|       // to scroll off, shift moves the selection endpoint so the same rows
 580|       // won't intersect again next frame. Capturing without shifting leaves
 581|       // the endpoint in place, so the SAME viewport rows re-intersect every
 582|       // frame and scrolledOffAbove grows without bound — getSelectedText
 583|       // then returns ever-growing text on each re-copy. Keep capture inside
 584|       // each shift branch so the pairing can't be broken by a new guard.
 585|       if (this.selection.isDragging) {
 586|         if (hasSelection(this.selection)) {
 587|           captureScrolledRows(
 588|             this.selection,
 589|             this.frontFrame.screen,
 590|             viewportTop,
 591|             viewportTop + delta - 1,
 592|             'above',
 593|           )
 594|         }
 595|         shiftAnchor(this.selection, -delta, viewportTop, viewportBottom)
 596|       } else if (
 597|         // Flag-3 guard: the anchor check above only proves ONE endpoint is
 598|         // on scrollbox content. A drag from row 3 (scrollbox) into the
 599|         // footer at row 6, then release, leaves focus outside the viewport
 600|         // — shiftSelectionForFollow would clamp it to viewportBottom,
 601|         // teleporting the highlight from static footer into the scrollbox.
 602|         // Symmetric check: require BOTH ends inside to translate. A
 603|         // straddling selection falls through to NEITHER shift NOR capture:
 604|         // the footer endpoint pins the selection, text scrolls away under
 605|         // the highlight, and getSelectedText reads the CURRENT screen
 606|         // contents — no accumulation. Dragging branch doesn't need this:
 607|         // shiftAnchor ignores focus, and the anchor DOES shift (so capture
 608|         // is correct there even when focus is in the footer).
 609|         !this.selection.focus ||
 610|         (this.selection.focus.row >= viewportTop &&
 611|           this.selection.focus.row <= viewportBottom)
 612|       ) {
 613|         if (hasSelection(this.selection)) {
 614|           captureScrolledRows(
 615|             this.selection,
 616|             this.frontFrame.screen,
 617|             viewportTop,
 618|             viewportTop + delta - 1,
 619|             'above',
 620|           )
 621|         }
 622|         const cleared = shiftSelectionForFollow(
 623|           this.selection,
 624|           -delta,
 625|           viewportTop,
 626|           viewportBottom,
 627|         )
 628|         // Auto-clear (both ends overshot minRow) must notify React-land
 629|         // so useHasSelection re-renders and the footer copy/escape hint
 630|         // disappears. notifySelectionChange() would recurse into onRender;
 631|         // fire the listeners directly — they schedule a React update for
 632|         // LATER, they don't re-enter this frame.
 633|         if (cleared) for (const cb of this.selectionListeners) cb()
 634|       }
 635|     }
 636| 
 637|     // Selection overlay: invert cell styles in the screen buffer itself,
 638|     // so the diff picks up selection as ordinary cell changes and
 639|     // LogUpdate remains a pure diff engine.
 640|     //
 641|     // Full-screen damage (PR #20120) is a correctness backstop for the
 642|     // sibling-resize bleed: when flexbox siblings resize between frames
 643|     // (spinner appears → bottom grows → scrollbox shrinks), the
 644|     // cached-clear + clip-and-cull + setCellAt damage union can miss
 645|     // transition cells at the boundary. But that only happens when layout
 646|     // actually SHIFTS — didLayoutShift() tracks exactly this (any node's
 647|     // cached yoga position/size differs from current, or a child was
 648|     // removed). Steady-state frames (spinner rotate, clock tick, text
 649|     // stream into fixed-height box) don't shift layout, so normal damage
 650|     // bounds are correct and diffEach only compares the damaged region.
 651|     //

onRender 后半:写终端与池重置

diff 生成后 writeDiffToTerminal(terminal, optimized, needBsuEsu):

  • SYNC_OUTPUT_SUPPORTED(DEC 2026):BSU/ESU 包裹,减少中间态撕裂;tmux 通常 false
  • displayCursor / cursorDeclaration:主屏在 diff 前可能 preamble 把物理光标移回 prev;声明节点用 nodeCache 矩形 + relativeX/Y 发 CUP
  • 性能埋点:onFrame 回调可携带 yogaMs、diffMs、writeMs、flickers 数组

每 5 分钟 resetPools() 迁移 char/hyperlink 池;prevFrameContaminated 记录上一帧是否写过选择/搜索反色,影响下一帧 blit 路径正确性。

Unmount 时同步 writeSync 退出 alt-screen、关鼠标跟踪、关 Kitty keyboard、DBP、显示光标,并 drainStdin 防止鼠标事件泄漏到 shell。

源码引用: src/ink/ink.tsx · 第 653–737 行(共 2006 行)

 653|     // which doesn't track damage, and prev-frame overlay cells need to be
 654|     // compared when selection moves/clears. prevFrameContaminated covers
 655|     // the frame-after-selection-clears case.
 656|     let selActive = false
 657|     let hlActive = false
 658|     if (this.altScreenActive) {
 659|       selActive = hasSelection(this.selection)
 660|       if (selActive) {
 661|         applySelectionOverlay(frame.screen, this.selection, this.stylePool)
 662|       }
 663|       // Scan-highlight: inverse on ALL visible matches (less/vim style).
 664|       // Position-highlight (below) overlays CURRENT (yellow) on top.
 665|       hlActive = applySearchHighlight(
 666|         frame.screen,
 667|         this.searchHighlightQuery,
 668|         this.stylePool,
 669|       )
 670|       // Position-based CURRENT: write yellow at positions[currentIdx] +
 671|       // rowOffset. No scanning — positions came from a prior scan when
 672|       // the message first mounted. Message-relative + rowOffset = screen.
 673|       if (this.searchPositions) {
 674|         const sp = this.searchPositions
 675|         const posApplied = applyPositionedHighlight(
 676|           frame.screen,
 677|           this.stylePool,
 678|           sp.positions,
 679|           sp.rowOffset,
 680|           sp.currentIdx,
 681|         )
 682|         hlActive = hlActive || posApplied
 683|       }
 684|     }
 685| 
 686|     // Full-damage backstop: applies on BOTH alt-screen and main-screen.
 687|     // Layout shifts (spinner appears, status line resizes) can leave stale
 688|     // cells at sibling boundaries that per-node damage tracking misses.
 689|     // Selection/highlight overlays write via setCellStyleId which doesn't
 690|     // track damage. prevFrameContaminated covers the cleanup frame.
 691|     if (
 692|       didLayoutShift() ||
 693|       selActive ||
 694|       hlActive ||
 695|       this.prevFrameContaminated
 696|     ) {
 697|       frame.screen.damage = {
 698|         x: 0,
 699|         y: 0,
 700|         width: frame.screen.width,
 701|         height: frame.screen.height,
 702|       }
 703|     }
 704| 
 705|     // Alt-screen: anchor the physical cursor to (0,0) before every diff.
 706|     // All cursor moves in log-update are RELATIVE to prev.cursor; if tmux
 707|     // (or any emulator) perturbs the physical cursor out-of-band (status
 708|     // bar refresh, pane redraw, Cmd+K wipe), the relative moves drift and
 709|     // content creeps up 1 row/frame. CSI H resets the physical cursor;
 710|     // passing prev.cursor=(0,0) makes the diff compute from the same spot.
 711|     // Self-healing against any external cursor manipulation. Main-screen
 712|     // can't do this — cursor.y tracks scrollback rows CSI H can't reach.
 713|     // The CSI H write is deferred until after the diff is computed so we
 714|     // can skip it for empty diffs (no writes → physical cursor unused).
 715|     let prevFrame = this.frontFrame
 716|     if (this.altScreenActive) {
 717|       prevFrame = { ...this.frontFrame, cursor: ALT_SCREEN_ANCHOR_CURSOR }
 718|     }
 719| 
 720|     const tDiff = performance.now()
 721|     const diff = this.log.render(
 722|       prevFrame,
 723|       frame,
 724|       this.altScreenActive,
 725|       // DECSTBM needs BSU/ESU atomicity — without it the outer terminal
 726|       // renders the scrolled-but-not-yet-repainted intermediate state.
 727|       // tmux is the main case (re-emits DECSTBM with its own timing and
 728|       // doesn't implement DEC 2026, so SYNC_OUTPUT_SUPPORTED is false).
 729|       SYNC_OUTPUT_SUPPORTED,
 730|     )
 731|     const diffMs = performance.now() - tDiff
 732|     // Swap buffers
 733|     this.backFrame = this.frontFrame
 734|     this.frontFrame = frame
 735| 
 736|     // Periodically reset char/hyperlink pools to prevent unbounded growth
 737|     // during long sessions. 5 minutes is infrequent enough that the O(cells)

源码引用: src/ink/ink.tsx · 第 1475–1505 行(共 2006 行)

1475|     // Tab cycling is the default action — only fires if no handler
1476|     // called preventDefault(). Mirrors browser behavior.
1477|     if (
1478|       !event.defaultPrevented &&
1479|       parsedKey.name === 'tab' &&
1480|       !parsedKey.ctrl &&
1481|       !parsedKey.meta
1482|     ) {
1483|       if (parsedKey.shift) {
1484|         this.focusManager.focusPrevious(this.rootNode)
1485|       } else {
1486|         this.focusManager.focusNext(this.rootNode)
1487|       }
1488|     }
1489|   }
1490|   /**
1491|    * Look up the URL at (col, row) in the current front frame. Checks for
1492|    * an OSC 8 hyperlink first, then falls back to scanning the row for a
1493|    * plain-text URL (mouse tracking intercepts the terminal's native
1494|    * Cmd+Click URL detection, so we replicate it). This is a pure lookup
1495|    * with no side effects — call it synchronously at click time so the
1496|    * result reflects the screen the user actually clicked on, then defer
1497|    * the browser-open action via a timer.
1498|    */
1499|   getHyperlinkAt(col: number, row: number): string | undefined {
1500|     if (!this.altScreenActive) return undefined
1501|     const screen = this.frontFrame.screen
1502|     const cell = cellAt(screen, col, row)
1503|     let url = cell?.hyperlink
1504|     // SpacerTail cells (right half of wide/CJK/emoji chars) store the
1505|     // hyperlink on the head cell at col-1.

搜索高亮在渲染链中的位置

屏幕空间搜索由 searchHighlight.ts 的 applySearchHighlight 在 Screen 已绘制后 对匹配格做 SGR 7 反色。大小写不敏感,按行构建 codeUnit→cell 映射以支持宽字符与 noSelect gutter 跳过。

「当前匹配」黄色叠加由 render-to-screen.ts 的 applyPositionedHighlight 处理,与全匹配反色分层。Hook useSearchHighlight 把 query/positions 设到 Ink 实例(见 ink-hooks 章)。

这保证高亮的是 用户看到的像素,而非 message 源文本;被截断或省略的字符串不会虚假高亮。

源码引用: src/ink/searchHighlight.ts · 第 9–80 行(共 94 行)

   9| /**
  10|  * Highlight all visible occurrences of `query` in the screen buffer by
  11|  * inverting cell styles (SGR 7). Post-render, same damage-tracking machinery
  12|  * as applySelectionOverlay — the diff picks up highlighted cells as ordinary
  13|  * changes, LogUpdate stays a pure diff engine.
  14|  *
  15|  * Case-insensitive. Handles wide characters (CJK, emoji) by building a
  16|  * col-of-char map per row — the Nth character isn't at col N when wide chars
  17|  * are present (each occupies 2 cells: head + SpacerTail).
  18|  *
  19|  * This ONLY inverts — there is no "current match" logic here. The yellow
  20|  * current-match overlay is handled separately by applyPositionedHighlight
  21|  * (render-to-screen.ts), which writes on top using positions scanned from
  22|  * the target message's DOM subtree.
  23|  *
  24|  * Returns true if any match was highlighted (damage gate — caller forces
  25|  * full-frame damage when true).
  26|  */
  27| export function applySearchHighlight(
  28|   screen: Screen,
  29|   query: string,
  30|   stylePool: StylePool,
  31| ): boolean {
  32|   if (!query) return false
  33|   const lq = query.toLowerCase()
  34|   const qlen = lq.length
  35|   const w = screen.width
  36|   const noSelect = screen.noSelect
  37|   const height = screen.height
  38| 
  39|   let applied = false
  40|   for (let row = 0; row < height; row++) {
  41|     const rowOff = row * w
  42|     // Build row text (already lowercased) + code-unit→cell-index map.
  43|     // Three skip conditions, all aligned with setCellStyleId /
  44|     // extractRowText (selection.ts):
  45|     //   - SpacerTail: 2nd cell of a wide char, no char of its own
  46|     //   - SpacerHead: end-of-line padding when a wide char wraps
  47|     //   - noSelect: gutters (⎿, line numbers) — same exclusion as
  48|     //     applySelectionOverlay. "Highlight what you see" still holds for
  49|     //     content; gutters aren't search targets.
  50|     // Lowercasing per-char (not on the joined string at the end) means
  51|     // codeUnitToCell maps positions in the LOWERCASED text — U+0130
  52|     // (Turkish İ) lowercases to 2 code units, so lowering the joined
  53|     // string would desync indexOf positions from the map.
  54|     let text = ''
  55|     const colOf: number[] = []
  56|     const codeUnitToCell: number[] = []
  57|     for (let col = 0; col < w; col++) {
  58|       const idx = rowOff + col
  59|       const cell = cellAtIndex(screen, idx)
  60|       if (
  61|         cell.width === CellWidth.SpacerTail ||
  62|         cell.width === CellWidth.SpacerHead ||
  63|         noSelect[idx] === 1
  64|       ) {
  65|         continue
  66|       }
  67|       const lc = cell.char.toLowerCase()
  68|       const cellIdx = colOf.length
  69|       for (let i = 0; i < lc.length; i++) {
  70|         codeUnitToCell.push(cellIdx)
  71|       }
  72|       text += lc
  73|       colOf.push(col)
  74|     }
  75| 
  76|     let pos = text.indexOf(lq)
  77|     while (pos >= 0) {
  78|       applied = true
  79|       const startCi = codeUnitToCell[pos]!
  80|       const endCi = codeUnitToCell[pos + qlen - 1]!

终端能力门控(渲染相关)

terminal.ts 提供渲染写路径依赖的能力开关:

  • isSynchronizedOutputSupported:是否发 BSU/ESU
  • isProgressReportingAvailable:OSC 9;4(排除 WT_SESSION)
  • isXtermJs / setXtversionName:SSH 下识别 VS Code 集成终端
  • writeDiffToTerminal:把 optimized diff 写到 Writable,处理 multiplexer wrap

渲染代码不应硬编码终端品牌;新能力先在此文件加探测,再在 ink.tsx / App.tsx 消费。

源码引用: src/ink/terminal.ts · 第 66–118 行(共 249 行)

  66| /**
  67|  * Checks if the terminal supports DEC mode 2026 (synchronized output).
  68|  * When supported, BSU/ESU sequences prevent visible flicker during redraws.
  69|  */
  70| export function isSynchronizedOutputSupported(): boolean {
  71|   // tmux parses and proxies every byte but doesn't implement DEC 2026.
  72|   // BSU/ESU pass through to the outer terminal but tmux has already
  73|   // broken atomicity by chunking. Skip to save 16 bytes/frame + parser work.
  74|   if (process.env.TMUX) return false
  75| 
  76|   const termProgram = process.env.TERM_PROGRAM
  77|   const term = process.env.TERM
  78| 
  79|   // Modern terminals with known DEC 2026 support
  80|   if (
  81|     termProgram === 'iTerm.app' ||
  82|     termProgram === 'WezTerm' ||
  83|     termProgram === 'WarpTerminal' ||
  84|     termProgram === 'ghostty' ||
  85|     termProgram === 'contour' ||
  86|     termProgram === 'vscode' ||
  87|     termProgram === 'alacritty'
  88|   ) {
  89|     return true
  90|   }
  91| 
  92|   // kitty sets TERM=xterm-kitty or KITTY_WINDOW_ID
  93|   if (term?.includes('kitty') || process.env.KITTY_WINDOW_ID) return true
  94| 
  95|   // Ghostty may set TERM=xterm-ghostty without TERM_PROGRAM
  96|   if (term === 'xterm-ghostty') return true
  97| 
  98|   // foot sets TERM=foot or TERM=foot-extra
  99|   if (term?.startsWith('foot')) return true
 100| 
 101|   // Alacritty may set TERM containing 'alacritty'
 102|   if (term?.includes('alacritty')) return true
 103| 
 104|   // Zed uses the alacritty_terminal crate which supports DEC 2026
 105|   if (process.env.ZED_TERM) return true
 106| 
 107|   // Windows Terminal
 108|   if (process.env.WT_SESSION) return true
 109| 
 110|   // VTE-based terminals (GNOME Terminal, Tilix, etc.) since VTE 0.68
 111|   const vteVersion = process.env.VTE_VERSION
 112|   if (vteVersion) {
 113|     const version = parseInt(vteVersion, 10)
 114|     if (version >= 6800) return true
 115|   }
 116| 
 117|   return false
 118| }

源码引用: src/ink/terminal.ts · 第 130–146 行(共 249 行)

 130| let xtversionName: string | undefined
 131| 
 132| /** Record the XTVERSION response. Called once from App.tsx when the reply
 133|  *  arrives on stdin. No-op if already set (defend against re-probe). */
 134| export function setXtversionName(name: string): void {
 135|   if (xtversionName === undefined) xtversionName = name
 136| }
 137| 
 138| /** True if running in an xterm.js-based terminal (VS Code, Cursor, Windsurf
 139|  *  integrated terminals). Combines TERM_PROGRAM env check (fast, sync, but
 140|  *  not forwarded over SSH) with the XTVERSION probe result (async, survives
 141|  *  SSH — query/reply goes through the pty). Early calls may miss the probe
 142|  *  reply — call lazily (e.g. in an event handler) if SSH detection matters. */
 143| export function isXtermJs(): boolean {
 144|   if (process.env.TERM_PROGRAM === 'vscode') return true
 145|   return xtversionName?.startsWith('xterm.js') ?? false
 146| }

调试与性能实践

启用 repaint 调试时,clearTerminal patch 带 debug.triggerY 与 prev/next 行文本,日志打印 DOM owner 链。

减少全帧 damage:

  • 固定高度容器内更新文本(消息 append、spinner tick)
  • 避免无必要的 flex 兄弟尺寸连锁变化(注释称 sibling-resize bleed)
  • ScrollBox 用 stickyScroll + scrollToBottom 而非每帧 scrollTo 竞态

测量: getLastYogaMs / getLastCommitMs Profiler 计数;长会话关注 pool reset 瞬间的 migrate 成本。

与上层 REPL 联调时,若「闪屏」仅在 resize 出现,查 needsEraseBeforePaint 与 flicker reason;若滚动掉帧,查 scrollDrainPending 与 DECSTBM hint 是否生效。

源码目录

建议顺序:ink.tsx → render-node-to-output.ts → output.ts → screen.ts → log-update.ts → reconciler.ts。

本章小结与延伸

终端 UI 性能在 ink 层决定:减少 layout shift、利用窄 damage、ScrollBox 硬件滚动。业务组件应固定高度盒子,避免每帧改变 Yoga 树深度。 继续学习:

  • 终端事件
  • Ink 组件
Prev
模块: ink
Next
终端事件 · resize、paste、stdin