qwen-code/docs/design/tui-optimization/02-screen-flickering.md

33 KiB
Raw Blame History

TUI 优化:屏幕闪烁

详细设计文档 — 解决流式输出、窄屏、终端 resize 等场景下的屏幕闪烁问题。

1. 问题分析

1.1 闪烁的根本原因

Ink 6.2.3 的渲染模型决定了闪烁问题的一部分根源,但 qwen-code 当前的可见整屏闪烁还叠加了应用层主动清屏路径:

  1. 全量重绘:每次 React 状态变更Ink 对整个动态区域执行 eraseLines(N) + 重新输出。eraseLines 会逐行发出 ERASE_LINE + CURSOR_UP 序列对,然后重写所有内容。
  2. 超高重绘频率:流式输出时每个内容 chunk可包含一到多个 token触发一次状态更新和重绘高频时可达 50+ 次/秒。
  3. 应用层整屏清除路径:当前 qwen-code 的 refreshStatic() 会主动调用 ansiEscapes.clearTerminal,在 resize、compact 切换、视图切换等场景触发整屏刷新;这和 Ink 的 eraseLines 路径是两类问题,必须分开治理。

1.2 当前缓解措施

terminalRedrawOptimizer.ts

位于 packages/cli/src/ui/utils/terminalRedrawOptimizer.ts,通过拦截 stdout.write() 优化 ANSI 序列:

// 核心优化:折叠重复的 ERASE_LINE + CURSOR_UP 序列
// 原始序列N 行):
//   ESC[2K ESC[1A  ESC[2K ESC[1A  ... ESC[2K ESC[G
// 优化后:
//   ESC[NA  ESC[2K ESC[1B  ESC[2K ESC[1B  ... ESC[NA ESC[G

局限

  • 仅优化光标移动模式,不减少实际输出字节数
  • 不解决 Ink 全量重绘的根本问题
  • 不支持同步输出协议
  • refreshStatic() 触发的 clearTerminal 路径无效

Static/Dynamic 分离

packages/cli/src/ui/components/MainContent.tsx 使用 Ink 的 <Static> 组件分离已完成内容和流式内容:

// Static 区域:已完成的历史消息,追加后不再更新
<Static items={mergedHistory}>
  {(item) => <HistoryItemDisplay key={item.key} ... />}
</Static>

// Dynamic 区域:当前流式内容,每帧重绘
<Box>
  {pendingHistoryItems.map((item) => <HistoryItemDisplay ... />)}
</Box>

局限

  • 当流式内容本身超过终端高度时,动态区仍会频繁走 eraseLines 全量重绘,闪烁被放大
  • refreshStatic() 使用 clearTerminal 导致整屏闪烁resize、compact 切换、active view 切换等场景)

1.3 具体闪烁场景

场景 触发条件 严重程度 代码位置
流式输出 每个内容 chunk 触发 React re-render useGeminiStream hook
长输出超屏 动态内容高度 > 终端行数 严重 Ink 动态区 eraseLines 路径被放大
终端宽度 resize refreshStatic() 调用 clearTerminal;当前 effect 主要依赖宽度变化 AppContainer.tsx resize effect
Compact 模式切换 历史合并、settings dialog、快捷键切换触发 refreshStatic() MainContent / SettingsDialog / AppContainer
手动清屏/视图切换 /clear、active view 切换触发全屏刷新 slashCommandProcessor / DefaultAppLayout
窄屏布局抖动 布局重算导致内容高度反复变化 Ink 布局引擎
tmux/SSH 终端复用器放大闪烁效果 严重 终端环境因素

1.4 社区反馈

当前 issue 已经可以分成几类,不宜继续只用“闪烁”一个词笼统概括:

类别 代表 issue 当前文档结论
动态区流式闪烁 / 滚动条抖动 qwen-code#1184 #1491 #2748 #3007 #3144 已确认与 Ink eraseLines 路径和高频更新共同相关
refreshStatic() 型整屏闪烁 qwen-code#938 #1861 #2924 已确认是应用层 clearTerminal 路径
窄屏重复输出 / 无限滚动 qwen-code#2912 #2972 #1591 #1778 症状确认,但 #1778 的 one-line fix 不能直接当作当前源码根因
大输出导致的闪烁与不可读 qwen-code#1479 #2748 #2818 #1008 已确认需要把“预裁剪”和“最终视觉裁剪”分开治理

更完整的分类、issue 引用与修复矩阵见 07-issue-backed-failure-taxonomy.md

1.5 Gemini CLI / Claude Code 调研结论

外部源码调研表明qwen-code 当前的闪烁问题并不是单点 bug而是缺少三层能力

层级 Gemini CLI 已有能力 Claude Code 已有能力 qwen-code 当前缺口
观测层 useFlickerDetector():测量 UI 高度是否超屏 自定义 Ink profiler / frame diff 统计 缺少 flicker frame、frame write 指标
中层策略 findLastSafeSplitPoint() + Static 提升alternate/terminal bufferScrollableList StreamingMarkdown 稳定前缀;ScrollBox 贴底与滚动解耦 动态区高度控制、渲染模式分层不足
底层输出 自定义 Ink fork + incrementalRendering 选项,但 main-screen 仍有 clearTerminal 路径 synchronized output + diff patch + DECSTBM + output buffer 只有 stdout monkeypatch没有 frame 级 ownership

这带来一个明确的路线修正:

  1. Phase 1 先做 Gemini 风格的“中层治理”观测、节流、Static 提升、渲染模式分层
  2. Phase 3 再评估 Claude 风格的“底层接管”双缓冲、diff、DECSTBM
  3. 不要在尚无同步输出和 frame ownership 时提前推进 DECSTBM

1.6 本文档聚焦边界

为避免职责混乱,本文件只覆盖三类“屏幕闪烁本体”问题:

  1. 动态区重绘闪烁
  2. refreshStatic() 引发的整屏闪烁
  3. 与闪烁强相关的窄屏重复输出 / 无限滚动

而“大工具输出预算”“长会话滚动”“Markdown/tool detail 呈现”这些问题,虽然会放大闪烁,但其主方案在 03-rendering-extensibility.md 中展开。

2. 解决方案

2.1 [P0] 同步输出 — DECSET 2026

原理同步输出协议 允许应用通过转义序列告知终端"我正在更新帧,请暂缓显示直到帧完成"。

CSI ? 2026 h    ← Begin Synchronized Update暂停显示
... 帧内容 ...
CSI ? 2026 l    ← End Synchronized Update刷新显示

终端支持矩阵的使用方式

下面的矩阵应视为 rollout 验证矩阵,不是“单靠本仓源码就能证明的最终定论”。本地源码和竞品源码能证明的是:

  • WezTerm 官方文档 明确支持 synchronized rendering
  • kitty 官方文档 明确讨论过 synchronized update 对性能的帮助
  • Contour 的 synchronized output 规范页 维护了一份 adoption state列出 Contour、mintty、foot、WezTerm、iTerm2、Kitty 已支持,而 Windows Terminal 仍标注为 not yet
  • Claude Code 在自己的 runtime gating 中对 tmux 采取了保守禁用策略

因此qwen-code 的实施文档不应把 tmux、iTerm2、Windows Terminal、Terminal.app 的行为写成无条件事实,而应以 runtime probe + 终端家族 allowlist + 实机验证 共同决定是否默认开启。文档里的矩阵只用于安排 rollout 优先级,不替代实际探测。

终端家族 文档阶段结论 落地要求
WezTerm 官方文档明确支持 可作为优先验证对象
kitty 有官方资料表明支持并强调性能收益 可作为优先验证对象
iTerm2 / foot / Contour 外部 adoption state 显示已支持,但仍需结合 qwen 自身输出模型实测 默认先走特性开关或 runtime probe
Windows Terminal 外部 adoption state 仍标记为 not yet 默认关闭,待后续验证
tmux / SSH 嵌套场景 不应仅因“外层终端支持”就默认视为安全 默认保守禁用,待验证 passthrough/atomicity 后再开
Terminal.app 等未知终端 不能假设退化为零风险 需验证忽略未知序列时是否保持行为不变

落地步骤:先在现有的 terminalRedrawOptimizer.ts 中加入输出指标,再根据指标决定采用“单 write 包裹”还是“帧缓冲合并”。

前置 instrumentation:在默认启用前,必须先统计 Ink 每帧对应的 stdout.write() 次数、每次 write 的字节数、chunk 类型string / Buffer和 callback 语义。当前优化器的 optimizeMultilineEraseLines() 只能处理单次 string write 内的 ANSI 序列折叠,不能据此假设每帧一定只有一次 write。

实现方案:先在现有 terminalRedrawOptimizer.ts 中扩展 optimizedWrite,但需保证 BSU/ESU 成对、可禁用、且不改变 Buffer/callback 行为。

const BSU = '\x1b[?2026h';  // Begin Synchronized Update
const ESU = '\x1b[?2026l';  // End Synchronized Update

const optimizedWrite = function (
  this: NodeJS.WriteStream,
  chunk: unknown,
  encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void),
  callback?: (error?: Error | null) => void,
) {
  let optimizedChunk = chunk;
  if (typeof chunk === 'string') {
    optimizedChunk = optimizeMultilineEraseLines(chunk);
    // 检测是否包含帧更新(包含擦除序列即视为帧更新)
    if (chunk.includes(ERASE_LINE) || chunk.includes('\x1b[2J')) {
      optimizedChunk = BSU + optimizedChunk + ESU;
    }
  }
  return originalWrite.call(this, optimizedChunk as string | Uint8Array, ...);
};

如果 Ink 每帧多次 write:不要简单给每个 write 都包 BSU/ESU。应改用帧缓冲策略在 microtask/idle tick 中收集同一帧的 write 调用,合并后统一输出,并记录合并前后的 writes/sec 和 bytes/sec。

验证步骤

  1. 在优化器中添加 counters统计单次 React render 触发多少次 stdout.write()
  2. 覆盖 string、Buffer、带 encoding、带 callback 的 stdout.write() 调用形态
  3. 覆盖 screen reader 开启时不安装优化器的路径
  4. 覆盖 ansiEscapes.clearTerminaleraseLines、普通文本输出三类路径
  5. 检查 bsu_frame_count === esu_frame_count,异常时自动关闭同步输出

影响范围:仅 packages/cli/src/ui/utils/terminalRedrawOptimizer.ts

风险评估中低

  • 不支持的终端常见行为是忽略 BSU/ESU但这里不能宣称零风险需覆盖 tmux/SSH/Windows Terminal/Terminal.app 等组合路径
  • 可通过 QWEN_CODE_LEGACY_ERASE_LINES=1QWEN_CODE_LEGACY_RENDERING=1 禁用
  • stdout monkeypatch 是全局副作用,必须保证原始 write() 语义不变
  • Claude Code 在 src/ink/terminal.ts 中使用相同协议,但其 runtime gating 对 tmux 明确更保守qwen-code 也应沿用这种保守策略

预期收益

  • 消除大部分可见的帧撕裂和闪烁
  • 在已支持且通过验证的终端中writes/sec 与可见帧撕裂会显著下降tmux/SSH 需单独验证后再评估默认开启
  • 不改变渲染管线,仅改变终端侧行为

2.2 [P0] 流式更新节流

现状LLM 流式输出的每个内容 chunk 都触发 React 状态更新。虽然不是逐 token 更新(而是按 API 返回的 chunk 粒度),但在高速流式输出时仍可能产生每秒 50+ 次 re-render。人眼对文本更新的感知频率约 15-20fps大量渲染被浪费。

方案:在流式 hook 中实现 chunk 缓冲 + 定时刷新。需要覆盖 content stream 和 thought streamshell 命令输出已有 1s 级节流,应作为现状保留并单独验证。

// packages/cli/src/ui/hooks/useGeminiStream.ts概念实现

const chunkBufferRef = useRef<string>('');
const flushTimerRef = useRef<NodeJS.Timeout | null>(null);
const FLUSH_INTERVAL_MS = 60; // ≈16fps足够文本展示

const flushBuffer = useCallback(() => {
  if (chunkBufferRef.current) {
    setStreamingContent((prev) => prev + chunkBufferRef.current);
    chunkBufferRef.current = '';
  }
  flushTimerRef.current = null;
}, []);

const onContentChunk = useCallback(
  (chunk: string) => {
    chunkBufferRef.current += chunk;
    if (!flushTimerRef.current) {
      flushTimerRef.current = setTimeout(flushBuffer, FLUSH_INTERVAL_MS);
    }
  },
  [flushBuffer],
);

// 流结束、取消、工具调用开始、需要展示确认框时立即刷新
const onStreamEnd = useCallback(() => {
  if (flushTimerRef.current) clearTimeout(flushTimerRef.current);
  flushBuffer();
}, [flushBuffer]);

影响范围packages/cli/src/ui/hooks/useGeminiStream.ts

具体切入点

  • 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.2B [P0] refreshStatic() 语义拆分

为什么单独拆出来:当前可见整屏闪烁里,有一部分并不是 Ink 自己的 eraseLines 路径,而是应用层主动 clearTerminal。如果继续把这两类问题混在一起治理,就会出现“动态区闪烁减轻了,但一切 view switch / compact merge 还是整屏闪”的错觉。

当前源码事实

  • packages/cli/src/ui/AppContainer.tsxrefreshStatic() 直接 stdout.write(ansiEscapes.clearTerminal)
  • packages/cli/src/ui/components/MainContent.tsx 在 compact mode 合并 tool group 时,为了绕过 <Static> append-only 限制,会主动调用 uiActions.refreshStatic()

方案:把当前 refreshStatic() 拆成两个层级,而不是继续保留一个“什么都做”的总开关。

// 概念 API
function remountStaticHistory(): void {
  setHistoryRemountKey((prev) => prev + 1);
}

function clearTerminalAndRemount(): void {
  stdout.write(ansiEscapes.clearTerminal);
  remountStaticHistory();
}

使用约束

  • remountStaticHistory():仅用于已经由外部路径完成清屏的场景,例如 /clear / slash clear 中避免第二次 clearTerminal
  • clearTerminalAndRemount():保留给 compact merge、active view switch、明显宽度重排、显式全屏重置、或某些无法避免的严重错位恢复

源码校准Ink <Static> 是永久追加输出模型remount 不会删除已经写入终端 scrollback 的旧 static output。compact merge、active view switch、settings toggle、resize 这类“替换旧 static 内容”的场景,不能在当前架构下简单改成 remount-only否则会有重复历史或旧 view 残留风险。它们需要新的 static replacement / renderer 策略后再继续收紧。

验收要求

  • clear_terminal_counthistory_remount_count 分开统计
  • /clear / slash clear 不再出现先 console.clear() 后额外 clearTerminal 的重复清屏
  • compact / view switch / resize 的剩余 clear 路径有明确注释和后续 workstream

2.2A [P0] 渲染模式分层alternate / terminal buffer

动机Gemini CLI 已经把闪烁治理和渲染模式绑定在一起,而不是企图让 main-screen、fullscreen、copy mode、长会话都共享同一条输出路径。qwen-code 当前文档也应明确:防闪烁不是单纯改 ANSI 序列,而是要先区分不同 UI 模式。

建议分层

模式 建议用途 闪烁治理策略
main-screen 最保守兼容路径 节流 + 渐进转 Static + 尽量避免 clearTerminal
alternate buffer 长对话 / fullscreen / 复杂交互 优先落地滚动容器、selection、贴底逻辑
terminal buffer / future buffer mode 需要稳定 scrollback 的场景 为虚拟滚动和更激进的 render 优化预留

近期可执行动作

  1. 把当前 main-screen 路径与 fullscreen / alternate buffer 路径的闪烁目标拆开写
  2. refreshStatic() 的 main-screen 语义与 fullscreen 重排语义分离
  3. 为后续虚拟滚动预留“仅 alternate/fullscreen 启用”的接入点,避免给普通输出路径增加复杂度

为什么要现在写进设计

  • Gemini 的经验表明,长会话滚动与防闪烁是绑定问题
  • Claude 的经验表明,一旦要做 ScrollBox / 虚拟滚动,滚动状态就不该继续依赖高频 React setState
  • qwen-code 若不先分模式,后续任何滚动或缓冲优化都会和 main-screen 兼容性缠在一起

2.3 [P1] 动态内容高度管理 + 渐进提升

现状校准当流式内容超过终端高度时Ink 可能触发全屏重绘。源码中已经存在渐进提升的雏形:useGeminiStream 在 content 和 thought 流中调用 findLastSafeSplitPoint(),把安全分割点之前的内容加入 history/static只保留尾部 pending 内容在动态区域。当前缺口不是“从零实现提升”,而是提升阈值、覆盖范围和刷新频率不够可控。

方案:增强现有"渐进提升"Progressive Promotion模式 — 随着流式内容增长,将已完成的块从动态区域提升到 <Static> 区域,并把触发条件从纯文本边界升级为“渲染高度 + 时间间隔 + 安全 Markdown 边界”。

2.3A [P0] 窄屏重复输出 / 无限滚动专项治理

为什么放在闪烁文档:从用户感知看,这类问题通常表现为“屏幕一直抖、一直刷、不断重复输出”,与普通闪烁属于同一体感簇。但在实现层面,它不是单纯的 ANSI 序列问题,而是viewport 序列化、窄屏重换行、滚动事件和主屏渲染模型叠加后的复合问题

当前源码能确认的事实

  1. packages/core/src/services/shellExecutionService.ts 的彩色 shell 路径会在每次 render 时重新调用 serializeTerminalToObject(headlessTerminal),序列化当前可见 viewport
  2. headlessTerminal.onScroll() 也会触发 render
  3. packages/core/src/utils/terminalSerializer.ts 当前默认 scrollOffset = 0,因此 issue #1778 中“遗漏 scrollOffset 参数”不能直接视为现状根因
  4. 当前 JSON.stringify(output) !== JSON.stringify(finalOutput) 的整块对比方式会在窄屏换行、viewport 变化或滚动后把整块 viewport 当成“新输出”

更准确的设计结论

  • #2912 / #2972 的窄屏问题是真实存在的
  • 当前源码里已经能看到几个高风险点
  • 但不能把单一的 one-line fix 写成最终结论

分阶段修复方案

  1. P0 回归 harness
    • 窄宽度(<= 40 列)
    • tmux 多 pane 等效宽度
    • 宽度缩小后继续 streaming / shell 运行
    • git commit / interactive shell prompt
  2. P0 分离 live viewport 与 transcript archival
    • live viewport 继续用于嵌入 shell / 详情面板
    • transcript 只追加稳定块或低频快照
  3. P1 main-screen 保守策略
    • main-screen 默认只展示尾部 N 行
    • 旧内容折叠或进摘要
    • 避免持续把完整 viewport 回灌到主消息流

验收要求

  • 40 列和 tmux 多 pane 下不再复现重复打印
  • interactive shell 不再触发顶部/底部来回跳
  • 文档中保留“历史 issue 假设”和“当前源码已确认”两种标签,避免未来再次混淆

核心逻辑

流式输出开始
  ├─ 新 token 追加到 pendingContent
  ├─ 估算 pendingContent 渲染高度 vs 可用动态区域高度
  │   ├─ 高度安全且未超过最小间隔 → 继续累积
  │   └─ 接近阈值 →
  │       ├─ 使用 findLastSafeSplitPoint() 找到安全分割点
  │       ├─ 分割点之前的内容 → 提升到 history (Static)
  │       └─ 分割点之后的内容 → 保留在 pending (Dynamic)
  └─ 流结束 → 全部提升到 history

findLastSafeSplitPoint() 已存在于 packages/cli/src/ui/utils/markdownUtilities.ts,专为此类场景设计:

  • 不在代码块内部分割
  • 优先在段落边界 \n\n 分割
  • 回退到行边界 \n

增强点

  • 使用 availableTerminalHeightcontentWidth 和渲染行数估算 pending 高度
  • 对 content stream、thought stream、tool 输出摘要分别设置阈值
  • 加入最小提升间隔(如 300-500ms避免频繁写入 <Static>
  • 只在安全 Markdown 边界分割;代码块、列表、表格中保守不切

影响范围

  • packages/cli/src/ui/components/MainContent.tsx — 提供可用动态高度和 pending 高度约束
  • packages/cli/src/ui/AppContainer.tsx — 改进高度计算
  • packages/cli/src/ui/hooks/useGeminiStream.ts — 增强现有分割/提升逻辑

风险评估:中

  • 分割可能导致部分 Markdown 上下文丢失(如跨段落的列表)→ 通过保守的分割策略缓解
  • 频繁提升可能导致 <Static> 闪烁 → 设置最小提升间隔(如 500ms

预期收益:动态内容尽量控制在终端高度内,显著降低 Ink 全屏重绘路径触发概率。

2.4 [P1] 智能 refreshStatic()

承接关系:本节建立在 2.2B 的“安全语义拆分”已经完成 之上。也就是说,先把已清屏路径的 remount-only 与“clear terminal + remount”拆开再谈后续按 resize / compact / view switch 做更细粒度的选择。

现状:当前 refreshStatic()AppContainer.tsx 中通过 clearTerminal(完整的 ESC[2J ESC[3J ESC[H)实现全屏清除后重新挂载:

// AppContainer.tsx 当前实现
const refreshStatic = useCallback(() => {
  process.stdout.write(ansiEscapes.clearTerminal);
  setHistoryRemountKey((prev) => prev + 1); // 触发 <Static> 重新渲染
}, []);

触发场景:

  • 终端宽度 resize当前主要依赖 terminalWidth,高度变化不应触发静态区重排)
  • Compact 模式合并:MainContent
  • Compact 设置变更:SettingsDialog
  • Compact 快捷键切换:AppContainer
  • 手动清屏:/clear / clearScreen()
  • Active view 切换:DefaultAppLayout

方案

  1. Resize 优化:高度变化不应触发静态区重排;宽度变化仍需要保守处理,除非后续实现了可替换旧 static output 的 renderer

    const handleResize = useCallback(
      debounce(() => {
        updateTerminalDimensions();
        // 高度变化不影响已渲染内容换行;宽度变化仍可能需要全量重绘。
        if (widthChanged) {
          refreshStatic(); // 宽度变化时仍需全量重绘(行包装会变)
        }
      }, 500),
      [],
    );
    
  2. Compact 模式合并:使用增量更新而非全量重绘

    • 仅当合并确实改变了可见内容时触发刷新
    • 增加合并去抖动间隔
  3. 增加 resize debounce 到 500ms(从 300ms因为 resize 事件通常成组到达

补充自源码调研得到的约束

  • Gemini 的 refreshStatic() 只在“不使用 alternate buffer 且不使用 terminal buffer”时走 clearTerminal,说明 main-screen 与 buffer mode 已经是不同语义
  • Claude 的滚动体系将 scrollTop、贴底和重挂载分离避免简单 resize 导致滚动/刷新链路互相污染
  • 因此 qwen-code 的 refreshStatic() 设计必须明确“是否只是静态区 remount”“是否允许整屏清除”“是否需要保持当前 scrollback/selection”

影响范围packages/cli/src/ui/AppContainer.tsx(第 462-464, 1508-1517 行)

2.5 [P2] 双缓冲 + Diff PatchPhase 3

现状Ink 每帧都向 stdout 写入完整的新内容。

方案:维护一个 2D 字符网格作为"后缓冲区",每次渲染时仅输出与当前缓冲区不同的单元格。

架构设计

React 状态更新
  → Ink 渲染管线(产出新帧文本)
    → ScreenBuffer.diff(oldFrame, newFrame)
      → 产出 Patch 列表 [{row, col, content, style}]
        → 序列化为最小 ANSI 序列
          → 单次 stdout.write(BSU + patches + ESU)

核心数据结构

interface Cell {
  char: string; // 单个字符/grapheme cluster
  styleId: number; // 内化的样式 ID
  hyperlinkId: number; // 内化的超链接 ID
}

class ScreenBuffer {
  private cells: Cell[][]; // rows × cols
  private width: number;
  private height: number;

  diff(newBuffer: ScreenBuffer): Patch[];
  apply(patches: Patch[]): string; // 生成 ANSI 序列
}

风险评估:高

  • 需要拦截 Ink 的输出层或 fork Ink
  • 字符宽度计算CJK、emoji需要精确匹配 Ink 的计算
  • 样式边界的 diff 比纯文本 diff 复杂得多

参考Claude Code 在 src/ink/screen.ts 中实现了完整的双缓冲 + StylePool + CharPool是最成熟的参考实现。

建议:先评估 Phase 1 的同步输出 + 节流效果。如果已满足需求,可降低此方案优先级。

2.6 [P2] DECSTBM 滚动区域优化Phase 3

原理:使用 CSI DECSTBMSet Top and Bottom Margins设定终端滚动区域当内容需要滚动时发出 CSI n Sscroll up指令由终端硬件执行滚动而非重写整个视口。

前置条件需要双缓冲2.5)作为基础。

参考Claude Code 的 src/ink/render-node-to-output.ts 实现了自适应 drain 策略:

  • xterm.js5 行以下即时12 行以上平滑步进
  • 原生终端:待处理行数的 3/4最少 4 行

3. 竞品参考与路线校准

3.1 Gemini CLI中层防闪烁体系

Gemini CLI 在闪烁问题上最值得借鉴的不是底层 diff而是“中层组合拳”

能力 文件 对 qwen-code 的启示
findLastSafeSplitPoint() + 渐进转 Static packages/cli/src/ui/hooks/useGeminiStream.ts 继续强化现有 progressive promotion而不是推倒重来
useFlickerDetector() packages/cli/src/ui/hooks/useFlickerDetector.ts 先把闪烁变成指标
alternate / terminal buffer render options packages/cli/src/interactiveCli.tsx 闪烁方案必须分模式设计
ScrollableList / VirtualizedList packages/cli/src/ui/components/shared/* 长会话滚动本身就是防闪烁的一部分

关键洞察Gemini 证明了在不重写 Ink 内核的前提下,仍能通过模式分层、渐进转 Static 和滚动容器明显改善体验。

3.2 Claude Code底层防闪烁体系

Claude Code 的自研 Ink 内核提供了五层防闪烁保护:

层级 机制 对应文件
1. 帧缓冲 screen buffer / prevScreen 复用 src/ink/output.tssrc/ink/render-to-screen.ts
2. Diff 渲染 逐 cell 比较,仅输出变更 src/ink/log-update.ts
3. 原子帧 BSU/ESU 同步输出包裹 src/ink/terminal.ts
4. 硬件滚动 DECSTBM 滚动区域 src/ink/log-update.ts
5. 布局/scrollback 感知 resize / offscreen / shrink 时显式 full reset src/ink/log-update.ts

关键洞察Claude Code 的经验表明,同步输出(第 3 层)是单项收益最大的优化;双缓冲 + diff第 1-2 层则是最彻底但也最昂贵的路线。qwen-code 的 Phase 1 策略应继续聚焦“同步输出 + 节流 + 中层治理”,不要过早跳入自研 renderer。

对于 PR-1PR-4 之后仍未闭环的 refreshStatic() 替换型清屏、detail / subagent / long output bounded surface、JetBrains / Windows / cmux 终端矩阵,以及 Claude 魔改 Ink 是否可迁移的问题,详见 15-complete-tui-flicker-closure-plan.md

4. 实施优先级与里程碑

如果目标是彻底解决闪屏,而不是继续维护一条“大而全”的 patch 集合,那么实施顺序就不应再建立在 #3013 的 diff 结构上。#3013 只保留为参考样本:它证明了 pre-slicing、stable height、stream throttle 这些方向有收益,但真正开 PR 时应按用户 issue 归纳出的故障类来组织,详见 10-issue-oriented-flicker-plan.md。下面这张里程碑表给的是技术演进顺序10 给的是真正开 PR 时的切分粒度

优先级 方案 周次 风险 预期收益
P0 输出层 instrumentation 1 指标口径可信
P0 流式更新节流 60ms 2 stdout.write -60%+
P0 refreshStatic() 安全语义拆分 2-3 已清屏路径不再重复 clear剩余替换型 clear 有边界
P0 大输出 pre-slicing + detail stability 3-5 大结果与展开场景不再引发 layout 风暴
P1 窄屏 / interactive shell 专项 5-7 中高 重复输出与无限滚动可收敛
P1 渲染模式分层 6-8 为滚动和 fullscreen 优化铺路
P1 同步输出 DECSET 2026按终端家族灰度 7-9 中低 消除已支持终端中的残余帧撕裂
P2 alternate/fullscreen 虚拟滚动 9-12 长会话稳定性显著提升
P2 双缓冲 + diff patch 11-13 stdout 字节/帧 -80%
P2 DECSTBM 滚动区域 13+ 滚动性能接近原生

4.1 压缩后的 4 条主 PR

从执行角度看,原先按问题类细拆成 8 条虽然严谨,但有点过碎了。这一版把它们压成 4 条主 PR同时保留两个必须单独隔离的高风险区窄屏 shell 路径、终端协议层。

主 PR 解决的问题类 吸收旧拆分 代表 issue 代表性验证场景
PR-1 主屏闪烁基础修复 PR-Prep + PR-A1 + PR-B1 #1184 #1491 #3007 #938 #1861 #2924,以及 #2748 的 flicker 子问题 长回答、thought+content、冷启动样本、/clear 重复清屏
PR-2 大输出与详情展开稳定性 PR-D1 + PR-E1 #1479 #2424 #2624,以及展开相关子问题 npm install、5000 行输出、ctrl+ectrl+f、markdown-heavy 结果不退化
PR-3 窄屏 / interactive shell 专项 PR-C1 #2912 #2972 #1591 #1778 40 列窄终端、tmux 多 pane、git commit、彩色 shell
PR-4 终端协议层残余闪烁收尾 PR-A2 #3144#2903 作为必须验证的 JetBrains 环境样本 WezTerm、kitty、JetBrains 终端、tmux/SSH

推荐顺序

  1. PR-1
  2. PR-2
  3. PR-3
  4. PR-4

这样排的原因是:先把主屏高频闪烁和已清屏路径的重复 clear 收住,再处理大输出/详情展开,再去单独攻窄屏 shell最后才引入终端协议层灰度复杂度。

边界提醒

  • #2818 #1008 #355 属于后续 budgeting workstream不在当前 4 条 flicker 主 PR 的 closure 范围
  • synchronized output 虽然单项收益高,但在执行顺序上仍应晚于 PR-1 ~ PR-3

完整的文件落点、非目标、借鉴来源和验收矩阵见 10-issue-oriented-flicker-plan.md

5. 验证方案

除本节外,实施前还应对照 06-implementation-rollout-checklist.md 中“闪烁治理验收清单”的退出标准。

5.1 定量指标

指标 当前估计 Phase 1 目标 Phase 3 目标
stdout.write 调用/秒(流式) 50+ < 20 < 16
stdout 字节/帧(增量更新) 全帧大小 全帧大小(同步包裹) 仅变更 cell
clearTerminal 次数(正常流式) 未知 0 0
BSU/ESU 平衡 100% 成对 100% 成对
tmux 滚动事件/秒 4,000-6,700 < 100 < 20
可见闪烁(主观) 严重 轻微/无

5.2 测试场景

场景 测试方法 验收标准
正常流式输出 生成 500 token 响应 无可见闪烁
超长输出 生成 5000+ 行响应 不触发全屏清除
终端 resize 快速拖拽窗口大小 无全屏闪烁
窄屏 (< 40 列) 将终端缩至 30 列 布局优雅降级,无抖动
tmux 内运行 tmux 分屏环境 滚动事件 < 100/秒
SSH 远程 高延迟网络 闪烁不加剧
kitty/WezTerm 官方资料明确支持或已有正向验证的终端 无明显帧撕裂
Terminal.app / 未知终端 未通过 runtime probe 或未纳入 allowlist 行为不变(不退化)
alternate/fullscreen 路径 长会话滚动 + 贴底输出 不出现 blank spacer 或整屏 flash
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:新增,禁用同步输出 + 节流
  • 未通过 runtime probe 或未纳入 allowlist 的终端:默认不启用同步输出,仍保留开关和终端矩阵验证