docs: refine tui optimization design

This commit is contained in:
秦奇 2026-04-20 21:18:29 +08:00
parent 4eb19a94c0
commit 88efd775db
4 changed files with 342 additions and 153 deletions

View file

@ -60,8 +60,9 @@ Ink 6.2.3 的渲染模型决定了闪烁问题的根源:
| ---------------- | -------------------------------------- | -------- | ---------------------------- |
| 流式输出 | 每个内容 chunk 触发 React re-render | 高 | `useGeminiStream` hook |
| 长输出超屏 | 动态内容高度 > 终端行数 | 严重 | Ink 内部 `eraseLines` 路径 |
| 终端 resize | `refreshStatic()` 调用 `clearTerminal` | 中 | `AppContainer.tsx:1508-1517` |
| Compact 模式切换 | 历史合并触发 `refreshStatic()` | 中 | `MainContent.tsx:80` |
| 终端宽度 resize | `refreshStatic()` 调用 `clearTerminal`;当前 effect 主要依赖宽度变化 | 中 | `AppContainer.tsx` resize effect |
| Compact 模式切换 | 历史合并、settings dialog、快捷键切换触发 `refreshStatic()` | 中 | `MainContent` / `SettingsDialog` / `AppContainer` |
| 手动清屏/视图切换 | `/clear`、active view 切换触发全屏刷新 | 中 | `slashCommandProcessor` / `DefaultAppLayout` |
| 窄屏布局抖动 | 布局重算导致内容高度反复变化 | 中 | Ink 布局引擎 |
| tmux/SSH | 终端复用器放大闪烁效果 | 严重 | 终端环境因素 |
@ -95,13 +96,13 @@ CSI ? 2026 l ← End Synchronized Update刷新显示
| Windows Terminal | 1.18+ |
| Contour | 0.3.0+ |
| tmux | 3.4+ (透传) |
| 不支持的终端 | 静默忽略序列,零副作用 |
| 不支持的终端 | 通常会忽略未知私有 CSI 序列;仍需按终端和 tmux/SSH 组合验证 |
**实现方案**
**落地步骤**:先在现有的 `terminalRedrawOptimizer.ts` 中加入输出指标,再根据指标决定采用“单 write 包裹”还是“帧缓冲合并”。
在现有的 `terminalRedrawOptimizer.ts` 中扩展 `optimizedWrite`
**前置 instrumentation**:在默认启用前,必须先统计 Ink 每帧对应的 `stdout.write()` 次数、每次 write 的字节数、chunk 类型string / Buffer和 callback 语义。当前优化器的 `optimizeMultilineEraseLines()` 只能处理**单次 string write 内**的 ANSI 序列折叠,不能据此假设每帧一定只有一次 write
**需要确认的前提**Ink 是否每帧只调用一次 `stdout.write()`?当前优化器的 `optimizeMultilineEraseLines()` 处理的是**单次 write 内**的 ANSI 序列折叠,这暗示 Ink 大概率将完整帧内容在单次 write 中输出。如果确认如此BSU/ESU 可直接包裹单次 write
**实现方案**:先在现有 `terminalRedrawOptimizer.ts` 中扩展 `optimizedWrite`,但需保证 BSU/ESU 成对、可禁用、且不改变 Buffer/callback 行为。
```typescript
const BSU = '\x1b[?2026h'; // Begin Synchronized Update
@ -125,16 +126,23 @@ const optimizedWrite = function (
};
```
**如果 Ink 每帧多次 write**需要改用帧缓冲策略 — 在 idle tick 中收集所有 write 调用,合并后统一输出。实现更复杂但同样可行
**如果 Ink 每帧多次 write**不要简单给每个 write 都包 BSU/ESU。应改用帧缓冲策略在 microtask/idle tick 中收集同一帧的 write 调用,合并后统一输出,并记录合并前后的 writes/sec 和 bytes/sec
**验证步骤**:在优化器中添加临时计数器,统计单次 React render 触发多少次 `stdout.write()`
**验证步骤**
1. 在优化器中添加 counters统计单次 React render 触发多少次 `stdout.write()`
2. 覆盖 string、Buffer、带 encoding、带 callback 的 `stdout.write()` 调用形态
3. 覆盖 screen reader 开启时不安装优化器的路径
4. 覆盖 `ansiEscapes.clearTerminal``eraseLines`、普通文本输出三类路径
5. 检查 `bsu_frame_count === esu_frame_count`,异常时自动关闭同步输出
**影响范围**:仅 `packages/cli/src/ui/utils/terminalRedrawOptimizer.ts`
**风险评估****极低**
**风险评估****低**
- 不支持的终端静默忽略 BSU/ESU 序列
- 可通过 `QWEN_CODE_LEGACY_ERASE_LINES=1` 完全禁用
- 不支持的终端通常忽略 BSU/ESU但不能宣称零风险需覆盖 tmux/SSH/Windows Terminal/Terminal.app
- 可通过 `QWEN_CODE_LEGACY_ERASE_LINES=1``QWEN_CODE_LEGACY_RENDERING=1` 禁用
- stdout monkeypatch 是全局副作用,必须保证原始 `write()` 语义不变
- Claude Code 在 `src/ink/terminal.ts` 中已使用相同方案
**预期收益**
@ -147,7 +155,7 @@ const optimizedWrite = function (
**现状**LLM 流式输出的每个内容 chunk 都触发 React 状态更新。虽然不是逐 token 更新(而是按 API 返回的 chunk 粒度),但在高速流式输出时仍可能产生每秒 50+ 次 re-render。人眼对文本更新的感知频率约 15-20fps大量渲染被浪费。
**方案**:在流式 hook 中实现 chunk 缓冲 + 定时刷新。
**方案**:在流式 hook 中实现 chunk 缓冲 + 定时刷新。需要覆盖 content stream 和 thought streamshell 命令输出已有 1s 级节流,应作为现状保留并单独验证。
```typescript
// packages/cli/src/ui/hooks/useGeminiStream.ts概念实现
@ -174,7 +182,7 @@ const onContentChunk = useCallback(
[flushBuffer],
);
// 流结束时立即刷新
// 流结束、取消、工具调用开始、需要展示确认框时立即刷新
const onStreamEnd = useCallback(() => {
if (flushTimerRef.current) clearTimeout(flushTimerRef.current);
flushBuffer();
@ -183,29 +191,33 @@ const onStreamEnd = useCallback(() => {
**影响范围**`packages/cli/src/ui/hooks/useGeminiStream.ts`
**具体切入点**`handleContentEvent()` 回调(第 664 行),该函数在每个内容 chunk 到达时调用 `setPendingHistoryItem()`(第 690 行)触发 React 状态更新。节流层应在 `setPendingHistoryItem` 调用之前缓冲内容。
**具体切入点**
- `handleContentEvent()`:在 `setPendingHistoryItem()` 前缓冲 content chunk
- thought stream 更新路径:使用同一套缓冲/flush 机制,避免思考内容绕过节流
- shell command output保留现有 `OUTPUT_UPDATE_INTERVAL_MS = 1000`,只补指标和回归测试
**风险评估**:低
- 60ms 延迟对用户不可感知
- 流结束时立即刷新确保最终内容完整
- 流结束、取消、工具调用、确认框展示前立即刷新,确保 UI 状态不滞后
- 如有问题可调整 `FLUSH_INTERVAL_MS` 或通过环境变量禁用
**预期收益**`stdout.write` 调用从 50+/秒降至 < 20/直接减少 60%+ 的渲染开销
### 2.3 [P1] 动态内容高度管理 + 渐进提升
**现状**当流式内容超过终端高度时Ink 触发全屏重绘。`constrainHeight` 状态标志虽然存在(`AppContainer.tsx:1482`),但计算不够精确
**现状校准**当流式内容超过终端高度时Ink 可能触发全屏重绘。源码中已经存在渐进提升的雏形:`useGeminiStream` 在 content 和 thought 流中调用 `findLastSafeSplitPoint()`,把安全分割点之前的内容加入 history/static只保留尾部 pending 内容在动态区域。当前缺口不是“从零实现提升”,而是提升阈值、覆盖范围和刷新频率不够可控
**方案**实现"渐进提升"Progressive Promotion模式 — 随着流式内容增长,将已完成的块从动态区域提升到 `<Static>` 区域。
**方案**增强现有"渐进提升"Progressive Promotion模式 — 随着流式内容增长,将已完成的块从动态区域提升到 `<Static>` 区域,并把触发条件从纯文本边界升级为“渲染高度 + 时间间隔 + 安全 Markdown 边界”
**核心逻辑**
```
流式输出开始
├─ 新 token 追加到 pendingContent
├─ 检查 pendingContent 高度 vs 可用动态区域高度
│ ├─ 高度安全 → 继续累积
├─ 估算 pendingContent 渲染高度 vs 可用动态区域高度
│ ├─ 高度安全且未超过最小间隔 → 继续累积
│ └─ 接近阈值 →
│ ├─ 使用 findLastSafeSplitPoint() 找到安全分割点
│ ├─ 分割点之前的内容 → 提升到 history (Static)
@ -219,18 +231,25 @@ const onStreamEnd = useCallback(() => {
- 优先在段落边界 `\n\n` 分割
- 回退到行边界 `\n`
**增强点**
- 使用 `availableTerminalHeight``contentWidth` 和渲染行数估算 pending 高度
- 对 content stream、thought stream、tool 输出摘要分别设置阈值
- 加入最小提升间隔(如 300-500ms避免频繁写入 `<Static>`
- 只在安全 Markdown 边界分割;代码块、列表、表格中保守不切
**影响范围**
- `packages/cli/src/ui/components/MainContent.tsx` — 实现提升逻辑
- `packages/cli/src/ui/components/MainContent.tsx`提供可用动态高度和 pending 高度约束
- `packages/cli/src/ui/AppContainer.tsx` — 改进高度计算
- `packages/cli/src/ui/hooks/useGeminiStream.ts` — 在流式处理中调用分割
- `packages/cli/src/ui/hooks/useGeminiStream.ts`增强现有分割/提升逻辑
**风险评估**:中
- 分割可能导致部分 Markdown 上下文丢失(如跨段落的列表)→ 通过保守的分割策略缓解
- 频繁提升可能导致 `<Static>` 闪烁 → 设置最小提升间隔(如 500ms
**预期收益**:动态内容始终控制在终端高度内,永远不触发 Ink 的全屏重绘路径
**预期收益**:动态内容尽量控制在终端高度内,显著降低 Ink 全屏重绘路径触发概率
### 2.4 [P1] 智能 refreshStatic()
@ -246,10 +265,12 @@ const refreshStatic = useCallback(() => {
触发场景:
- 终端 resize300ms debounce`AppContainer.tsx:1508-1517`
- Compact 模式合并:`MainContent.tsx:80`
- 手动清屏:`AppContainer.tsx:1200`
- Auth 变更:`AppContainer.tsx:498`
- 终端宽度 resize当前主要依赖 `terminalWidth`,高度变化不应触发静态区重排)
- Compact 模式合并:`MainContent`
- Compact 设置变更:`SettingsDialog`
- Compact 快捷键切换:`AppContainer`
- 手动清屏:`/clear` / `clearScreen()`
- Active view 切换:`DefaultAppLayout`
**方案**
@ -260,7 +281,7 @@ const refreshStatic = useCallback(() => {
debounce(() => {
// 不再 clearTerminal仅更新布局尺寸
updateTerminalDimensions();
// 只在宽度变化时才需要重新渲染(高度变化不影响已渲染内容)
// 只在宽度变化时才需要重新渲染(高度变化不影响已渲染内容换行
if (widthChanged) {
refreshStatic(); // 宽度变化时仍需全量重绘(行包装会变)
}
@ -354,9 +375,10 @@ Claude Code 的自研 Ink 内核提供了五层防闪烁保护:
| 优先级 | 方案 | 周次 | 风险 | 预期收益 |
| ------ | -------------------- | ----- | ---- | ------------------------- |
| P0 | 同步输出 DECSET 2026 | 1 | 极低 | 消除帧撕裂tmux 效果显著 |
| P0 | 流式更新节流 60ms | 1 | 低 | stdout.write -60%+ |
| P1 | 渐进提升到 Static | 6-7 | 中 | 消除长输出全屏闪烁 |
| P0 | 输出层 instrumentation | 1 | 低 | 指标口径可信 |
| P0 | 同步输出 DECSET 2026 | 2 | 中低 | 消除帧撕裂tmux 效果显著 |
| P0 | 流式更新节流 60ms | 2 | 低 | stdout.write -60%+ |
| P1 | 现有渐进提升增强 | 7 | 中 | 降低长输出全屏闪烁 |
| P1 | 智能 refreshStatic() | 8-9 | 中 | resize 不再全屏闪烁 |
| P2 | 双缓冲 + diff patch | 11-13 | 高 | stdout 字节/帧 -80% |
| P2 | DECSTBM 滚动区域 | 13+ | 高 | 滚动性能接近原生 |
@ -369,6 +391,8 @@ Claude Code 的自研 Ink 内核提供了五层防闪烁保护:
| ---------------------------- | ----------- | -------------------- | ------------ |
| stdout.write 调用/秒(流式) | 50+ | < 20 | < 16 |
| stdout 字节/帧(增量更新) | 全帧大小 | 全帧大小(同步包裹) | 仅变更 cell |
| clearTerminal 次数(正常流式) | 未知 | 0 | 0 |
| BSU/ESU 平衡 | 无 | 100% 成对 | 100% 成对 |
| tmux 滚动事件/秒 | 4,000-6,700 | < 100 | < 20 |
| 可见闪烁(主观) | 严重 | 轻微/无 | 无 |
@ -382,11 +406,13 @@ Claude Code 的自研 Ink 内核提供了五层防闪烁保护:
| 窄屏 (< 40 ) | 将终端缩至 30 | 布局优雅降级无抖动 |
| tmux 内运行 | tmux 分屏环境 | 滚动事件 < 100/ |
| SSH 远程 | 高延迟网络 | 闪烁不加剧 |
| kitty/WezTerm | 支持 DECSET 2026 的终端 | 零帧撕裂 |
| kitty/WezTerm | 支持 DECSET 2026 的终端 | 无明显帧撕裂 |
| Terminal.app | 不支持 DECSET 2026 | 行为不变(不退化) |
| screen reader | `config.getScreenReader()` 开启 | 不安装 stdout 优化器 |
| Buffer write/callback | 直接写 stdout 的外部路径 | `write()` 返回值和 callback 行为不变 |
### 5.3 向后兼容
- `QWEN_CODE_LEGACY_ERASE_LINES=1`:禁用所有 stdout 拦截优化(已有)
- `QWEN_CODE_LEGACY_RENDERING=1`:新增,禁用同步输出 + 节流
- 不支持 DECSET 2026 的终端:序列被静默忽略,零风险
- 不支持 DECSET 2026 的终端:通常忽略未知序列,但仍需保留开关和终端矩阵验证