本章总览
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 读取。
建议学习步骤
- 阅读 Ink 构造:throttle scheduleRender、双 Frame 初始化
- 跟踪 onRender:renderNodeToOutput → applySearchHighlight → log.render
- 阅读 output.ts 的 ClusteredChar 与 write 操作语义
- 阅读 log-update.ts 的 diffEach 与 scroll hint
- 对照 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 上限)。
触发渲染的典型路径:
render(node)→updateContainerSync+flushSyncWork同步刷 reconciler- 宿主
commit后resetAfterCommit→rootNode.onRender() - 用户输入、选择变化、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 可能扩成全屏 damagefollowScroll:粘底滚动时选择区随内容上移(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 reasonresize,可能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 树深度。 继续学习: