diff --git a/docs/design/tui-optimization/00-overview.md b/docs/design/tui-optimization/00-overview.md index 366a110a5..e9058a707 100644 --- a/docs/design/tui-optimization/00-overview.md +++ b/docs/design/tui-optimization/00-overview.md @@ -10,7 +10,14 @@ qwen-code 的 TUI 层基于 **Ink 6.2.3 + React 19** 构建,当前面临三个 2. **屏幕闪烁**:Ink 的全量重绘机制导致流式输出时严重闪烁,在 tmux/SSH 环境下尤为突出(社区报告高达 4,000-6,700 次/秒的滚动事件) 3. **渲染能力与可扩展性**:自定义正则 Markdown 解析器功能受限,缺少 LaTeX 数学公式、终端超链接等支持;主题系统默认 hex 主题可能影响透明背景终端 -这些问题在 GitHub Issues 中被大量报告(qwen-code#1778, #2748, #2877; claude-code#9935, #37283, #14641 等),是当前最主要的用户体验痛点。 +这些问题在 GitHub Issues 中被大量报告,而且已经不再只是“泛泛的体验抱怨”,而是形成了几个清晰的故障簇: + +- 动态区流式闪烁 / 滚动条抖动:qwen-code#1184, #1491, #2748, #3007, #3144 +- `refreshStatic()` 型整屏闪烁:qwen-code#938, #1861, #2924 +- 窄屏重复输出 / 无限滚动:qwen-code#2912, #2972, #1591 +- 大输出不可读 / 工具输出预算不足:qwen-code#1479, #2818, #1008, #355 + +这些反馈已在新文档 [07-issue-backed-failure-taxonomy.md](./07-issue-backed-failure-taxonomy.md) 中重新按症状、源码证据和修复方案分类。 **重要校准**:当前启动分析器只覆盖 UI render 之前的 checkpoint,尚未覆盖交互式 `config.initialize()`、MCP 首个工具注册、全部 MCP 发现完成、Gemini tools 声明刷新等阶段。因此本文档的实施顺序必须先补观测,再用真实数据确认优先级。 @@ -79,6 +86,14 @@ Entry (gemini.tsx) | 窄屏问题 | claude-code#13504, #18493, #5408 | 中 | | LaTeX 支持 | claude-code#21433 | 低 | +### 2.5 Issue-backed 故障分类 + +本轮新增了一份专门把 qwen-code issue、当前源码和竞品源码对齐的分类文档: + +| 文档 | 说明 | +| --- | --- | +| [07-issue-backed-failure-taxonomy.md](./07-issue-backed-failure-taxonomy.md) | 按“闪烁 / 窄屏重复 / 大输出 / 工具与子 agent 展开”分类,给出症状、源码证据、修复路线与验收方式 | + ## 3. 核心工作流概览 | 工作流 | 核心问题 | 关键指标 | 依赖关系 | @@ -88,7 +103,7 @@ Entry (gemini.tsx) | **屏幕闪烁** | Ink 全量重绘;无同步输出 | 闪烁事件/秒,stdout writes/sec、clearTerminal 次数 | 依赖输出层观测 | | **渲染与扩展** | 正则解析器脆弱;缺少格式支持;主题限制 | 格式覆盖率,parse/highlight 耗时,可配置性 | 依赖稳定输出层 | -**执行顺序**:观测基线 -> 屏幕闪烁低风险治理 -> 启动/MCP 渐进可用 -> 渲染缓存与扩展。MCP 与渲染可并行推进,但必须共享同一套指标口径。 +**执行顺序**:观测基线 -> issue-backed P0 问题治理(动态闪烁、`refreshStatic()`、大输出预裁剪、窄屏回归 harness) -> 启动/MCP 渐进可用 -> 渲染缓存与扩展。MCP 与渲染可并行推进,但必须共享同一套指标口径。 **实施约束**:从这一版开始,所有落地工作默认都应同时参考 [06-implementation-rollout-checklist.md](./06-implementation-rollout-checklist.md)。如果某项优化没有满足对应的验收清单、灰度顺序和回滚条件,就不应直接进入默认开启阶段。 @@ -176,3 +191,5 @@ Entry (gemini.tsx) | [04-gemini-cli-research.md](./04-gemini-cli-research.md) | Gemini CLI 源码调研 | | [05-claude-code-research.md](./05-claude-code-research.md) | Claude Code 源码调研 | | [06-implementation-rollout-checklist.md](./06-implementation-rollout-checklist.md) | 实施门禁、验收、灰度与回滚清单 | +| [07-issue-backed-failure-taxonomy.md](./07-issue-backed-failure-taxonomy.md) | 基于 issue 与当前源码的故障分类和修复路线 | +| [08-execution-plan-and-test-matrix.md](./08-execution-plan-and-test-matrix.md) | 按文件落点、阶段拆解和测试矩阵整理的执行稿 | diff --git a/docs/design/tui-optimization/02-screen-flickering.md b/docs/design/tui-optimization/02-screen-flickering.md index 9b658fa8e..8ffb0120e 100644 --- a/docs/design/tui-optimization/02-screen-flickering.md +++ b/docs/design/tui-optimization/02-screen-flickering.md @@ -68,11 +68,16 @@ Ink 6.2.3 的渲染模型决定了闪烁问题的一部分根源,但 qwen-code ### 1.4 社区反馈 -- **qwen-code#1778**:流式输出时屏幕闪烁 -- **qwen-code#2748**:MCP 加载时闪烁 + 慢启动 -- **claude-code#9935**:tmux 中 4,000-6,700 次/秒滚动事件 -- **claude-code#37283**:长输出全屏闪烁 -- **claude-code#10794**:SSH 远程场景闪烁加剧 +当前 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](./07-issue-backed-failure-taxonomy.md)。 ### 1.5 Gemini CLI / Claude Code 调研结论 @@ -90,6 +95,16 @@ Ink 6.2.3 的渲染模型决定了闪烁问题的一部分根源,但 qwen-code 2. **Phase 3** 再评估 Claude 风格的“底层接管”:双缓冲、diff、DECSTBM 3. 不要在尚无同步输出和 frame ownership 时提前推进 DECSTBM +### 1.6 本文档聚焦边界 + +为避免职责混乱,本文件只覆盖三类“屏幕闪烁本体”问题: + +1. 动态区重绘闪烁 +2. `refreshStatic()` 引发的整屏闪烁 +3. 与闪烁强相关的窄屏重复输出 / 无限滚动 + +而“大工具输出预算”“长会话滚动”“Markdown/tool detail 呈现”这些问题,虽然会放大闪烁,但其主方案在 [03-rendering-extensibility.md](./03-rendering-extensibility.md) 中展开。 + ## 2. 解决方案 ### 2.1 [P0] 同步输出 — DECSET 2026 @@ -229,6 +244,39 @@ const onStreamEnd = useCallback(() => { **预期收益**:`stdout.write` 调用从 50+/秒降至 < 20/秒,直接减少 60%+ 的渲染开销。 +### 2.2B [P0] `refreshStatic()` 语义拆分 + +**为什么单独拆出来**:当前可见整屏闪烁里,有一部分并不是 Ink 自己的 `eraseLines` 路径,而是应用层主动 `clearTerminal`。如果继续把这两类问题混在一起治理,就会出现“动态区闪烁减轻了,但一切 view switch / compact merge 还是整屏闪”的错觉。 + +**当前源码事实**: + +- `packages/cli/src/ui/AppContainer.tsx` 的 `refreshStatic()` 直接 `stdout.write(ansiEscapes.clearTerminal)` +- `packages/cli/src/ui/components/MainContent.tsx` 在 compact mode 合并 tool group 时,为了绕过 `` append-only 限制,会主动调用 `uiActions.refreshStatic()` + +**方案**:把当前 `refreshStatic()` 拆成两个层级,而不是继续保留一个“什么都做”的总开关。 + +```typescript +// 概念 API +function remountStaticHistory(): void { + setHistoryRemountKey((prev) => prev + 1); +} + +function clearTerminalAndRemount(): void { + stdout.write(ansiEscapes.clearTerminal); + remountStaticHistory(); +} +``` + +**使用约束**: + +- `remountStaticHistory()`:用于 compact merge、局部布局变化、需要重算 `` 但不需要清空终端的场景 +- `clearTerminalAndRemount()`:仅保留给 `/clear`、显式全屏重置、或某些无法避免的严重错位恢复 + +**验收要求**: + +- `clear_terminal_count` 与 `history_remount_count` 分开统计 +- resize、compact toggle、subagent expand 三类场景默认不再触发 `clearTerminal` + ### 2.2A [P0] 渲染模式分层(alternate / terminal buffer) **动机**:Gemini CLI 已经把闪烁治理和渲染模式绑定在一起,而不是企图让 main-screen、fullscreen、copy mode、长会话都共享同一条输出路径。qwen-code 当前文档也应明确:防闪烁不是单纯改 ANSI 序列,而是要先区分不同 UI 模式。 @@ -259,6 +307,44 @@ const onStreamEnd = useCallback(() => { **方案**:增强现有"渐进提升"(Progressive Promotion)模式 — 随着流式内容增长,将已完成的块从动态区域提升到 `` 区域,并把触发条件从纯文本边界升级为“渲染高度 + 时间间隔 + 安全 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 假设”和“当前源码已确认”两种标签,避免未来再次混淆 + **核心逻辑**: ``` @@ -301,7 +387,9 @@ const onStreamEnd = useCallback(() => { ### 2.4 [P1] 智能 refreshStatic() -**现状**:`refreshStatic()` 在 `AppContainer.tsx` 中通过 `clearTerminal`(完整的 `ESC[2J ESC[3J ESC[H`)实现全屏清除后重新挂载: +**承接关系**:本节建立在 **2.2B 的“语义拆分”已经完成** 之上。也就是说,先把“仅 remount static”与“clear terminal + remount”拆开,再谈后续按 resize / compact / view switch 做更细粒度的选择。 + +**现状**:当前 `refreshStatic()` 在 `AppContainer.tsx` 中通过 `clearTerminal`(完整的 `ESC[2J ESC[3J ESC[H`)实现全屏清除后重新挂载: ```typescript // AppContainer.tsx 当前实现 @@ -322,7 +410,7 @@ const refreshStatic = useCallback(() => { **方案**: -1. **Resize 优化**:仅重绘动态区域而非全屏清除 +1. **Resize 优化**:优先走 `remountStaticHistory()`,仅在宽度变化且确实需要时才升级到 `clearTerminalAndRemount()` ```typescript const handleResize = useCallback( diff --git a/docs/design/tui-optimization/03-rendering-extensibility.md b/docs/design/tui-optimization/03-rendering-extensibility.md index f0b0a0e9f..49793d6e3 100644 --- a/docs/design/tui-optimization/03-rendering-extensibility.md +++ b/docs/design/tui-optimization/03-rendering-extensibility.md @@ -113,6 +113,18 @@ export const QwenDark: Theme = { 因此,本设计文档后续的重点不应只是“换 parser”,而是把 parser、streaming、高亮、虚拟滚动作为一组相互制约的问题来处理。 +### 1.7 基于 issue 的渲染问题校准 + +本轮补查 qwen-code issue 后,渲染层至少还要面对三类已被用户反复报告的问题: + +| 类别 | 代表 issue | 当前源码结论 | +| --- | --- | --- | +| 大工具输出导致闪烁 / 卡顿 | #2748 #2818 #1008 | 当前 plain text 路径仍主要依赖 `MaxSizedBox` 做最终视觉裁剪,容易出现“先 layout 全量,再裁剪” | +| 长回答 / 长会话不可读不可滚动 | #1479 #2748 | 当前主路径仍是 `` + pending,缺少专门的长会话滚动容器 | +| 工具 / 子 agent 详情既想看全,又会导致界面抖动 | #2424 #2624 #1861 #2924 | 当前折叠模式存在,但缺少统一预算、稳定高度与 bounded detail panel | + +更完整的问题分类见 [07-issue-backed-failure-taxonomy.md](./07-issue-backed-failure-taxonomy.md)。本文件只展开这些问题在**渲染层**的修复方式。 + ## 2. 解决方案 ### 2.1 [P0] Markdown token/block 缓存 @@ -255,6 +267,87 @@ function cachedHighlight(input: HighlightInput): HighlightResult { - 同步基线 + 异步预热:减少启动时模块加载量,降低内存占用,同时不破坏同步 render - 缓存:对已完成代码块的重复渲染耗时降至 O(1) +### 2.2A [P0] 大工具输出预裁剪(pre-render slicing) + +**当前问题**:`packages/cli/src/ui/components/messages/ToolMessage.tsx` 的 plain text 工具输出路径,仍然倾向于把整段字符串交给 React/Ink,再依赖 `MaxSizedBox` 做最终视觉裁剪。这样会出现一个经典坏路径: + +``` +500 行工具输出 + -> React/Ink 先 layout 500 行 + -> 终端最终只显示 10-15 行 + -> 每次增量更新都重新走一遍 +``` + +这类问题与同步输出、ANSI 优化是**两条独立治理线**。即便终端输出完全原子,若 React 每次仍要 layout 巨量节点,闪烁和卡顿也不会真正消失。 + +**Gemini 参考实现**:Gemini CLI 在 `ToolResultDisplay.tsx` 中已经使用 `SlicingMaxSizedBox`,先做: + +1. 字符级保护 +2. logical line slice +3. 再交给 `MaxSizedBox` 做最终安全裁剪 + +**qwen-code 设计建议**: + +- 为 plain text / ANSI tool output 引入预裁剪层 +- 预裁剪在进入 React render tree 前完成 +- `MaxSizedBox` 只保留为 width limiter 和安全网,而不是主要削峰手段 + +```typescript +// 概念实现 +interface SlicingMaxSizedBoxProps extends Omit { + data: T; + maxLines?: number; + children: (truncatedData: T) => React.ReactNode; +} +``` + +**必须保留的约束**: + +- markdown-heavy 输出不能因为防闪烁而直接退化成纯文本 +- hidden lines 计数必须区分 logical line 与 soft wrap line,避免双重计算 +- alternate/fullscreen 模式下应允许查看完整输出,main-screen 才做保守裁剪 + +**维护者方向信号**:截至 **2026-04-22**,PR `#3013` 仍是 `OPEN + CHANGES_REQUESTED`。它确认了“预裁剪 + 稳定高度 + 硬上限”的方向,但 reviewer 也明确指出: + +1. markdown path 不能被粗暴删掉 +2. hidden line 统计不能混淆 pre-slice 与 visual overflow + +因此本设计文档应采用该方向,但不能把 PR 当前实现直接当作已验证终稿。 + +### 2.2B [P0] 通用 tool output budgeting + +`#2818` 和 `#1008` 说明当前另一个真实问题是:预算规则并不统一。shell / MCP 路径已有截断,但 `grep`、`glob`、`read_file`、`edit` 等仍可能直接把巨大字符串送入上下文和 UI。 + +**设计建议**:把 budget 分成两层,而不是混在一起: + +1. **模型可见预算** + - 在 scheduler / function response 生成前统一截断 + - 控制上下文膨胀 +2. **用户可见预算** + - 在 UI 层按 main-screen / alternate/fullscreen 模式决定显示多少 + - 控制 Ink layout 成本与可读性 + +这两个预算不能互相替代: + +- 只做 UI 折叠,模型上下文仍会爆 +- 只做模型截断,UI 仍可能因未折叠的原始结果而卡顿 + +### 2.2C [P1] 有边界的 detail panel + +`#1479` 和 `#2748` 反映的不是“某个组件性能不够”,而是当前主界面缺少一个正式的长内容容器。继续把所有细节直接摊进主 transcript,会同时造成: + +- 工具输出太长 +- 子 agent 展开闪烁 +- 生成时无法自由回看 + +**建议路线**: + +- main transcript 默认展示 summary / truncated preview +- detail 内容进入 bounded scroll container +- fullscreen / alternate buffer 优先承接完整详情 + +这个动作和后面的虚拟滚动并不冲突,反而是更稳妥的前置步骤。 + ### 2.3 [P1] 切换到 marked 解析器 **动机**:当前自定义正则解析器的功能和鲁棒性已接近上限。`marked` 是 Claude Code 的选择,提供成熟的 block/inline lexer API,可作为 v2 渲染器候选。但迁移必须先定义安全策略和流式不完整语法策略,不能只替换 parser。 @@ -285,6 +378,12 @@ const lastBlockTokens = marked.lexer(blocks[blocks.length - 1]); return [...cachedBlocks.flat(), ...lastBlockTokens]; ``` +**和大工具输出方案的关系**: + +- `marked` 迁移不能替代 pre-render slicing +- markdown tool output 需要自己的 bounded strategy:字符保护、stable prefix / unstable suffix、必要时 summary + detail panel +- 不能因为 parser 升级就默认放开大块 markdown 全量渲染 + **新增 GFM 能力**: | 能力 | marked 支持 | 当前解析器 | |---|---|---| diff --git a/docs/design/tui-optimization/04-gemini-cli-research.md b/docs/design/tui-optimization/04-gemini-cli-research.md index 1a450ae21..9125d55dd 100644 --- a/docs/design/tui-optimization/04-gemini-cli-research.md +++ b/docs/design/tui-optimization/04-gemini-cli-research.md @@ -230,6 +230,38 @@ Gemini 将滚动行为和虚拟化行为分层: 这是 qwen-code 当前文档值得吸收的结构性建议:**不要把虚拟滚动逻辑塞进 `MainContent` 本体**,否则后续 tool 输出、prompt 历史、selection list 都会复制同一套复杂逻辑。 +### 5.4 Gemini 对“大工具输出”和“详情滚动”已经分层治理 + +这一轮针对 issue 重新核源码后,有两个和 qwen-code 当前痛点高度相关的点值得单独写出来: + +1. `packages/cli/src/ui/components/messages/ToolResultDisplay.tsx` + - 普通模式下对 string / object 结果使用 `SlicingMaxSizedBox` + - alternate buffer 下则使用 `Scrollable` 或 `ScrollableList` +2. `packages/cli/src/ui/components/shared/SlicingMaxSizedBox.tsx` + - 先做字符保护 + - 再做 logical line slice + - 最后才把数据交给 `MaxSizedBox` + +这意味着 Gemini 已经把下面两件事拆开了: + +- **主屏摘要与削峰** +- **全量详情与可滚动查看** + +这对 qwen-code 的价值非常直接:大工具输出问题不应继续只靠 `MaxSizedBox` 或 compact mode 顶住,应该尽快形成“预裁剪 + 独立详情容器”的双层策略。 + +### 5.5 Gemini 也暴露了窄屏 / 动态高度的剩余风险 + +Gemini 并不是“长列表问题已经全部解决”。在 `ScrollableList.test.tsx` 里仍有一条显式注释: + +- 当前 `VirtualizedList` 在某些场景下“won't remeasure” + +这说明即便已经有 `ScrollableList` / `VirtualizedList` / `ResizeObserver`,**动态高度 remeasure、窄屏重换行和 resize 后稳定性** 仍然是长期边界问题。 + +对 qwen-code 的意义是: + +- 可以借鉴 Gemini 的模式化渲染和预裁剪 +- 但不能把“只要上了虚拟滚动”误写成窄屏重复输出问题已自动解决 + ## 6. Markdown、代码高亮、表格与主题 ### 6.1 Markdown 仍是正则解析器 diff --git a/docs/design/tui-optimization/05-claude-code-research.md b/docs/design/tui-optimization/05-claude-code-research.md index f7673bb40..c2317dee0 100644 --- a/docs/design/tui-optimization/05-claude-code-research.md +++ b/docs/design/tui-optimization/05-claude-code-research.md @@ -258,6 +258,36 @@ Claude 的 `log-update.ts` 注释写得很直白: 这是 Claude 在“长会话 + 动态高度消息”问题上最有参考价值的部分。qwen-code 未来做虚拟滚动时,应该优先借鉴这里的: +### 6.3 Claude 对“大输出可读性”和“滚动稳定性”的核心取舍 + +如果只看 issue 表象,很容易把 Claude 的优势理解成“它用了 synchronized output,所以不闪”。源码表明并不是这么简单: + +1. `ScrollBox` 让高频滚动不经过 React state +2. `useVirtualScroll()` 通过 quantized snapshot、overscan、height cache 和 range freeze 控制 mounted range +3. `Messages.tsx` / `VirtualMessageList.tsx` 把长会话视为一个正式的一等场景,而不是附着在主 transcript 上的补丁 + +对 qwen-code 的含义是: + +- 如果想解决 `#1479` / `#2748` 这类“长输出不可读、生成时不能自由回看”的问题,不能只靠 ANSI 优化 +- 需要把“长内容滚动容器”本身提到架构层 + +### 6.4 Claude 的 Markdown/streaming 设计说明:防闪烁不只是终端问题 + +`src/components/Markdown.tsx` 有两条和 qwen 当前问题高度相关的经验: + +1. 模块级 token cache(500 条)降低了重挂载和回滚时的重复 parse 成本 +2. `StreamingMarkdown` 使用 stable prefix / unstable suffix,只让最后一个增长中的块反复 re-parse + +这给 qwen-code 一个很重要的修正: + +- 工具输出、长 markdown、子 agent 详情之所以闪,不只是终端输出序列不够原子 +- 也是因为 parser / render tree 在不断吞下越来越大的内容块 + +因此,Claude 的经验更适合被拆成两条路线吸收: + +- **终端层**:同步输出、单 write、保守 gating +- **渲染层**:token cache、stable prefix、bounded detail container + 1. scroll quantization 2. resize height scaling 3. frozen range diff --git a/docs/design/tui-optimization/06-implementation-rollout-checklist.md b/docs/design/tui-optimization/06-implementation-rollout-checklist.md index 1f4f75353..ae3347fad 100644 --- a/docs/design/tui-optimization/06-implementation-rollout-checklist.md +++ b/docs/design/tui-optimization/06-implementation-rollout-checklist.md @@ -2,6 +2,8 @@ > 本文档给 `00-05` 各设计/调研文档补齐实施门槛、验收标准、灰度顺序和回滚条件。目标不是重复方案细节,而是把“什么时候能开始做、做到什么算完成、什么情况下必须停下来”写清楚。 +如果需要把设计进一步落成开发排期,请与 [08-execution-plan-and-test-matrix.md](./08-execution-plan-and-test-matrix.md) 配套阅读:本清单负责“能不能上线”,`08` 负责“先改哪里、先测什么、先拆哪几条 PR”。 + ## 1. 使用方式 这份清单按四个层次组织: @@ -83,6 +85,7 @@ - `02-screen-flickering.md` - `04-gemini-cli-research.md` - `05-claude-code-research.md` +- `07-issue-backed-failure-taxonomy.md` ### 4.1 前置判断 @@ -109,11 +112,26 @@ ### 4.4 `refreshStatic()` 与渲染模式分层 - [ ] `refreshStatic()` 的触发来源已梳理清楚 +- [ ] `refreshStatic()` 已拆分为“仅 remount static”与“clear terminal + remount”两类语义 - [ ] main-screen 路径与 alternate/fullscreen 路径的目标分离 - [ ] resize 导致的重排不会默认演变为整屏 `clearTerminal` - [ ] active view / compact toggle / manual clear 三类路径分别有回归样例 -### 4.5 闪烁治理退出标准 +### 4.5 窄屏 / 无限滚动回归 + +- [ ] 已有 <= 40 列窄终端回归样例 +- [ ] 已有 tmux 多 pane 等效宽度回归样例 +- [ ] shell interactive prompt(如 `git commit`)有回归样例 +- [ ] 文档和测试中没有再把 `#1778` 的历史 one-line fix 写成当前源码事实 + +### 4.6 工具 / 子 agent 详情稳定性 + +- [ ] `ctrl+e` / `ctrl+f` 展开路径有独立回归样例 +- [ ] tool progress / subagent progress / assistant content 的更新频率已分开验证 +- [ ] bounded detail panel 或等价容器的键盘交互已回归 +- [ ] pending confirmation / force expand / focus lock 规则未退化 + +### 4.7 闪烁治理退出标准 - [ ] 正常流式输出场景 `stdout.write` 频率显著下降 - [ ] 已支持的终端中肉眼可见的帧撕裂明显减轻 @@ -127,6 +145,7 @@ - `03-rendering-extensibility.md` - `04-gemini-cli-research.md` - `05-claude-code-research.md` +- `07-issue-backed-failure-taxonomy.md` ### 5.1 Markdown / parser @@ -143,7 +162,15 @@ - [ ] `highlightAuto()` 有长度和 grammar 集合限制 - [ ] pending streaming 代码块不会触发最重路径 -### 5.3 虚拟滚动 +### 5.3 大工具输出与 budgeting + +- [ ] pre-render slicing 已区分 plain text / ANSI / markdown 三类输出 +- [ ] hidden lines 统计不会把 pre-slice 与 soft wrap overflow 双重计算 +- [ ] 模型可见预算与用户可见预算已拆分 +- [ ] markdown-heavy 工具输出不会因防闪烁而直接退化为纯文本 +- [ ] 工具输出默认折叠 / summary + detail 的产品语义已与 force expand 规则对齐 + +### 5.4 虚拟滚动 - [ ] 仅在 fullscreen / alternate 路径先行 - [ ] wheel/scroll 高频输入不直接驱动 React 高频 state 更新 @@ -151,12 +178,13 @@ - [ ] sticky bottom / copy mode / search mode 的语义预留已写明 - [ ] 不出现 blank spacer / mounted range 抖动 -### 5.4 渲染扩展退出标准 +### 5.5 渲染扩展退出标准 - [ ] Markdown fixture 测试覆盖旧 parser 与新 parser 共同边界 - [ ] 表格、代码块、列表、未闭合块在 streaming 场景不退化 - [ ] 高亮资源未就绪时内容仍优先可见 - [ ] 长会话场景 CPU / commit 次数有可测下降 +- [ ] issue 驱动场景(长工具输出、WebStorm/JetBrains 终端、长回答回看)至少各有一条验收样例 ## 6. 灰度顺序 diff --git a/docs/design/tui-optimization/07-issue-backed-failure-taxonomy.md b/docs/design/tui-optimization/07-issue-backed-failure-taxonomy.md new file mode 100644 index 000000000..d647e99fb --- /dev/null +++ b/docs/design/tui-optimization/07-issue-backed-failure-taxonomy.md @@ -0,0 +1,368 @@ +# TUI 问题分类:基于源码与 Issues 的故障画像 + +> 本文档把 qwen-code 当前 TUI 问题按“真实用户反馈 -> 当前源码证据 -> Gemini CLI / Claude Code 对照 -> 可执行修复方案”串成一张可实施地图。 +> 校准时间点:2026-04-22。GitHub issue 状态、PR 状态、上游源码实现若后续变更,需要重新核对。 + +## 1. 方法与事实边界 + +本文件只混合三类证据,并且明确区分置信度: + +1. **当前 qwen-code 源码已确认** + - `packages/cli/src/ui/AppContainer.tsx` + - `packages/cli/src/ui/components/MainContent.tsx` + - `packages/cli/src/ui/components/messages/ToolMessage.tsx` + - `packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx` + - `packages/core/src/services/shellExecutionService.ts` + - `packages/core/src/utils/terminalSerializer.ts` +2. **竞品源码可直接借鉴** + - Gemini CLI:`ScrollableList`、`VirtualizedList`、`ToolResultDisplay`、`SlicingMaxSizedBox`、`useFlickerDetector()` + - Claude Code:`writeDiffToTerminal()`、`ScrollBox`、`useVirtualScroll()`、`StreamingMarkdown` +3. **GitHub issue 用户症状与维护者归因** + - 仅作为症状证据或历史信号 + - 除非能被当前源码再次印证,否则不直接当作现状根因 + +**特别提醒**:`qwen-code#1778` 评论中“`serializeTerminalToObject()` 因默认 `scrollOffset=viewportY` 导致重复输出”的说法,不能直接当作当前源码事实。当前本地源码里 `scrollOffset` 默认值已经是 `0`。因此这条结论只能当作**历史信号**,不能直接写成今天的根因。 + +## 2. 总体分类 + +| 类别 | 典型症状 | 代表 issue | 当前结论 | 优先级 | +| --- | --- | --- | --- | --- | +| A. 动态区重绘闪烁 | 流式输出时闪屏、滚动条抖动、tmux/SSH 下更明显 | #1184 #1491 #2748 #3007 #3144 #2903 | 已确认是 Ink `eraseLines` 路径 + 高频更新共同放大 | P0 | +| B. `refreshStatic()` 整屏闪烁 | resize、compact merge、切 view、展开子 agent 时整屏清空 | #938 #1491 #1861 #2924 #2748 | 已确认是应用层 `clearTerminal` 路径,不应混同于 Ink 自身重绘 | P0 | +| C. 窄屏重复输出 / 无限滚动 | 窄终端、多 pane tmux、上下反复滚动、内容重复打印 | #2912 #2972 #1591 #1778 | 症状确认,根因是复合问题;历史 one-line fix 不能直接套用 | P0 | +| D. 大输出不可读 / 长会话不可滚动 | 长回答读不全、工具输出占满屏幕、上下文过快膨胀 | #1479 #2748 #2818 #1008 #355 | 已确认主问题是“先渲染全量,再裁剪”和缺少统一预算 | P0 | +| E. 工具 / 子 agent 详情展开闪烁 | `ctrl+e` / `ctrl+f` 展开时闪烁、布局跳动、聚焦困难 | #1491 #1861 #2424 #2624 #2924 | 已确认与高度抖动、详情区无边界、实时更新耦合在一起 | P1 | + +## 3. 分类 A:动态区重绘闪烁 + +### 3.1 用户反馈 + +- `#3144`:流式输出或 agent 执行时,滚动条每秒 10-30 次上下跳动 +- `#2748`:启动和视图切换时可见闪烁 +- `#1184`、`#1491`、`#3007`:通用“界面频闪” +- `#2903`:JetBrains 终端环境中闪屏 + +### 3.2 当前源码证据 + +已确认的事实: + +1. qwen-code 仍依赖 Ink 的动态区重绘模型 +2. `packages/cli/src/ui/utils/terminalRedrawOptimizer.ts` 的存在,本身就说明当前输出层仍在围绕 `eraseLines` 路径做补丁式缓解 +3. `packages/cli/src/ui/hooks/useGeminiStream.ts` 中 content/thought 流会持续更新 pending item,只是通过 `findLastSafeSplitPoint()` 将部分稳定内容提前挪进 history +4. shell 输出虽然已有 `OUTPUT_UPDATE_INTERVAL_MS = 1000` 节流,但 LLM 内容流和 thought 流仍没有统一的低频 flush 模型 + +### 3.3 Gemini CLI / Claude Code 对照 + +- **Gemini CLI** + - 有 `useFlickerDetector()`,把“render 高度超屏”当成事件记录 + - 用 `findLastSafeSplitPoint()` 缩小动态区 + - 在 alternate/fullscreen 路径把长输出放进 `ScrollableList` +- **Claude Code** + - `writeDiffToTerminal()` 先拼完整 buffer,再单次 `stdout.write()` + - 对同步输出有明确 runtime gating + - 接受某些场景必须 full reset,而不是假装所有帧都能优雅 diff + +### 3.4 可执行修复方案 + +**P0.1 观测先行** + +- 增加 `stdout_write_count` +- 增加 `stdout_bytes` +- 增加 `erase_lines_optimized_count` +- 增加 `clear_terminal_count` +- 增加 `flicker_frame_count` + +**P0.2 流式更新节流** + +- content stream 统一缓冲到 50-80ms flush +- thought stream 走同一套 flush 机制 +- tool call 开始、confirm prompt 展示、stream end/cancel 前强制 flush + +**P0.3 同步输出与单帧 write 合并** + +- 默认只在 allowlist + runtime probe 成功时启用 DECSET 2026 +- 如果当前每帧有多次 `stdout.write()`,先做帧内合并,再包 BSU/ESU +- tmux / SSH 嵌套默认保守关闭 + +**P1.4 长期路线** + +- 若 Phase 1 无法覆盖足够多场景,再评估 cursor-home/diff 路径 +- DECSTBM 继续保持在 Phase 3,不提前承诺 + +### 3.5 验收 + +- 同一长回答下 `stdout.write` 次数显著下降 +- WezTerm / kitty / iTerm2 中可见闪烁下降 +- tmux / SSH 未被默认开启的场景不出现行为退化 + +## 4. 分类 B:`refreshStatic()` 整屏闪烁 + +### 4.1 用户反馈 + +- `#1861`、`#2924`:展开 subagent 详情时闪烁 +- `#1491`:处理过程中按 `Ctrl-E` / `Ctrl-F` 闪烁 +- `#2748`:启动与切 view 闪烁 +- `#938`:设置页上下切换闪烁 + +### 4.2 当前源码证据 + +这里已经是**确认根因**,不是猜测: + +1. `packages/cli/src/ui/AppContainer.tsx` 的 `refreshStatic()` 直接执行 `stdout.write(ansiEscapes.clearTerminal)` +2. `packages/cli/src/ui/components/MainContent.tsx` 在 compact mode 合并 tool group 时,会因为 `` 不能替换旧内容而主动 `refreshStatic()` +3. `packages/cli/src/ui/layouts/DefaultAppLayout.tsx` 的注释已明确 `refreshStatic` 语义是“清屏 + remount history” + +### 4.3 Gemini CLI / Claude Code 对照 + +- Gemini CLI 也有 `refreshStatic()` / `clearTerminal`,说明这是主屏模式的已知弱点 +- Claude Code 的核心经验不是“永不清屏”,而是把可局部刷新的内容放在自管滚动区里,减少全局无效 + +### 4.4 可执行修复方案 + +**P0.1 拆分 API 语义** + +当前 `refreshStatic()` 同时承担了两件事: + +- 清空主屏 +- 强制 `` 重新挂载 + +应拆成两个动作: + +- `remountStaticHistory()`:只让静态区重新计算 +- `clearTerminalAndRemount()`:仅保留给 `/clear`、明确的全屏重置场景 + +**P0.2 main-screen 不再默认用 `clearTerminal` 处理非致命变化** + +优先改掉这些路径: + +- compact merge +- settings / active view 切换 +- 宽度稳定但内容结构变化不大的局部刷新 + +**P1.3 用有边界的详情面板替代“整块高度暴涨”** + +- subagent 详情 +- 长工具结果 +- 长 diff + +都应优先进入 bounded scroll container,而不是直接撑大主动态区。 + +### 4.5 验收 + +- `clear_terminal_count` 显著下降 +- resize、compact toggle、subagent expand 不再默认整屏清空 +- `/clear` 仍保持当前语义 + +## 5. 分类 C:窄屏重复输出 / 无限滚动 + +### 5.1 用户反馈 + +- `#2912`:终端窗口小于一定宽度或高度会重复输出文字 +- `#2972`:context 超过一定比例后遇到 `git commit` 交互,屏幕在顶部/底部之间来回滚 +- `#1591`:message duplication +- `#1778`:历史上对重复输出链路做了概念分析 + +### 5.2 当前源码证据 + +当前能确认的事实只有这些: + +1. `packages/core/src/services/shellExecutionService.ts` 在彩色 shell 路径里,每次 render 都会调用 `serializeTerminalToObject(headlessTerminal)`,重新序列化**当前可见 viewport** +2. `headlessTerminal.onScroll()` 会触发 `render()` +3. 当前 `serializeTerminalToObject()` 默认 `scrollOffset = 0`,说明“遗漏 scrollOffset 参数”已经不是当前源码层面的直接结论 +4. 当前比较逻辑使用 `JSON.stringify(output) !== JSON.stringify(finalOutput)`,这意味着窄屏重换行、viewport 变化、滚动事件都可能导致完整 viewport 被视为“整块新内容” + +因此,这一类问题目前最准确的说法是: + +- **症状确认** +- **当前存在明显高风险路径** +- **但不能把历史 issue 评论里的 one-line fix 直接当成今天的唯一根因** + +### 5.3 Gemini CLI / Claude Code 对照 + +- Gemini CLI 在 alternate/fullscreen 路径会把 ANSI 长输出放入 `ScrollableList` / `VirtualizedList`,不把整个可见 viewport 每次都塞回主 transcript +- Claude Code 把滚动和 mounted range 绑定到 `ScrollBox` / `useVirtualScroll()`,高频滚动不走 React 全量 state + +### 5.4 可执行修复方案 + +**P0.1 先补专门回归场景** + +至少新增这些自动化/半自动化用例: + +- 40 列以下窄终端 +- tmux 5-pane 等效宽度 +- 宽度缩小后继续流式输出 +- shell 进入 `git commit` / pager / interactive prompt + +**P0.2 分离“实时 viewport”与“归档到 transcript 的内容”** + +当前彩色 shell 路径更接近“持续重发当前屏幕状态”,而不是“只追加稳定输出”。应把这两者拆开: + +- 实时 viewport:只给嵌入 shell / bounded detail panel +- transcript 归档:低频快照或稳定块提交 + +**P1.3 main-screen 保守策略** + +在 main-screen 中: + +- 只显示尾部 N 行 +- 旧内容进入摘要或折叠块 +- 避免把窄屏换行后的完整 viewport 持续回灌到主历史流 + +### 5.5 验收 + +- 40 列和 tmux 多 pane 不再复现重复打印 +- `git commit`、interactive prompt、滚动回放时不再出现顶部/底部来回跳 +- 历史 issue `#1778` 的假设不会再被文档误写成现状根因 + +## 6. 分类 D:大输出不可读 / 长会话不可滚动 + +### 6.1 用户反馈 + +- `#1479`:长回答在 WebStorm 终端中读不全 +- `#2748` 评论:生成中无法一边继续输出一边向上滚动查看历史 +- `#2818`:只有 shell/MCP 有截断,其他工具没有统一预算 +- `#1008`:现有字数/行数截断阈值需要更系统的 golden range +- `#355`:早期 shell 输出被截断且排版错乱 + +### 6.2 当前源码证据 + +1. `packages/cli/src/ui/components/messages/ToolMessage.tsx` 的 plain text 路径把原始字符串先交给 React/Ink,再由 `MaxSizedBox` 做视觉裁剪 +2. 长工具结果在 `availableHeight` 存在时会强制关闭 markdown 路径,说明当前 markdown 渲染无法稳定服从高度约束 +3. `packages/cli/src/ui/components/MainContent.tsx` 仍然是 `` + pending 主路径,没有独立的长会话滚动容器 +4. `compact mode` 可以隐藏工具输出,但它是 coarse-grained 的会话模式,不等于细粒度的预算与滚动策略 + +### 6.3 Gemini CLI / Claude Code 对照 + +- Gemini CLI 已经在 `ToolResultDisplay` 中为普通模式使用 `SlicingMaxSizedBox`,先做**字符/行切片,再交给 `MaxSizedBox`** +- Claude Code 则进一步把长会话放进 `ScrollBox` / `useVirtualScroll()` 体系 + +### 6.4 当前维护者方向信号 + +`qwen-code#2748` 的维护者评论指向 PR `#3013`。截至 **2026-04-22**: + +- `#3013` 仍是 **OPEN** +- reviewDecision 为 **CHANGES_REQUESTED** +- PR 内部已经把问题拆成三阶段: + 1. `SlicingMaxSizedBox` + 2. `useStableHeight` + 3. `MAX_TOOL_OUTPUT_LINES` + +这说明维护方向已经与本文件基本一致,但 review 也暴露了两个关键约束: + +1. 不能为了防闪烁直接移除 markdown 呈现 +2. 预切片之后,hidden lines 统计和软换行仍需严谨处理 + +### 6.5 可执行修复方案 + +**P0.1 预裁剪优先于视觉裁剪** + +- string/plain text 工具输出:引入 `SlicingMaxSizedBox` +- ANSI 输出:同样在进入 React 树前先做 logical line slice +- 避免 “500 行 -> Ink layout 500 行 -> 只显示 15 行” + +**P0.2 通用 tool budgeting** + +在 scheduler 层统一接入 `truncateToolOutput()`: + +- shell / MCP / grep / glob / read_file / edit / declarative tools 一视同仁 +- 将“模型可见预算”和“用户界面可见预算”区分开 + +**P1.3 为长详情建立单独容器** + +- main transcript 只放摘要 +- 详细输出放进 bounded scroll container、alternate panel 或 fullscreen detail view +- 支持“生成中继续向上滚动查看历史” + +### 6.6 验收 + +- 大工具输出不会让 Ink 每次重排全部内容 +- Markdown-heavy 工具结果仍能保持可读格式 +- 生成中可以滚动回看 +- 上下文增长速度因统一预算而下降 + +## 7. 分类 E:工具 / 子 agent 详情展开闪烁 + +### 7.1 用户反馈 + +- `#1491`、`#1861`、`#2924`:展开 subagent 时闪烁 +- `#2424`:希望看到完整 task/subagent 输出,而不只是工具调用日志 +- `#2624`:希望工具输出默认折叠并可展开 + +### 7.2 当前源码证据 + +1. `packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx` 的 `compact / default / verbose` 模式会显著改变可见高度 +2. 同一组件同时承载: + - task prompt + - tool call list + - pending confirmation + - 执行总结 +3. `ToolMessage.tsx` 和 `AgentExecutionDisplay.tsx` 的更新节奏与主消息流耦合,展开细节时容易把动态区整体撑大 + +### 7.3 Gemini CLI / Claude Code 对照 + +- Gemini CLI 倾向于把长工具结果放入专门滚动容器 +- Claude Code 把长内容滚动和高频输入从 React state 中抽离 + +### 7.4 可执行修复方案 + +**P1.1 稳定高度** + +- 为 tool/subagent 详情加 `useStableHeight` 一类的吸收层 +- 小幅高度波动不立即改变可见行数 + +**P1.2 详情区边界化** + +- `ctrl+e` / `ctrl+f` 不再直接把主流式区域整体撑大 +- 使用 bounded panel、modal、alternate/fullscreen details 之一 + +**P1.3 更新时钟解耦** + +- assistant 文本流 +- tool progress +- subagent detail + +使用不同节流频率,避免“每个 progress tick 都引发整个详情树抖动”。 + +### 7.5 验收 + +- `ctrl+e` / `ctrl+f` 不再导致整屏闪烁 +- pending confirmation / focus lock 不退化 +- 工具输出默认折叠与 force expand 规则共存 + +## 8. 推荐实施顺序 + +1. **P0** + - 观测与 issue-backed 回归样例 + - content/thought 节流 + - `refreshStatic()` 语义拆分 + - 预裁剪大工具输出 + - 窄屏/interactive shell 回归 harness +2. **P1** + - stable height + - bounded detail panel + - 通用 tool budgeting + - main-screen 与 alternate/fullscreen 的渲染分层 +3. **P2** + - 虚拟滚动 + - output diff / cursor-home + - 更深的终端协议/scroll region 优化 + +## 9. 三轮无方向自审结论 + +### Pass 1:事实核对 + +- 已把 `#1778` 的 one-line fix 降级为历史信号 +- 已把 `refreshStatic()` 与 Ink `eraseLines` 明确拆开 +- 已把 `#3013` 标注为 2026-04-22 时仍未合入 + +### Pass 2:边界条件核对 + +- 不再把 tmux 中的 synchronized output 写成默认安全 +- 不把预切片误写成 markdown / ANSI / diff 的通解 +- 不把“折叠 UI 输出”误写成“模型上下文预算已解决” + +### Pass 3:实施可执行性核对 + +- 每一类问题都给出可落地的 P0/P1 修复动作 +- 每一类都带了验收条件 +- 与 `02-screen-flickering.md`、`03-rendering-extensibility.md`、`06-implementation-rollout-checklist.md` 的职责不冲突 diff --git a/docs/design/tui-optimization/08-execution-plan-and-test-matrix.md b/docs/design/tui-optimization/08-execution-plan-and-test-matrix.md new file mode 100644 index 000000000..58402ee64 --- /dev/null +++ b/docs/design/tui-optimization/08-execution-plan-and-test-matrix.md @@ -0,0 +1,318 @@ +# TUI 优化执行计划与测试矩阵 + +> 本文档把 `00-07` 的设计与调研进一步压缩成“可以直接排期和拆任务”的执行稿。 +> 校准时间点:2026-04-22。若 issue / PR / 上游源码继续变化,需要重新核对后再执行。 + +## 1. 目标 + +本执行稿只覆盖当前最值得落地的两层: + +1. **P0:先止血** + - 动态区闪烁 + - `refreshStatic()` 整屏闪烁 + - 大工具输出导致的高 layout 成本 + - 窄屏 / interactive shell 回归缺失 +2. **P1:把主风险点结构化** + - bounded detail panel + - 通用 tool budgeting + - main-screen / alternate-fullscreen 分层 + - 长会话滚动的前置基础 + +不在本阶段承诺: + +- 自研 diff renderer +- DECSTBM scroll region +- 全量虚拟滚动切换 +- parser 全量替换上线 + +## 2. 任务切片总览 + +| Slice | 目标 | 主要文件 | 风险 | 建议周期 | +| --- | --- | --- | --- | --- | +| S1 | 建立可观测性 | `terminalRedrawOptimizer.ts`, `startupProfiler.ts` | 低 | 1-2 天 | +| S2 | 降低内容流重绘频率 | `useGeminiStream.ts` | 低 | 1-2 天 | +| S3 | 拆分 `refreshStatic()` 语义 | `AppContainer.tsx`, `MainContent.tsx`, `DefaultAppLayout.tsx` | 中 | 2-3 天 | +| S4 | 大工具输出 pre-render slicing | `ToolMessage.tsx`, `AnsiOutput.tsx`, shared slicing component | 中 | 2-4 天 | +| S5 | 通用 tool budgeting | `coreToolScheduler.ts`, truncation util 相关路径 | 中 | 2-3 天 | +| S6 | 窄屏 / interactive shell 专项回归与修复 | `shellExecutionService.ts`, `terminalSerializer.ts`, CLI tests | 中高 | 3-5 天 | +| S7 | bounded detail panel + stable height | tool/subagent 相关组件 | 中高 | 3-5 天 | + +## 3. Slice S1:建立可观测性 + +### 3.1 目标 + +在不改变产品行为的前提下,拿到后续所有优化都要依赖的指标。 + +### 3.2 文件落点 + +- `packages/cli/src/ui/utils/terminalRedrawOptimizer.ts` +- `packages/cli/src/utils/startupProfiler.ts` +- 如需 UI 展示或 debug dump,可补充: + - `packages/cli/src/gemini.tsx` + - `packages/cli/src/ui/AppContainer.tsx` + +### 3.3 具体任务 + +1. 为输出层增加 counters + - `stdout_write_count` + - `stdout_bytes` + - `clear_terminal_count` + - `erase_lines_optimized_count` + - `bsu_frame_count` + - `esu_frame_count` +2. 为启动链路增加 checkpoint + - `first_paint` + - `input_enabled` + - `config_initialize_start` + - `config_initialize_end` + - `gemini_tools_updated` +3. 明确 profile 输出是否运行在 sandbox child process + +### 3.4 测试与验收 + +- 单测: + - `packages/cli/src/ui/utils/terminalRedrawOptimizer.test.ts` + - profiler 相关测试若已有则补齐;若没有至少补 smoke test +- 验收: + - 指标开启前后不改变可见行为 + - `bsu_frame_count === esu_frame_count` + - screen reader 路径不被误安装 + +## 4. Slice S2:流式内容节流 + +### 4.1 目标 + +把 content / thought 流从“几乎每个 chunk 都重绘”变成“稳定低频 flush + 关键节点即时刷新”。 + +### 4.2 文件落点 + +- `packages/cli/src/ui/hooks/useGeminiStream.ts` + +### 4.3 具体任务 + +1. 为 content stream 增加 buffer + timer +2. 为 thought stream 使用同一 flush 模型 +3. 这些场景必须强制 flush: + - stream end + - user cancel + - tool call start + - confirm dialog render 前 +4. 保持现有 `findLastSafeSplitPoint()` 逻辑继续工作 + +### 4.4 测试与验收 + +- 单测: + - `packages/cli/src/ui/hooks/useGeminiStream.test.ts` +- 验收: + - flush 间隔内 UI 不丢内容 + - 取消与结束时不会遗漏尾部 chunk + - thought 与 content 不会互相覆盖 pending item + +## 5. Slice S3:拆分 `refreshStatic()` 语义 + +### 5.1 目标 + +把“静态区 remount”和“整屏 clear + remount”彻底拆开,避免大量非致命变化也整屏闪。 + +### 5.2 文件落点 + +- `packages/cli/src/ui/AppContainer.tsx` +- `packages/cli/src/ui/components/MainContent.tsx` +- `packages/cli/src/ui/layouts/DefaultAppLayout.tsx` +- 可能波及: + - `packages/cli/src/ui/components/SettingsDialog.tsx` + - `/clear` 所在命令处理路径 + +### 5.3 具体任务 + +1. 引入两个明确动作 + - `remountStaticHistory()` + - `clearTerminalAndRemount()` +2. 检查当前触发源并逐个改道 + - compact merge + - settings toggle + - active view switch + - resize + - manual clear +3. resize 策略收紧 + - 高度变化默认不整屏 clear + - 宽度变化仅在必须重排历史时升级为清屏 + +### 5.4 测试与验收 + +- 单测: + - `packages/cli/src/ui/components/MainContent.test.tsx` 若无则建议补 + - `packages/cli/src/ui/AppContainer.test.tsx` +- 验收: + - `clear_terminal_count` 显著下降 + - `/clear` 仍保留旧语义 + - compact merge 不再默认整屏闪 + +## 6. Slice S4:大工具输出 pre-render slicing + +### 6.1 目标 + +避免大工具输出在进入 React/Ink 树之后才被裁剪。 + +### 6.2 文件落点 + +- `packages/cli/src/ui/components/messages/ToolMessage.tsx` +- `packages/cli/src/ui/components/AnsiOutput.tsx` +- 新增共享组件建议: + - `packages/cli/src/ui/components/shared/SlicingMaxSizedBox.tsx` + +### 6.3 具体任务 + +1. plain text 工具输出先做字符保护与 logical line slice +2. ANSI 输出进入 React 树前先按 logical line slice +3. `MaxSizedBox` 降级成 width limiter + safety net +4. 保留 markdown 路径,不允许因防闪烁直接退化成纯文本 + +### 6.4 关键约束 + +- 不得把 pre-slice hidden line 和 soft-wrap hidden line 双重计数 +- alternate/fullscreen 模式下默认不应强裁为主屏摘要语义 +- diff 输出单独维持自己的高度策略 + +### 6.5 测试与验收 + +- 单测: + - `packages/cli/src/ui/components/messages/ToolMessage.test.tsx` + - `packages/cli/src/ui/components/shared/MaxSizedBox.test.tsx` + - 新增 `SlicingMaxSizedBox.test.tsx` +- 场景: + - `npm install` + - `git log` + - 5000 行纯文本 + - markdown-heavy 工具结果 + +## 7. Slice S5:通用 tool budgeting + +### 7.1 目标 + +把当前零散分布的 tool output 截断整合成统一入口,区分“模型预算”和“UI 预算”。 + +### 7.2 文件落点 + +- `packages/core/src/core/coreToolScheduler.ts` +- `packages/core/src/utils/truncation.ts` +- 已有 shell / MCP 截断接入点 + +### 7.3 具体任务 + +1. 在 scheduler 层统一检查 string `llmContent` +2. 超阈值时: + - 保存完整结果 + - 返回 head/tail preview + - 附 full output 引用 +3. 保持已有 shell / MCP 截断逻辑可兼容通过 + +### 7.4 测试与验收 + +- 单测: + - `packages/core` 下 scheduler / truncation 相关测试 +- 验收: + - `grep` / `glob` / `read_file` / `edit` 都受统一 budget 保护 + - 非字符串结果不受误伤 + +## 8. Slice S6:窄屏 / interactive shell 专项 + +### 8.1 目标 + +把当前最危险但根因仍复合的窄屏问题先“可复现、可回归、可收敛”。 + +### 8.2 文件落点 + +- `packages/core/src/services/shellExecutionService.ts` +- `packages/core/src/utils/terminalSerializer.ts` +- interactive / integration tests + +### 8.3 具体任务 + +1. 增加专门回归场景 + - <= 40 列窄终端 + - tmux 多 pane 等效宽度 + - interactive shell(如 `git commit`) + - 宽度缩小后继续输出 +2. 审查当前彩色 shell 路径 + - `serializeTerminalToObject(headlessTerminal)` 的更新频率 + - `headlessTerminal.onScroll()` 与 render 的耦合 + - `JSON.stringify` 整块比较的副作用 +3. 先把 live viewport 和 transcript archival 语义区分开 + +### 8.4 测试与验收 + +- 优先使用 integration / interactive tests +- 验收: + - 窄屏不再重复刷旧行 + - interactive prompt 不再顶/底来回跳 + - 文档中不再把 `#1778` 的历史猜测误写成现状根因 + +## 9. Slice S7:bounded detail panel + stable height + +### 9.1 目标 + +解决 `ctrl+e` / `ctrl+f` 展开 subagent/tool 详情时的高度暴涨和整屏闪烁。 + +### 9.2 文件落点 + +- `packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx` +- `packages/cli/src/ui/components/messages/ToolMessage.tsx` +- `packages/cli/src/ui/components/messages/ToolGroupMessage.tsx` +- 如采用 hook: + - `packages/cli/src/ui/hooks/useStableHeight.ts` + +### 9.3 具体任务 + +1. 对 detail 区采用稳定高度吸收 +2. 将 `compact/default/verbose` 展开从“撑大主流式区”改成“进入有边界的 detail 容器” +3. assistant content / tool progress / subagent progress 用不同刷新节奏 +4. 保留 force-expand、pending confirmation、focus lock 语义 + +### 9.4 测试与验收 + +- 单测: + - `AgentExecutionDisplay.test.tsx` + - `ToolGroupMessage.test.tsx` +- 验收: + - `ctrl+e` / `ctrl+f` 不再导致整屏闪烁 + - confirmation / keyboard focus 行为不退化 + +## 10. 建议的提交顺序 + +建议不要把这些 slice 混成一个超大 PR。更稳的顺序是: + +1. PR-A:S1 观测 +2. PR-B:S2 流式节流 +3. PR-C:S3 `refreshStatic()` 语义拆分 +4. PR-D:S4 大工具输出 pre-slicing +5. PR-E:S5 通用 tool budgeting +6. PR-F:S6 窄屏专项 +7. PR-G:S7 bounded detail panel / stable height + +这样做的好处是: + +- 每个 PR 都有独立收益 +- 回滚粒度更小 +- 便于把 issue 与 PR 一一对应 + +## 11. 代码审查重点 + +每个 slice 提交前,review 重点建议固定看这些: + +1. **是否把历史猜测写成当前事实** +2. **是否把主屏语义和 fullscreen/alternate 语义混淆** +3. **是否为了性能牺牲了 markdown / confirmation / focus 等产品语义** +4. **是否只优化了冷启动,却漏掉运行期路径** +5. **是否新增了无法回退的高风险逻辑** + +## 12. 最终退出标准 + +当下面这些都满足时,才可以说这套 TUI 优化进入“可默认开启”的阶段: + +- main-screen 流式输出可见闪烁明显下降 +- `refreshStatic()` 不再成为高频整屏闪烁源 +- 大工具输出不会再让 Ink 每次 layout 全量内容 +- 窄屏与 interactive shell 有稳定回归样例 +- `ctrl+e` / `ctrl+f` 展开不再造成明显闪屏 +- tool budgeting 同时保护模型上下文和 UI 渲染