-
Notifications
You must be signed in to change notification settings - Fork 9
Add Claude Code terminal rendering deep-dive (+342 lines) #27
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,348 @@ | ||||||
| # 11. 终端渲染与防闪烁 | ||||||
|
|
||||||
| > 本文基于 Claude Code v2.1.89 源码分析(`ink/` 目录 ~6,800 行 + `utils/bufferedWriter.ts` 100 行),覆盖差分渲染引擎、同步输出、双缓冲、硬件滚动、缓存池化、渲染节流等 13 项防闪烁机制。 | ||||||
| > | ||||||
| > **数据来源**:文中所有源码路径和行号均引用自 Claude Code 应用源码(非本仓库文件),通过反编译 SEA 二进制获得。源码行数基于 TypeScript 文件的 `wc -l` 统计。 | ||||||
|
|
||||||
| ## 问题背景 | ||||||
|
|
||||||
| 终端 UI 不同于浏览器 DOM——没有硬件合成层、没有 `requestAnimationFrame`、没有 GPU 加速。每次输出都是往 stdout 写入 ANSI 转义序列的字节流,终端模拟器实时逐字解析并渲染。如果一次渲染涉及"清屏→重绘"两步,终端会先显示空白画面再显示新内容,产生可见闪烁。 | ||||||
|
|
||||||
| Claude Code 使用 React + Ink 构建 TUI(Terminal UI),需要在以下场景保持视觉稳定: | ||||||
|
|
||||||
| - **文本流式输出**:assistant 回复按 chunk 到达,每秒多次更新 | ||||||
| - **Spinner / 进度指示**:高频旋转动画 | ||||||
| - **滚动**:对话历史上下滚动 | ||||||
| - **布局变化**:工具调用展开/折叠、权限面板弹出 | ||||||
| - **窗口大小调整**:终端 resize 后全量重排 | ||||||
|
|
||||||
| ## 架构总览 | ||||||
|
|
||||||
| ``` | ||||||
| ┌────────────────────────────────────────────────────────────────┐ | ||||||
| │ React 组件树(Ink) │ | ||||||
| │ JSX → Yoga 布局 → 虚拟屏幕(Screen Buffer) │ | ||||||
| └────────────────────┬───────────────────────────────────────────┘ | ||||||
| │ renderNodeToOutput() | ||||||
| ▼ | ||||||
| ┌────────────────────────────────────────────────────────────────┐ | ||||||
| │ Screen Buffer(二维 cell 数组) │ | ||||||
| │ output.ts: 损伤追踪 + CharCache + blit 优化 │ | ||||||
| └────────────────────┬───────────────────────────────────────────┘ | ||||||
| │ logUpdate() | ||||||
| ▼ | ||||||
| ┌────────────────────────────────────────────────────────────────┐ | ||||||
| │ 差分引擎(log-update.ts, 773 行) │ | ||||||
| │ 前后帧 diff → Diff 补丁数组 │ | ||||||
| │ DECSTBM 硬件滚动 / 逐 cell 增量写入 │ | ||||||
| └────────────────────┬───────────────────────────────────────────┘ | ||||||
| │ writeDiffToTerminal() | ||||||
| ▼ | ||||||
| ┌────────────────────────────────────────────────────────────────┐ | ||||||
| │ 终端输出(terminal.ts, 248 行) │ | ||||||
| │ BSU/ESU 包裹 → 单次 stdout.write() │ | ||||||
| └────────────────────────────────────────────────────────────────┘ | ||||||
| ``` | ||||||
|
|
||||||
| ## 核心渲染文件 | ||||||
|
|
||||||
| | 文件 | LOC | 职责 | | ||||||
| |------|-----|------| | ||||||
| | `ink/ink.tsx` | 1,722 | Ink 主循环:调度渲染、双缓冲、池管理、alt-screen | | ||||||
| | `ink/log-update.ts` | 773 | 差分引擎:逐 cell diff、DECSTBM 滚动、全量回退检测 | | ||||||
| | `ink/output.ts` | 797 | 屏幕绘制:损伤追踪、CharCache、blit 优化 | | ||||||
| | `ink/screen.ts` | 1,486 | Screen buffer:StylePool、CharPool、HyperlinkPool | | ||||||
| | `ink/render-node-to-output.ts` | 1,462 | React 节点 → Screen buffer 渲染 | | ||||||
| | `ink/terminal.ts` | 248 | 终端能力检测、BSU/ESU 包裹、单次写入 | | ||||||
| | `ink/renderer.ts` | 178 | 光标管理、alt-screen 切换 | | ||||||
| | `utils/bufferedWriter.ts` | 100 | 流式文本批量写入 | | ||||||
|
|
||||||
| ## 机制 1:DEC 2026 同步输出(原子更新) | ||||||
|
|
||||||
| 源码: `ink/terminal.ts#L66-118`, `ink/terminal.ts#L190-248` | ||||||
|
|
||||||
| **最关键的防闪烁机制**。所有终端输出用 BSU/ESU(Begin/End Synchronized Update)包裹,终端在收到 ESU 前不会渲染任何中间状态。这里的"DEC 2026"是 DEC 私有模式 DECSET/DECRST `?2026`(synchronized output)的编号,并非年份: | ||||||
|
|
||||||
| ``` | ||||||
| CSI ?2026h ← BSU: 开始缓冲 | ||||||
| ...清屏、光标移动、文本写入... | ||||||
| CSI ?2026l ← ESU: 一次性刷新到屏幕 | ||||||
| ``` | ||||||
|
|
||||||
| ### 终端检测 | ||||||
|
|
||||||
| `isSynchronizedOutputSupported()` 在模块加载时计算一次(能力不会中途变化)。DEC 2026 检测除 VTE 外**不做版本检查**——VTE 需 `VTE_VERSION >= 6800`(0.68+),其他终端只匹配名称/环境变量。注意:版本检查也存在于独立的 `isProgressReportingAvailable()` 函数中(用于 OSC 9;4 进度报告),两者是不同功能: | ||||||
|
|
||||||
| | 终端 | 检测方式 | | ||||||
| |------|----------| | ||||||
| | iTerm2 | `TERM_PROGRAM === 'iTerm.app'` | | ||||||
| | WezTerm | `TERM_PROGRAM === 'WezTerm'` | | ||||||
| | Ghostty | `TERM_PROGRAM === 'ghostty'` 或 `TERM === 'xterm-ghostty'` | | ||||||
| | Kitty | `TERM` 含 `kitty` 或 `KITTY_WINDOW_ID` 存在 | | ||||||
| | Alacritty | `TERM_PROGRAM === 'alacritty'` 或 `TERM` 含 `alacritty` | | ||||||
| | foot | `TERM` 以 `foot` 开头 | | ||||||
| | Windows Terminal | `WT_SESSION` 存在 | | ||||||
| | VS Code | `TERM_PROGRAM === 'vscode'` | | ||||||
| | Warp | `TERM_PROGRAM === 'WarpTerminal'` | | ||||||
| | Contour | `TERM_PROGRAM === 'contour'` | | ||||||
| | Zed | `ZED_TERM` 存在 | | ||||||
| | VTE 系列(GNOME Terminal、Tilix 等) | `VTE_VERSION >= 6800`(VTE 0.68+) | | ||||||
| | **tmux** | **跳过**:tmux 不实现 DEC 2026,BSU/ESU 穿透到外层终端但 tmux 已破坏原子性 | | ||||||
|
|
||||||
| > **SSH 场景**:`TERM_PROGRAM` 默认不被 SSH 转发。Claude Code 在启动时通过 XTVERSION 查询(`CSI > 0 q → DCS > | name ST`)异步检测终端名称,补充 env 检测的盲区(源码: `terminal.ts#L120-147`)。 | ||||||
|
|
||||||
| ### 输出管线 | ||||||
|
|
||||||
| `writeDiffToTerminal()`(源码: `terminal.ts#L190-248`)将所有 diff 补丁拼接为单个字符串,用 BSU/ESU 包裹后单次 `stdout.write()` 发送: | ||||||
|
|
||||||
| ```typescript | ||||||
| let buffer = useSync ? BSU : '' | ||||||
| for (const patch of diff) { | ||||||
| switch (patch.type) { | ||||||
| case 'stdout': buffer += patch.content; break | ||||||
| case 'clear': buffer += eraseLines(patch.count); break | ||||||
| case 'clearTerminal': buffer += getClearTerminalSequence(); break | ||||||
| case 'cursorMove': buffer += cursorMove(patch.x, patch.y); break | ||||||
| case 'styleStr': buffer += patch.str; break | ||||||
| // ... | ||||||
| } | ||||||
| } | ||||||
| if (useSync) buffer += ESU | ||||||
| terminal.stdout.write(buffer) // 单次写入 | ||||||
| ``` | ||||||
|
|
||||||
| ## 机制 2:差分渲染引擎 | ||||||
|
|
||||||
| 源码: `ink/log-update.ts`(773 行) | ||||||
|
|
||||||
| 不做全屏重绘,而是逐 cell 对比前后两帧,只输出变化的单元格。 | ||||||
|
|
||||||
| ### diff 算法 | ||||||
|
|
||||||
| 1. **损伤区域检查**:只扫描 `screen.damage` 矩形范围内的 cell(源码: `log-update.ts#L268-305`) | ||||||
| 2. **逐行比较**:对每行的每个 cell,比较 charId、styleId、hyperlinkId 三元组 | ||||||
| 3. **跳过规则**: | ||||||
| - 空 cell 不覆写已有内容(避免尾部空格导致终端换行) | ||||||
| - 宽字符占位符(`SpacerTail`/`SpacerHead`)跳过 | ||||||
| - 前后帧相同的 cell 跳过(大部分 cell) | ||||||
| 4. **光标管理**:追踪虚拟光标位置,使用相对移动(CR+LF、CUD)而非绝对定位 | ||||||
|
|
||||||
| ### 全量回退检测 | ||||||
|
|
||||||
| 某些场景 diff 无法处理,必须全屏重绘(源码: `log-update.ts#L142-147`): | ||||||
|
|
||||||
| | 触发条件 | 原因 | | ||||||
| |----------|------| | ||||||
| | `viewport.height` 缩小 | 终端高度变化无法增量处理 | | ||||||
| | `viewport.width` 变化 | 文本换行完全改变 | | ||||||
| | 内容超出视口 | 需要清除滚动缓冲区 | | ||||||
|
|
||||||
| 全量重绘通过 `fullResetSequence_CAUSES_FLICKER()` 执行——函数名本身就是对开发者的警告。 | ||||||
|
|
||||||
| ### diff 后优化 | ||||||
|
|
||||||
| diff 产生的补丁数组在写入终端前经过 `optimize()` 后处理(源码: `ink.tsx#L621`),合并相邻光标移动、消除冗余样式序列、将多个小补丁拼接为更少的写入单元,进一步减少输出字节数。 | ||||||
|
|
||||||
| ## 机制 3:DECSTBM 硬件滚动 | ||||||
|
|
||||||
| 源码: `ink/log-update.ts#L149-185` | ||||||
|
|
||||||
| 当 ScrollBox 的 `scrollTop` 变化时,用终端硬件滚动指令替代逐行重写: | ||||||
|
|
||||||
| ``` | ||||||
| CSI top;bottom r ← DECSTBM: 设置滚动区域 | ||||||
| CSI n S ← SU: 向上滚动 n 行 | ||||||
| CSI r ← 重置滚动区域 | ||||||
| CSI H ← 光标回原点 | ||||||
| ``` | ||||||
|
|
||||||
| 关键优化:在 `prev.screen` 上模拟同样的 `shiftRows()`,使 diff 循环自然只发现滚入的新行。 | ||||||
|
|
||||||
| **安全条件**(源码: `log-update.ts#L158-164`): | ||||||
| - 必须在 alt-screen 模式 | ||||||
| - 必须有 DEC 2026 支持(`decstbmSafe` 参数) | ||||||
| - 无 BSU/ESU 时回退到逐行重写——多输出字节,但无中间状态闪烁 | ||||||
|
|
||||||
| > **源码注释原文**:*"Without atomicity the outer terminal renders the intermediate state — region scrolled, edge rows not yet painted — a visible vertical jump on every frame where scrollTop moves."* | ||||||
|
|
||||||
| ## 机制 4:双缓冲 | ||||||
|
|
||||||
| 源码: `ink/ink.tsx#L99-100, #L593-595` | ||||||
|
||||||
| 源码: `ink/ink.tsx#L99-100, #L593-595` | |
| 源码: `ink/ink.tsx#L99-100, ink/ink.tsx#L593-595` |
Copilot
AI
Apr 1, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
这一段的源码引用同时出现 ink.tsx#L739-743 与 ink/output.ts#L268-305, #L522-528:前者缺少 ink/ 目录前缀,后者逗号后的第二段缺少文件前缀。建议统一为完整路径(例如 ink/ink.tsx#...、ink/output.ts#...),并确保同一条引用里的每个行号范围都带文件名。
Copilot
AI
Apr 1, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
FRAME_INTERVAL_MS = 16 实际对应约 62.5fps(1000/16),而非严格的 60fps。若想表达“约 60fps 上限”,建议把注释改为 ~60fps;若要严格 60fps,可考虑用 17ms 或说明为近似值。
| const FRAME_INTERVAL_MS = 16 // 60fps | |
| const FRAME_INTERVAL_MS = 16 // ~60fps(1000/16 ≈ 62.5fps) |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -14,3 +14,4 @@ | |||||
| | [08-Remote Control](./08-remote-control.md) | 远程控制架构、会话生命周期、安全纵深、评价优缺点、7 款竞品对比 | | ||||||
| | [09-多代理系统](./09-multi-agent.md) | Leader-Worker 协作、Swarm 三后端、Agent 定义、邮箱通信、任务管理、协调模式、远程传送 | | ||||||
| | [10-Prompt Suggestions](./10-prompt-suggestions.md) | 下一步提示预测:生成流程、Prompt 模板、12 条过滤规则、Speculation 推测执行 | | ||||||
| | [11-终端渲染](./11-terminal-rendering.md) | 防闪烁:DEC 2026 同步输出、差分渲染、双缓冲、DECSTBM 硬件滚动、缓存池化、60fps 节流 | | ||||||
|
||||||
| | [11-终端渲染](./11-terminal-rendering.md) | 防闪烁:DEC 2026 同步输出、差分渲染、双缓冲、DECSTBM 硬件滚动、缓存池化、60fps 节流 | | |
| | [11-终端渲染](./11-terminal-rendering.md) | 防闪烁:DECSET ?2026(BSU/ESU)同步输出、差分渲染、双缓冲、DECSTBM 硬件滚动、缓存池化、60fps 节流 | |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
在同一节里前面已用
ink/terminal.ts#...,但这里的源码引用写成了terminal.ts#L120-147/terminal.ts#L190-248,目录前缀不一致且在仓库外源码场景下更容易造成歧义。建议统一为完整相对路径(例如ink/terminal.ts#L120-147、ink/terminal.ts#L190-248)。