diff --git a/docs/design/tui-optimization/00-overview.md b/docs/design/tui-optimization/00-overview.md index 6b5a08f0e..366a110a5 100644 --- a/docs/design/tui-optimization/00-overview.md +++ b/docs/design/tui-optimization/00-overview.md @@ -1,6 +1,6 @@ # TUI 优化方案总览 -> 本文档是 qwen-code TUI 优化的整体方案概览,详细设计分别见三个子文档。 +> 本文档是 qwen-code TUI 优化的整体方案概览。除三份实施设计外,现已补充 Gemini CLI 与 Claude Code 的独立源码调研文档,用来校准方案优先级和技术路线。 ## 1. 背景与动机 @@ -36,25 +36,39 @@ Entry (gemini.tsx) | Markdown | 逐行正则解析器 | `packages/cli/src/ui/utils/MarkdownDisplay.tsx` | | 代码高亮 | lowlight (基于 highlight.js) | `packages/cli/src/ui/utils/CodeColorizer.tsx` | | 防闪烁 | stdout 拦截器,折叠重复 ANSI 序列 | `packages/cli/src/ui/utils/terminalRedrawOptimizer.ts` | -| 主题 | ThemeManager 单例,15+ 内置主题 | `packages/cli/src/ui/themes/theme-manager.ts` | -| MCP | 跨 Server 并行发现;整体仍等待全部完成,默认 10 分钟超时;工具注册和 Gemini tools 刷新需要拆开设计 | `packages/core/src/tools/mcp-client-manager.ts` | -| 启动分析 | 环境变量开启的 checkpoint 记录器;当前主要覆盖 render 前阶段 | `packages/cli/src/utils/startupProfiler.ts` | +| 主题 | ThemeManager 单例,15 个内置主题(另有 no-color fallback) | `packages/cli/src/ui/themes/theme-manager.ts` | +| MCP | 跨 Server 并行发现;已有单 server 重发现与增量发现基础,但启动 wiring、运行期 refresh 路径和 Gemini tools 刷新仍需补齐 | `packages/core/src/tools/mcp-client-manager.ts` | +| 启动分析 | 环境变量开启的 checkpoint 记录器;当前仅在 sandbox child process 中生效,且主要覆盖 render 前阶段 | `packages/cli/src/utils/startupProfiler.ts` | -### 2.2 竞品分析:Claude Code +### 2.2 外部源码调研结论 -Claude Code 使用**自研的 Ink 深度定制版本**(非 npm 库),包含以下关键优化: +本轮补充调研覆盖两个代码库: -| 能力 | Claude Code 实现 | qwen-code 现状 | -| -------- | ------------------------------------- | ------------------ | -| 渲染缓冲 | 前后双缓冲 + diff patch | 无,每帧全量输出 | -| 同步输出 | CSI BSU/ESU 原子帧更新 | 无 | -| 硬件滚动 | DECSTBM 滚动区域 | 无 | -| 布局检测 | 布局稳定时窄范围 diff,变化时全量重绘 | 无 diff,始终全量 | -| 样式池化 | StylePool 整数 ID 内化 + 转换缓存 | 无,每次重新计算 | -| Markdown | marked 库 + LRU 令牌缓存(500条) | 自定义正则,无缓存 | -| MCP 启动 | 提前并行启动 + Promise.race 超时 | UI 渲染后初始化,跨 Server 并行但整体等待 | +- Gemini CLI:`/Users/gawain/Documents/codebase/opensource/gemini-cli` +- Claude Code:`/Users/gawain/Documents/codebase/opensource/claude-code` -### 2.3 社区反馈汇总 +两者给 qwen-code 的启发点并不相同: + +| 维度 | Gemini CLI | Claude Code | 对 qwen-code 的意义 | +| --- | --- | --- | --- | +| 启动策略 | 入口动态导入交互 UI;render 前后初始化分层 | 顶层并行预取;feature-gated require;deferred prefetch | 先瘦冷启动,再拆关键/非关键初始化 | +| 渲染模式 | alternate buffer / terminal buffer / render process / incrementalRendering | 自定义 Ink + screen buffer + diff pipeline | 短期优先做“模式化渲染”,长期再评估自研渲染内核 | +| 防闪烁 | `findLastSafeSplitPoint()` + `useFlickerDetector()` + ScrollableList | synchronized output + diff + DECSTBM + output buffer | Phase 1 借鉴 Gemini 的中层优化;Phase 3 参考 Claude 的底层路线 | +| 长会话滚动 | `ScrollableList` / `VirtualizedList` / `StaticRender` | `ScrollBox` / `useVirtualScroll` / `VirtualMessageList` | qwen-code 需要正式设计滚动/虚拟化层,而不是继续把它藏在 `MainContent` 里 | +| Markdown | 仍是自定义正则解析器 | `marked` + token cache + streaming stable prefix | parser 迁移应更多参考 Claude,不应把 Gemini 当 parser 终局 | +| 代码高亮 | 同步 `lowlight(common)` | Suspense + fallback +宽度测量 | qwen-code 需要“同步基线 + 异步增强”而非直接 await grammar | +| MCP 生命周期 | UI 可先起来,MCP 状态事件化展示 | 批量状态更新、list-changed 增量刷新、远端重连 | MCP 设计要从“启动 discover”升级为“运行期生命周期管理” | + +### 2.3 新增调研文档 + +为避免把竞品经验压缩成几行摘要,现已将外部源码分析独立成两份文档: + +| 文档 | 说明 | +| --- | --- | +| [04-gemini-cli-research.md](./04-gemini-cli-research.md) | Gemini CLI 的启动、渲染模式、防闪烁、滚动、Markdown、MCP 调研 | +| [05-claude-code-research.md](./05-claude-code-research.md) | Claude Code 的自定义 Ink、diff 输出、虚拟滚动、Markdown、MCP 生命周期调研 | + +### 2.4 社区反馈汇总 | 问题类别 | 代表性 Issues | 严重程度 | | ---------- | ----------------------------------------------- | -------- | @@ -65,7 +79,7 @@ Claude Code 使用**自研的 Ink 深度定制版本**(非 npm 库),包含 | 窄屏问题 | claude-code#13504, #18493, #5408 | 中 | | LaTeX 支持 | claude-code#21433 | 低 | -## 3. 三大工作流概览 +## 3. 核心工作流概览 | 工作流 | 核心问题 | 关键指标 | 依赖关系 | | -------------- | -------------------------------------- | ------------------------------ | -------------------------- | @@ -76,6 +90,8 @@ Claude Code 使用**自研的 Ink 深度定制版本**(非 npm 库),包含 **执行顺序**:观测基线 -> 屏幕闪烁低风险治理 -> 启动/MCP 渐进可用 -> 渲染缓存与扩展。MCP 与渲染可并行推进,但必须共享同一套指标口径。 +**实施约束**:从这一版开始,所有落地工作默认都应同时参考 [06-implementation-rollout-checklist.md](./06-implementation-rollout-checklist.md)。如果某项优化没有满足对应的验收清单、灰度顺序和回滚条件,就不应直接进入默认开启阶段。 + ## 4. 分阶段实施计划 ### Phase 0:观测基线(第 1 周) @@ -102,6 +118,7 @@ Claude Code 使用**自研的 Ink 深度定制版本**(非 npm 库),包含 | 周次 | 变更 | 工作流 | 风险 | | ---- | ------------------------------------ | ------ | ---- | | 6-7 | 渐进式 MCP 可用性 + Gemini tools debounce 刷新 | 性能 | 中 | +| 6-7 | 运行期 MCP refresh/reload 路径增量化(避免 `restartMcpServers()` 全量重启) | 性能 | 中 | | 7 | 动态内容高度阈值优化 + 现有渐进提升增强 | 闪烁 | 中 | | 7-8 | 切换到 marked 解析器(特性开关) | 渲染 | 中 | | 8-9 | 智能 refreshStatic()(定向更新) | 闪烁 | 中 | @@ -125,18 +142,37 @@ Claude Code 使用**自研的 Ink 深度定制版本**(非 npm 库),包含 - **解析器**:特性开关控制,旧解析器作为过渡期回退 - **MCP**:所有 Server 快速响应时行为等价;慢 Server 不再阻塞快 Server,但工具声明只保证从下一次模型请求开始生效 -## 6. 验证策略 +## 6. 实施门禁 -1. **自动化基准测试**:启动分段耗时、渲染时间、stdout writes/sec、stdout 字节/帧 +除各子文档自己的验证章节外,还应统一遵守以下门禁: + +1. **先补观测,再改行为** + 没有 `first_paint`、`input_enabled`、`mcp_server_ready`、输出层 counters 的变更,不应宣称性能收益。 + +2. **先加开关,再做灰度** + 同步输出、渐进式 MCP、parser 切换、虚拟滚动都应先具备独立回退能力。 + +3. **先做主路径,后做高风险路径** + 冷启动并行化、流式节流、token cache 应先于 DECSTBM、自研 diff renderer、全量虚拟滚动。 + +4. **运行期路径必须和启动路径一起设计** + MCP 如果只优化首次启动,而保留 runtime refresh 的全量重启,方案仍然不完整。 + +## 7. 验证策略 + +1. **自动化基准测试**:启动分段耗时、渲染时间、stdout writes/sec、stdout 字节/帧;启动 profile 需明确在 sandbox child process 中采集 2. **多终端视觉测试**:iTerm2、Terminal.app、WezTerm、kitty、Windows Terminal、tmux 3. **回归检测**:滚动启动 profile 对比;MCP 首工具/全工具可用时间对比 4. **边界场景**:窄终端 (< 40 列)、超长输出 (5000+ 行)、CJK 内容、tmux/SSH 5. **特性开关**:Phase 2+ 所有变更可安全回滚 -## 7. 子文档索引 +## 8. 子文档索引 | 文档 | 说明 | | ---------------------------------------------------------------- | --------------------------- | | [01-performance.md](./01-performance.md) | 启动性能与 MCP 优化详细设计 | | [02-screen-flickering.md](./02-screen-flickering.md) | 屏幕闪烁问题分析与解决方案 | | [03-rendering-extensibility.md](./03-rendering-extensibility.md) | 渲染性能与可扩展性设计 | +| [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) | 实施门禁、验收、灰度与回滚清单 | diff --git a/docs/design/tui-optimization/01-performance.md b/docs/design/tui-optimization/01-performance.md index 9a9f2209e..f1e5cdd81 100644 --- a/docs/design/tui-optimization/01-performance.md +++ b/docs/design/tui-optimization/01-performance.md @@ -1,35 +1,35 @@ # TUI 优化:启动性能与 MCP -> 详细设计文档 1/3 — 解决启动缓慢问题,尤其是配置了 MCP Server 的场景。 +> 详细设计文档 — 解决启动缓慢问题,尤其是配置了 MCP Server 的场景。 ## 1. 问题分析 ### 1.1 启动流程现状 -启动入口位于 `packages/cli/src/gemini.tsx` 的 `main()` 函数(第 290 行),执行一个包含多段串行等待的初始化管线: +启动入口位于 `packages/cli/src/gemini.tsx` 的 `main()` 函数,执行一个包含多段串行等待的初始化管线: ``` -T0: profileCheckpoint('main_entry') ← 第 291 行 +T0: profileCheckpoint('main_entry') │ - ├─ loadSettings() [同步, 读取 4-5 个 JSON] ← 第 293 行 - ├─ cleanupCheckpoints() [异步, 等待完成] ← 第 294 行 - ├─ parseArguments() [异步, yargs 解析] ← 第 297 行 - ├─ dns.setDefaultResultOrder() ← 第 310 行 - ├─ themeManager.loadCustomThemes() [同步] ← 第 315 行 + ├─ loadSettings() [同步, 读取 4-5 个 JSON] + ├─ cleanupCheckpoints() [异步, 等待完成] + ├─ parseArguments() [异步, yargs 解析] + ├─ dns.setDefaultResultOrder() + ├─ themeManager.loadCustomThemes() [同步] │ - ├─ Sandbox 检查 + 可能的进程重启 ← 第 328-415 行 + ├─ Sandbox 检查 + 可能的进程重启 │ ├─ loadSandboxConfig() [异步, 文件 I/O] │ ├─ loadCliConfig() [异步, 仅用于沙箱场景] │ ├─ validateAuth() [异步, 可能触发网络请求] │ └─ start_sandbox() 或 relaunchAppInChildProcess() │ - ├─ loadCliConfig() [异步, 合并所有配置源] ← 第 445 行 - ├─ initializeApp() [异步: i18n + auth + IDE] ← 第 507 行 - ├─ 收集启动警告 ← 第 518-535 行 - ├─ Kitty 协议检测 [异步] ← 第 543 行 - ├─ startInteractiveUI() [渲染 React 树] ← 第 544 行 + ├─ loadCliConfig() [异步, 合并所有配置源] + ├─ initializeApp() [异步: i18n + auth + IDE] + ├─ 收集启动警告 + ├─ Kitty 协议检测 [异步] + ├─ startInteractiveUI() [渲染 React 树] │ - └─ config.initialize() [UI 渲染后, MCP 发现在此] ← config.ts 第 872 行 + └─ AppContainer mount 后 effect 中调用 `config.initialize()` ├─ FileDiscoveryService 初始化 ├─ GitService 初始化 ├─ PromptRegistry 初始化 @@ -40,7 +40,7 @@ T0: profileCheckpoint('main_entry') ← 第 291 行 ### 1.2 各阶段耗时分析 -当前启动分析器(`packages/cli/src/utils/startupProfiler.ts`)只记录到 UI render 前后的粗粒度 checkpoint;交互式模式下 `config.initialize()` 是在 `AppContainer` mount 后的 effect 中执行,现有 profile 文件并不会直接覆盖这段耗时。因此下表是**源码路径推导 + 需补充 instrumentation 验证的初始估计**,不能作为最终性能基线。 +当前启动分析器(`packages/cli/src/utils/startupProfiler.ts`)只记录到 UI render 前后的粗粒度 checkpoint;交互式模式下 `config.initialize()` 是在 `AppContainer` mount 后的 effect 中执行,现有 profile 文件并不会直接覆盖这段耗时。另外,当前 profiler 仅在 `QWEN_CODE_PROFILE_STARTUP=1` 且运行于 sandbox child process 时启用;默认本地开发命令如果不经过该路径,可能不会产出 profile。因此下表是**源码路径推导 + 需补充 instrumentation 验证的初始估计**,不能作为最终性能基线。 | 阶段 | 估计耗时 | I/O 操作 | 瓶颈类型 | | --------------------------------- | ---------- | ------------------ | ------------ | @@ -68,7 +68,7 @@ T0: profileCheckpoint('main_entry') ← 第 291 行 1. Settings 加载使用 `fs.readFileSync` 串行读取多个文件(`packages/cli/src/config/settings.ts`) 2. `initializeApp()` 依赖 `loadCliConfig()` 产出的 `config`,不能整体并行;可优化的是 i18n 与 `loadCliConfig()` 并行,以及 config 就绪后 auth、startup warnings、Kitty 检测等独立步骤并行 -3. MCP 发现跨 Server 并行(`Promise.all`),但 `discoverAllMcpTools()` 仍等待所有 Server settle 后才把 discovery state 标记为完成;UI 只能看到整体完成/失败语义,缺少首工具和逐 Server 可用指标 +3. MCP 发现跨 Server 并行(`Promise.all`),但 `discoverAllMcpTools()` 仍等待所有 Server settle 后才把 discovery state 标记为完成;当前 UI 已能显示 `connected/total` 的连接进度,但仍缺少首工具注册、逐 Server ready、Gemini tools 已刷新等更贴近“可用性”的指标 4. `McpClient.discover()` 内部会在单个 Server discover 完成时注册工具,并非“所有 Server 完成后才统一注册”;真正缺口是 ToolRegistry 的渐进刷新语义、Gemini tools declaration 的 debounce 更新,以及慢 Server 对整体完成状态的拖延 5. MCP 默认超时 10 分钟(`MCP_DEFAULT_TIMEOUT_MSEC = 10 * 60 * 1000`),需要区分“发现超时”和“工具调用超时”,不能简单把所有 MCP timeout 全局缩短到 30 秒 @@ -112,15 +112,37 @@ async discoverAllMcpTools(cliConfig: Config): Promise { - 单个 Server 的工具会在 `client.discover(cliConfig)` 完成时注册,但 ToolRegistry 没有对外暴露稳定的“server ready / tools changed”事件语义 - `GeminiClient.setTools()` 只在 chat 初始化或显式调用时刷新 tools declaration;后续 MCP 工具动态加入后,如果不额外调用,模型不会自动拿到新工具 - `ToolRegistry.discoverMcpTools()` 当前会先清理 discovered tools/prompts,不适合直接作为 fire-and-forget 的渐进发现入口 +- 源码中已存在 `discoverAllMcpToolsIncremental()` 与 `discoverToolsForServer()` 这两块增量基础设施,但前者尚未接入启动主路径,且还不检测 server config 变化 +- 运行期 refresh 仍会走 `ExtensionManager.refreshMemory()/refreshTools()` → `restartMcpServers()` 的全量重启路径,因此如果只优化冷启动,插件/技能刷新时依然会回退到全量 rediscovery - 默认超时 10 分钟对 discovery 过长,但对长耗时 tool call 可能合理,必须拆开配置和默认值 - 发现流程在 `config.initialize()` → `createToolRegistry()` → `registry.discoverAllTools()` 调用链中被前置初始化步骤阻塞 +### 1.4 Gemini CLI / Claude Code 调研结论 + +外部源码调研补充了三条对本设计非常关键的事实: + +1. **Gemini CLI 已经证明“UI 先起来、MCP 后补齐”是可产品化的** + `packages/cli/src/gemini.tsx` 会延迟加载交互 UI,`packages/cli/src/ui/AppContainer.tsx` 中的 `config.initialize()` 则放到 mount 后执行;同时 UI 通过 `useMcpStatus()` 和明确提示文案告诉用户 MCP 仍在初始化,prompt 会排队。对 qwen-code 的意义是:渐进式 MCP 可用性不只是内部时序优化,而是一个明确的用户体验模型。 + +2. **Claude Code 把冷启动优化前移到了模块求值阶段** + `src/main.tsx` 在大部分 imports 之前就启动 `startMdmRawRead()`、`startKeychainPrefetch()` 等后台工作,并大量使用 feature-gated `require()` 把冷路径模块排除在首屏之外。这说明 qwen-code 的“产物体积优化”不应只放在远期,应把“入口延迟加载 + 冷路径裁剪”前移到 P0/P1。 + +3. **MCP 生命周期不应只看首次 discover** + Claude 的 `useManageMCPConnections.ts` 不只处理首连,还处理 16ms 批量状态更新、`ToolListChanged` / `PromptListChanged` / `ResourceListChanged`、远端 transport 自动重连。对 qwen-code 的意义是:MCP 设计必须从“启动 discover”扩展为“运行期持续变更管理”。 + ## 2. 解决方案 ### 2.0 [P0] 启动观测基线先行 **目标**:先把启动过程拆成可验证的指标,再执行并行化和 MCP 渐进加载,避免用 render 前 checkpoint 推断 render 后瓶颈。 +**实现前提校准**: + +- 当前 profiler 仅在 `QWEN_CODE_PROFILE_STARTUP=1` 且 `SANDBOX` child process 中启用 +- 如果要让这套文档成为日常可执行的基线方案,需要二选一: + 1. 保持现状,但把所有基准采集都放到 sandbox child process 中执行 + 2. 扩展 profiler,使其支持受控的非 sandbox/dev 采集模式,并避免父子进程重复记录 + **新增 checkpoint/event**: | 指标 | 触发位置 | 用途 | @@ -175,6 +197,8 @@ async discoverAllMcpTools(cliConfig: Config): Promise { ```bash QWEN_CODE_PROFILE_STARTUP=1 qwen-code --prompt "test" +# 注意:当前实现要求 profile 运行在 sandbox child process;如果本地命令路径未进入 sandbox, +# 需要先扩展 profiler 或使用能确保进入 child process 的测试方式 # 对比 after_load_settings 阶段耗时 ``` @@ -223,6 +247,49 @@ const [_auth, startupWarnings, userWarnings, _kitty] = await Promise.all([ **预期收益**:`before_render` checkpoint 耗时减少 200-400ms(主要来自 i18n 与 config 并行 + auth 与警告/检测并行)。 +### 2.2A [P0] 入口延迟加载与冷路径裁剪 + +**动机**:Gemini CLI 在入口动态导入 `interactiveCli.js`,Claude Code 则通过顶层并行预取 + feature-gated require 把大量非关键模块留在冷路径之外。qwen-code 当前主入口仍偏“全部先加载,再决定要不要用”,会放大 bundle 解析和模块求值成本。 + +**方案**: + +1. 交互模式相关模块改为动态导入 + - `Ink` + - `AppContainer` + - 大型 UI hooks / layouts / themes +2. 将纯 CLI / 非交互路径与交互路径拆分 chunk +3. 对实验特性、远期 UI 能力、重依赖组件使用 feature flag 或运行时懒加载 +4. 将“首屏前必须完成”的代码限制为: + - 参数解析 + - settings / config 主路径 + - auth 最小必要路径 + - 进入交互 render 所需的最小依赖 + +**示意**: + +```typescript +// 当前:入口直接求值全部 UI 模块 +import './interactiveCli.js'; + +// 目标:确认进入交互模式后再加载 +if (isInteractive) { + const { startInteractiveUI } = await import('./interactiveCli.js'); + await startInteractiveUI(...); +} +``` + +**影响范围**: + +- `packages/cli/src/gemini.tsx` +- `packages/cli/src/ui/*` 的顶层 import 组织方式 +- 构建产物分析脚本 / bundle report + +**预期收益**: + +- 降低 `processUptimeAtT0Ms` +- 降低 UI 首屏前的模块求值时间 +- 为后续引入 `marked`、更复杂高亮或虚拟滚动组件预留体积空间 + ### 2.3 [P1] 渐进式 MCP 可用性 **现状校准**: @@ -231,6 +298,8 @@ const [_auth, startupWarnings, userWarnings, _kitty] = await Promise.all([ - 但 `discoverAllMcpTools()` 仍等待所有 Server settle 后才完成,慢 Server 会拖延整体 discovery state、初始化完成语义和 UI 反馈 - `ToolRegistry.discoverMcpTools()` 会先 `removeDiscoveredTools()` 并清空 prompt registry,不适合作为异步 fire-and-forget 入口,否则可能短暂移除已可用工具 - `GeminiClient.setTools()` 不会在 MCP 工具动态加入时自动触发;不刷新 tools declaration 时,模型下一次请求仍可能看不到新工具 +- `McpClientManager` 已有 `discoverAllMcpToolsIncremental()`,`ToolRegistry` 已有 `discoverToolsForServer()`;第一阶段应优先复用这些 primitives,而不是重写整套 client 生命周期 +- 当前 `ConfigInitDisplay` 已显示 `connected/total`,因此设计目标应从“做出进度 UI”升级为“把连接进度补齐成工具可用性语义” **方案**: @@ -241,10 +310,18 @@ const [_auth, startupWarnings, userWarnings, _kitty] = await Promise.all([ 5. **合理超时**:拆分 discovery timeout 与 tool-call timeout。discovery 默认可降至 30 秒;tool call 继续尊重 `MCP_DEFAULT_TIMEOUT_MSEC` 或 server 配置,避免误杀长耗时工具 6. **UI 进度指示**:复用现有 `mcp-client-update` 事件,显示 "N/M MCP Servers 已连接 / 失败 / 超时",并在 init 后持续更新 -**核心代码变更**(`packages/core/src/tools/mcp-client-manager.ts`): +**推荐实现路径**: + +第一阶段不要从零重写 `McpClient` 生命周期,而是在现有基础上补齐三层缺口: + +1. 启动路径接入 `skipDiscovery` + 增量发现 +2. ToolRegistry 暴露不清空全局 discovered tools 的 incremental wrapper +3. 每个 server ready 后 debounce `GeminiClient.setTools()` + +**核心代码变更**(建议基于现有 `discoverAllMcpToolsIncremental()` / `discoverToolsForServer()` 演进): ```typescript -async discoverMcpToolsProgressively( +async discoverMcpToolsIncrementally( cliConfig: Config, onServerReady?: (name: string) => void, onToolsChanged?: () => void, @@ -260,7 +337,8 @@ async discoverMcpToolsProgressively( await Promise.race([ (async () => { // 只清理当前 server 的旧工具/prompt,不能清空全局 discovered tools - this.toolRegistry.removeDiscoveredToolsForServer(name); + this.toolRegistry.removeMcpToolsByServer(name); + cliConfig.getPromptRegistry().removePromptsByServer(name); await client.connect(); await client.discover(cliConfig); onServerReady?.(name); @@ -279,7 +357,7 @@ async discoverMcpToolsProgressively( **实现路径**: -代码审查发现 `createToolRegistry()` 已支持 `skipDiscovery` 选项,但不能直接 fire-and-forget 调用现有 `discoverMcpTools()`,因为它会清理所有 discovered tools/prompts。应实现一个新的渐进入口或扩展现有 per-server 发现入口: +代码审查发现 `createToolRegistry()` 已支持 `skipDiscovery` 选项,但不能直接 fire-and-forget 调用现有 `discoverMcpTools()`,因为它会清理所有 discovered tools/prompts。更务实的方式是为 ToolRegistry 增加 incremental wrapper,内部复用现有 manager/per-server 发现入口,而不是完全另起炉灶: ```typescript // 阶段 1:快速创建工具注册表(跳过 MCP discovery) @@ -287,7 +365,7 @@ await createToolRegistry({ skipDiscovery: true }); // 阶段 2:异步 MCP 发现;server ready 后 debounce 刷新 Gemini tools const refreshGeminiTools = debounce(() => config.getGeminiClient().setTools(), 100); -void toolRegistry.discoverMcpToolsProgressively({ +void toolRegistry.discoverMcpToolsIncrementally({ onServerReady, onToolsChanged: refreshGeminiTools, }); @@ -295,9 +373,9 @@ void toolRegistry.discoverMcpToolsProgressively({ **影响范围**: -- `packages/core/src/tools/mcp-client-manager.ts` — 添加渐进发现入口、逐 Server 超时控制、server ready 事件 +- `packages/core/src/tools/mcp-client-manager.ts` — 复用并扩展现有 incremental/per-server 发现入口,补 server config change 检测、逐 Server 超时控制、server ready 事件 - `packages/core/src/config/config.ts` — 利用已有的 `skipDiscovery` 选项,在 `initialize()` 中跳过 MCP,另行启动 -- `packages/core/src/tools/tool-registry.ts` — 添加不清空全局 discovered tools 的 per-server discover/replace API +- `packages/core/src/tools/tool-registry.ts` — 添加不清空全局 discovered tools 的 incremental wrapper / per-server discover-replace API - `packages/core/src/core/client.ts` — 暴露或复用 `setTools()`,支持 debounce 刷新 - `packages/cli/src/ui/AppContainer.tsx` / `ConfigInitDisplay.tsx` — 扩展 MCP 连接状态显示到初始化后 @@ -314,6 +392,37 @@ void toolRegistry.discoverMcpToolsProgressively({ - 超时降低可能导致网络慢的环境误判 Server 不可用,应只作用于 discovery,并保留配置项允许用户调整 - per-server 替换必须是原子的,避免短暂删除其他 Server 工具或 prompts +### 2.3A [P1/P2] 运行期 MCP refresh/reload 路径增量化 + +**现状**:除冷启动外,运行期的 tools/memory refresh 仍会走全量重启路径: + +- `ExtensionManager.refreshMemory()` +- `ExtensionManager.refreshTools()` +- `ToolRegistry.restartMcpServers()` + +这条链路当前仍等价于“清空 discovered MCP tools/prompts 后重新 discover 全部 server”。如果只优化启动阶段,`/reload-plugins`、技能/扩展刷新、某些设置变更后的体验依然会出现全量抖动。 + +**方案**: + +1. 为 refresh 路径增加“配置 diff → changed server set”计算 +2. 未变化的 server 保持连接与工具集合,不重复 discover +3. 新增/断线/配置变化的 server 走 per-server replace +4. server 移除时只移除该 server 的 tools/prompts +5. 运行期批量更新继续复用同一套 `toolRegistryChanged` / debounce `setTools()` 机制 + +**为什么要单列这一节**: + +- 这不是冷启动优化的附属项,而是让 MCP 设计真正从“一次性 startup task”升级为“生命周期系统”的关键 +- Claude Code 的调研表明,list-changed、reconnect、batch flush 都属于同一个运行期问题空间 +- 如果这一层不设计,文档里关于“渐进式 MCP 可用性”的收益会只存在于首次启动 + +**影响范围**: + +- `packages/core/src/extension/extensionManager.ts` +- `packages/core/src/tools/tool-registry.ts` +- `packages/core/src/tools/mcp-client-manager.ts` +- `packages/core/src/core/client.ts` + ### 2.4 [P1] 启动分析器增强 **现状**:`packages/cli/src/utils/startupProfiler.ts` 仅记录粗粒度 phase 边界,并且交互式模式下在 UI render 前后 finalize,无法定位 `config.initialize()`、MCP 首工具注册、Gemini tools 刷新的具体瓶颈。 @@ -327,6 +436,11 @@ void toolRegistry.discoverMcpToolsProgressively({ 5. 保存滚动 10 次运行历史到 `~/.qwen/startup-perf/`,支持回归检测 6. 在 profile 中标记 `interactive` / `non_interactive`,避免把两种启动路径混合比较 +**补充约束**: + +- `--startup-profile` 若落地,必须透传到 sandbox child process,不能只在父进程消费参数 +- 非 sandbox/dev 采集模式若开放,需显式避免父子进程双写 profile + **影响范围**: - `packages/cli/src/utils/startupProfiler.ts` — 增强记录能力 @@ -351,58 +465,80 @@ void toolRegistry.discoverMcpToolsProgressively({ **预期收益**:`processUptimeAtT0Ms`(V8 解析时间)减少 20%+。该项与 `03-rendering-extensibility.md` 的代码高亮缓存/预热方案联动实施。 -## 3. 竞品参考 +## 3. 竞品参考与路线校准 -### Claude Code 启动优化策略 - -Claude Code 在 `src/main.tsx` 中实现了激进的并行初始化: +### 3.1 Gemini CLI:渐进可用与启动后初始化 ```typescript -// MCP 配置提前并行加载 -const [localMcpPromise, claudeaiMcpPromise] = [ - loadLocalMcpConfig(), - loadClaudeAiMcpConfig(), -]; +// 入口延迟加载交互 UI +const { startInteractiveUI } = await import('./interactiveCli.js'); -// 设置/信任对话框与 MCP 连接并行运行 -Promise.all([ensureMdmSettingsLoaded(), ensureKeychainPrefetchCompleted()]); +// mount 后再初始化 config / MCP +useEffect(() => { + await config.initialize(); + startupProfiler.flush(config); +}, []); -// MCP 连接使用 Promise.race 超时保护 -Promise.race([claudeaiConnect, timeout]); - -// 渲染后延迟预取(fire-and-forget) -void prefetchAllMcpResources(); - -// 特性门控的懒加载 -const module = feature('FLAG') ? require('./module.js') : null; +// UI 用事件化 MCP 状态驱动提示 +coreEvents.on(CoreEvent.McpClientUpdate, onChange); ``` -**关键设计差异**: +**对 qwen-code 的启示**: -- Claude Code 的 MCP 配置加载在 UI 渲染**之前**就开始 -- 使用 `Promise.race` 而非等待所有 Server -- 非关键预取使用 `void` fire-and-forget 模式 -- 特性门控避免加载不需要的模块 +- 入口延迟加载可以前移到 P0/P1,而不是等到 bundle 优化阶段 +- `config.initialize()` 的 render 后执行必须被 profiler 覆盖 +- MCP 渐进可用需要 UI 明确表达“哪些功能先可用、哪些仍在初始化” + +### 3.2 Claude Code:顶层并行预取与运行期 MCP 生命周期 + +Claude Code 在 `src/main.tsx` 和 `src/services/mcp/useManageMCPConnections.ts` 中体现了更激进的策略: + +```typescript +// 顶层就启动后台读取,和后续 imports 并行 +profileCheckpoint('main_tsx_entry'); +startMdmRawRead(); +startKeychainPrefetch(); + +// 冷路径 feature-gated require +const module = feature('FLAG') ? require('./module.js') : null; + +// MCP 更新 16ms 批量刷入 +const MCP_BATCH_FLUSH_MS = 16; +setTimeout(flushPendingUpdates, MCP_BATCH_FLUSH_MS); +``` + +**对 qwen-code 的启示**: + +- “产物体积优化”不只是分析 bundle,还要主动把冷路径从首屏剥离 +- MCP 需要设计成运行期持续更新系统,而不只是一次性 startup task +- 远端/长生命周期 server 的 reconnect、list-changed 事件、状态批处理应纳入后续阶段设计 ## 4. 实施优先级与里程碑 -| 优先级 | 方案 | 周次 | 风险 | 预期改善 | -| ------ | ------------------ | ---- | ---- | -------------------- | -| P0 | 启动观测基线 | 1 | 低 | 指标口径可信 | -| P0 | 并行 Settings 加载 | 4 | 中 | 配置加载耗时 -30~50% | -| P0 | 并行化 UI 前初始化 | 5 | 低 | TTI -200~400ms | -| P1 | 渐进式 MCP 可用性 | 6-7 | 中 | 首工具可见 < 2s | -| P1 | 启动分析器增强 | 1-2 | 低 | 持续监控能力 | -| P2 | 产物体积优化 | 10 | 中 | 冷启动 -20% | +| 优先级 | 方案 | 周次 | 风险 | 预期改善 | +| ------ | ------------------------ | ---- | ---- | -------------------- | +| P0 | 启动观测基线 | 1 | 低 | 指标口径可信 | +| P0 | 启动分析器增强 | 1-2 | 低 | 持续监控能力 | +| P0 | 入口延迟加载与冷路径裁剪 | 2-3 | 中 | 冷启动解析时间下降 | +| P0 | 并行 Settings 加载 | 4 | 中 | 配置加载耗时 -30~50% | +| P0 | 并行化 UI 前初始化 | 5 | 低 | TTI -200~400ms | +| P1 | 渐进式 MCP 可用性 | 6-7 | 中 | 首工具可见 < 2s | +| P2 | 运行期 MCP 生命周期治理 | 8-10 | 中 | 动态变更更稳定 | +| P2 | 产物体积优化 | 10 | 中 | 冷启动 -20% | ## 5. 验证方案 +除本节外,实施前还应对照 `06-implementation-rollout-checklist.md` 中“启动与 MCP 验收清单”的退出标准。 + ### 5.1 定量指标 ```bash # 启动 profile 对比 QWEN_CODE_PROFILE_STARTUP=1 qwen-code --prompt "test" +# 当前实现下,这个命令需要确保实际运行在 sandbox child process 中;否则可能不会生成 profile。 +# 如果后续引入 --startup-profile 或 dev 模式采集,需在文档和工具输出中明确标识采集路径。 + # 重点关注指标: # - processUptimeAtT0Ms: V8 模块解析时间 # - after_load_settings: 配置加载完成时间 @@ -426,6 +562,8 @@ QWEN_CODE_PROFILE_STARTUP=1 qwen-code --prompt "test" | 网络不可用 | 超时后优雅降级,显示警告 | | 冷启动 vs 热启动 | 两种场景均有改善 | | 正在进行的模型请求中 MCP 工具变化 | 当前请求工具集合不变,下一次请求看到更新 | +| 运行期 MCP 工具/资源变更 | UI 状态批量刷新,不出现工具列表抖动或重复 setTools | +| 非交互命令 / 测试 harness | 入口延迟加载不改变非交互路径行为 | ### 5.3 向后兼容 @@ -433,3 +571,4 @@ QWEN_CODE_PROFILE_STARTUP=1 qwen-code --prompt "test" - MCP discovery 超时降低需提供配置项允许用户恢复长超时;tool call 超时不随 discovery 默认值改变 - 渐进式工具注册需确保不破坏现有的工具描述生成逻辑,并通过 `GeminiClient.setTools()` debounce 刷新 - 不直接使用会全局清空 discovered tools/prompts 的 `ToolRegistry.discoverMcpTools()` 作为后台渐进入口 +- 入口延迟加载不能改变非交互路径、测试 harness 和 CLI 子命令的模块求值顺序 diff --git a/docs/design/tui-optimization/02-screen-flickering.md b/docs/design/tui-optimization/02-screen-flickering.md index 91711c963..9b658fa8e 100644 --- a/docs/design/tui-optimization/02-screen-flickering.md +++ b/docs/design/tui-optimization/02-screen-flickering.md @@ -1,16 +1,16 @@ # TUI 优化:屏幕闪烁 -> 详细设计文档 2/3 — 解决流式输出、窄屏、终端 resize 等场景下的屏幕闪烁问题。 +> 详细设计文档 — 解决流式输出、窄屏、终端 resize 等场景下的屏幕闪烁问题。 ## 1. 问题分析 ### 1.1 闪烁的根本原因 -Ink 6.2.3 的渲染模型决定了闪烁问题的根源: +Ink 6.2.3 的渲染模型决定了闪烁问题的一部分根源,但 qwen-code 当前的可见整屏闪烁还叠加了应用层主动清屏路径: 1. **全量重绘**:每次 React 状态变更,Ink 对整个动态区域执行 `eraseLines(N)` + 重新输出。`eraseLines` 会逐行发出 `ERASE_LINE + CURSOR_UP` 序列对,然后重写所有内容。 2. **超高重绘频率**:流式输出时每个内容 chunk(可包含一到多个 token)触发一次状态更新和重绘,高频时可达 50+ 次/秒。 -3. **全屏回退路径**:当动态内容高度超过终端高度时,Ink 切换到 `clearTerminal` + 全量重写,产生整屏闪烁。 +3. **应用层整屏清除路径**:当前 qwen-code 的 `refreshStatic()` 会主动调用 `ansiEscapes.clearTerminal`,在 resize、compact 切换、视图切换等场景触发整屏刷新;这和 Ink 的 `eraseLines` 路径是两类问题,必须分开治理。 ### 1.2 当前缓解措施 @@ -31,7 +31,7 @@ Ink 6.2.3 的渲染模型决定了闪烁问题的根源: - 仅优化光标移动模式,不减少实际输出字节数 - 不解决 Ink 全量重绘的根本问题 - 不支持同步输出协议 -- 对全屏清除路径无效 +- 对 `refreshStatic()` 触发的 `clearTerminal` 路径无效 #### Static/Dynamic 分离 @@ -51,15 +51,15 @@ Ink 6.2.3 的渲染模型决定了闪烁问题的根源: **局限**: -- 当流式内容本身超过终端高度时仍会触发全屏重绘 -- `refreshStatic()` 使用 `clearTerminal` 导致整屏闪烁(resize、compact 切换等场景) +- 当流式内容本身超过终端高度时,动态区仍会频繁走 `eraseLines` 全量重绘,闪烁被放大 +- `refreshStatic()` 使用 `clearTerminal` 导致整屏闪烁(resize、compact 切换、active view 切换等场景) ### 1.3 具体闪烁场景 | 场景 | 触发条件 | 严重程度 | 代码位置 | | ---------------- | -------------------------------------- | -------- | ---------------------------- | | 流式输出 | 每个内容 chunk 触发 React re-render | 高 | `useGeminiStream` hook | -| 长输出超屏 | 动态内容高度 > 终端行数 | 严重 | Ink 内部 `eraseLines` 路径 | +| 长输出超屏 | 动态内容高度 > 终端行数 | 严重 | Ink 动态区 `eraseLines` 路径被放大 | | 终端宽度 resize | `refreshStatic()` 调用 `clearTerminal`;当前 effect 主要依赖宽度变化 | 中 | `AppContainer.tsx` resize effect | | Compact 模式切换 | 历史合并、settings dialog、快捷键切换触发 `refreshStatic()` | 中 | `MainContent` / `SettingsDialog` / `AppContainer` | | 手动清屏/视图切换 | `/clear`、active view 切换触发全屏刷新 | 中 | `slashCommandProcessor` / `DefaultAppLayout` | @@ -74,11 +74,27 @@ Ink 6.2.3 的渲染模型决定了闪烁问题的根源: - **claude-code#37283**:长输出全屏闪烁 - **claude-code#10794**:SSH 远程场景闪烁加剧 +### 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 buffer;`ScrollableList` | `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 + ## 2. 解决方案 ### 2.1 [P0] 同步输出 — DECSET 2026 -**原理**:[同步输出协议](https://gist.github.com/christianparpart/d8a62cc1ab659194f6ca7e8e5b1b1814) 允许应用通过转义序列告知终端"我正在更新帧,请暂缓显示直到帧完成"。 +**原理**:[同步输出协议](https://contour-terminal.org/vt-extensions/synchronized-output/) 允许应用通过转义序列告知终端"我正在更新帧,请暂缓显示直到帧完成"。 ``` CSI ? 2026 h ← Begin Synchronized Update(暂停显示) @@ -86,17 +102,25 @@ CSI ? 2026 h ← Begin Synchronized Update(暂停显示) CSI ? 2026 l ← End Synchronized Update(刷新显示) ``` -**终端支持情况**: -| 终端 | 支持版本 | -|---|---| -| kitty | 0.17.0+ | -| foot | 1.0+ | -| WezTerm | 所有版本 | -| iTerm2 | 3.5+ | -| Windows Terminal | 1.18+ | -| Contour | 0.3.0+ | -| tmux | 3.4+ (透传) | -| 不支持的终端 | 通常会忽略未知私有 CSI 序列;仍需按终端和 tmux/SSH 组合验证 | +**终端支持矩阵的使用方式**: + +下面的矩阵应视为 **rollout 验证矩阵**,不是“单靠本仓源码就能证明的最终定论”。本地源码和竞品源码能证明的是: + +- [WezTerm 官方文档](https://wezterm.org/escape-sequences.html) 明确支持 synchronized rendering +- [kitty 官方文档](https://sw.kovidgoyal.net/kitty/performance/) 明确讨论过 synchronized update 对性能的帮助 +- [Contour 的 synchronized output 规范页](https://contour-terminal.org/vt-extensions/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 包裹”还是“帧缓冲合并”。 @@ -140,15 +164,15 @@ const optimizedWrite = function ( **风险评估**:**中低** -- 不支持的终端通常忽略 BSU/ESU,但不能宣称零风险,需覆盖 tmux/SSH/Windows Terminal/Terminal.app +- 不支持的终端常见行为是忽略 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` 中已使用相同方案 +- Claude Code 在 `src/ink/terminal.ts` 中使用相同协议,但其 runtime gating 对 tmux 明确更保守,qwen-code 也应沿用这种保守策略 **预期收益**: - 消除大部分可见的帧撕裂和闪烁 -- tmux 场景下效果最为显著(从数千次/秒滚动事件降至帧率级别) +- 在已支持且通过验证的终端中,writes/sec 与可见帧撕裂会显著下降;tmux/SSH 需单独验证后再评估默认开启 - 不改变渲染管线,仅改变终端侧行为 ### 2.2 [P0] 流式更新节流 @@ -205,6 +229,30 @@ const onStreamEnd = useCallback(() => { **预期收益**:`stdout.write` 调用从 50+/秒降至 < 20/秒,直接减少 60%+ 的渲染开销。 +### 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 内容在动态区域。当前缺口不是“从零实现提升”,而是提升阈值、覆盖范围和刷新频率不够可控。 @@ -296,6 +344,12 @@ const refreshStatic = useCallback(() => { 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 Patch(Phase 3) @@ -355,36 +409,53 @@ class ScreenBuffer { - xterm.js:5 行以下即时,12 行以上平滑步进 - 原生终端:待处理行数的 3/4,最少 4 行 -## 3. 竞品参考 +## 3. 竞品参考与路线校准 -### Claude Code 防闪烁架构 +### 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. 帧缓冲 | frontFrame/backFrame 双缓冲 | `src/ink/ink.tsx:99-100` | -| 2. Diff 渲染 | 逐 cell 比较,仅输出变更 | `src/ink/log-update.ts` | -| 3. 原子帧 | BSU/ESU 同步输出包裹 | `src/ink/terminal.ts` | -| 4. 硬件滚动 | DECSTBM 滚动区域 | `src/ink/render-node-to-output.ts` | -| 5. 布局感知 | 布局稳定时窄范围 diff | `src/ink/render-node-to-output.ts:34-42` | +| 层级 | 机制 | 对应文件 | +| --- | --- | --- | +| 1. 帧缓冲 | screen buffer / prevScreen 复用 | `src/ink/output.ts`、`src/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 层)提供了最彻底的解决方案。我们的 Phase 1 策略(同步输出 + 节流)可以以约 10% 的实现成本获得约 70% 的效果。 +**关键洞察**:Claude Code 的经验表明,同步输出(第 3 层)是**单项收益最大**的优化;双缓冲 + diff(第 1-2 层)则是最彻底但也最昂贵的路线。qwen-code 的 Phase 1 策略应继续聚焦“同步输出 + 节流 + 中层治理”,不要过早跳入自研 renderer。 ## 4. 实施优先级与里程碑 -| 优先级 | 方案 | 周次 | 风险 | 预期收益 | -| ------ | -------------------- | ----- | ---- | ------------------------- | -| 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+ | 高 | 滚动性能接近原生 | +| 优先级 | 方案 | 周次 | 风险 | 预期收益 | +| ------ | --------------------------- | ----- | ---- | ------------------------- | +| P0 | 输出层 instrumentation | 1 | 低 | 指标口径可信 | +| P0 | 同步输出 DECSET 2026 | 2 | 中低 | 消除帧撕裂,tmux 效果显著 | +| P0 | 流式更新节流 60ms | 2 | 低 | stdout.write -60%+ | +| P0 | 渲染模式分层 | 2-3 | 中 | 为滚动和 fullscreen 优化铺路 | +| P1 | 现有渐进提升增强 | 7 | 中 | 降低长输出全屏闪烁 | +| P1 | 智能 refreshStatic() | 8-9 | 中 | resize 不再全屏闪烁 | +| P2 | alternate/fullscreen 虚拟滚动 | 9-12 | 高 | 长会话稳定性显著提升 | +| P2 | 双缓冲 + diff patch | 11-13 | 高 | stdout 字节/帧 -80% | +| P2 | DECSTBM 滚动区域 | 13+ | 高 | 滚动性能接近原生 | ## 5. 验证方案 +除本节外,实施前还应对照 `06-implementation-rollout-checklist.md` 中“闪烁治理验收清单”的退出标准。 + ### 5.1 定量指标 | 指标 | 当前估计 | Phase 1 目标 | Phase 3 目标 | @@ -406,8 +477,9 @@ Claude Code 的自研 Ink 内核提供了五层防闪烁保护: | 窄屏 (< 40 列) | 将终端缩至 30 列 | 布局优雅降级,无抖动 | | tmux 内运行 | tmux 分屏环境 | 滚动事件 < 100/秒 | | SSH 远程 | 高延迟网络 | 闪烁不加剧 | -| kitty/WezTerm | 支持 DECSET 2026 的终端 | 无明显帧撕裂 | -| Terminal.app | 不支持 DECSET 2026 | 行为不变(不退化) | +| kitty/WezTerm | 官方资料明确支持或已有正向验证的终端 | 无明显帧撕裂 | +| Terminal.app / 未知终端 | 未通过 runtime probe 或未纳入 allowlist | 行为不变(不退化) | +| alternate/fullscreen 路径 | 长会话滚动 + 贴底输出 | 不出现 blank spacer 或整屏 flash | | screen reader | `config.getScreenReader()` 开启 | 不安装 stdout 优化器 | | Buffer write/callback | 直接写 stdout 的外部路径 | `write()` 返回值和 callback 行为不变 | @@ -415,4 +487,4 @@ Claude Code 的自研 Ink 内核提供了五层防闪烁保护: - `QWEN_CODE_LEGACY_ERASE_LINES=1`:禁用所有 stdout 拦截优化(已有) - `QWEN_CODE_LEGACY_RENDERING=1`:新增,禁用同步输出 + 节流 -- 不支持 DECSET 2026 的终端:通常忽略未知序列,但仍需保留开关和终端矩阵验证 +- 未通过 runtime probe 或未纳入 allowlist 的终端:默认不启用同步输出,仍保留开关和终端矩阵验证 diff --git a/docs/design/tui-optimization/03-rendering-extensibility.md b/docs/design/tui-optimization/03-rendering-extensibility.md index ca6de4347..f0b0a0e9f 100644 --- a/docs/design/tui-optimization/03-rendering-extensibility.md +++ b/docs/design/tui-optimization/03-rendering-extensibility.md @@ -1,6 +1,6 @@ # TUI 优化:渲染性能与可扩展性 -> 详细设计文档 3/3 — 提升渲染性能,支持更多格式,增强主题可配置性,探索远期方向。 +> 详细设计文档 — 提升渲染性能,支持更多格式,增强主题可配置性,探索远期方向。 ## 1. 问题分析 @@ -99,6 +99,20 @@ export const QwenDark: Theme = { | 虚拟滚动 | 无,长会话性能退化 | 长会话场景 | | 图表/图像 | 不支持 | 远期探索 | +### 1.6 Gemini CLI / Claude Code 调研结论 + +外部源码调研说明,渲染层的机会不能只看“Markdown 支持哪些语法”,而要同时看 parser、streaming、highlight、表格和长会话容器: + +| 维度 | Gemini CLI | Claude Code | 对 qwen-code 的含义 | +| --- | --- | --- | --- | +| Markdown parser | 仍是自定义正则解析器 | `marked` + token cache + plain-text fast path | parser 架构升级应主要参考 Claude,而不是把 Gemini 当 parser 终局 | +| 流式 Markdown | `findLastSafeSplitPoint()` + Static 提升 | `StreamingMarkdown` 稳定前缀 / 不稳定尾部 | 现有“安全分割点”方向正确,但应升级成稳定块模型 | +| 代码高亮 | 同步 `lowlight(common)` | Suspense + fallback + 宽度感知渲染 | qwen-code 应坚持“同步基线 + 异步增强” | +| 表格 | 已有成熟 ANSI/CJK 宽度处理 | `MarkdownTable` 单独组件化 | 表格不是首要重构目标,但应成为 parser 迁移的兼容边界 | +| 长会话 | `ScrollableList` / `VirtualizedList` | `ScrollBox` / `useVirtualScroll` / `VirtualMessageList` | 虚拟滚动必须进入正式路线图,且要处理动态高度与 resize | + +因此,本设计文档后续的重点不应只是“换 parser”,而是把 parser、streaming、高亮、虚拟滚动作为一组相互制约的问题来处理。 + ## 2. 解决方案 ### 2.1 [P0] Markdown token/block 缓存 @@ -298,6 +312,12 @@ return [...cachedBlocks.flat(), ...lastBlockTokens]; 5. 内部 dogfood 后渐进切换默认值到 v2,保留 v1 作为回退 6. 稳定两个小版本后再评估移除 v1 +**来自 Claude Code 的额外校准**: + +- `marked` 迁移的真正收益不只是语法支持,而是 token cache、plain-text fast path、流式稳定前缀可以一起落地 +- 表格应继续组件化渲染,避免为了 parser 迁移把表格退回到纯文本路径 +- 如果只替换 parser 而不补 cache / streaming policy,收益会明显低于预期 + **影响范围**: - 新增:`packages/cli/src/ui/utils/MarkdownDisplayV2.tsx` @@ -421,6 +441,16 @@ function wrapHyperlink(url: string, text: string): string { **现状**:所有历史消息通过 `` 追加到终端 scrollback,长会话会产生大量渲染元素。 +**调研结论先行**:这不是一个“列表 slice 一下”的小优化。Gemini CLI 的 `VirtualizedList` 和 Claude Code 的 `useVirtualScroll` 都表明,真正可用的消息虚拟滚动至少要处理: + +- 动态高度消息 +- 贴底行为(sticky bottom) +- resize 后高度缓存失效 +- overscan +- 搜索/跳转/定位 +- 复制模式 / 选择模式 +- 渲染中间态不出现 blank spacer + **方案设计**: ``` @@ -442,7 +472,21 @@ function wrapHyperlink(url: string, text: string): string { - 需要切换到 alternate screen 模式或自行管理终端输出 - 每条消息的高度需要预计算或缓存 -**参考**:Claude Code 的 `` 组件(31KB)实现了完整的虚拟滚动 + DECSTBM 硬件滚动。 +**应补充的工程约束**: + +1. **滚动输入不要每 tick 都走 React setState** + Claude 的 `ScrollBox` 直接操作 DOM scrollTop,`useVirtualScroll` 只在量化后的 snapshot 变化时触发 React commit。qwen-code 如果让 wheel/scroll 直接驱动高频 state 更新,后续所有虚拟化收益都会被抵消。 + +2. **高度缓存不能在 resize 时简单清空** + Claude 采用“按列宽比例缩放旧高度 + 冻结旧 range 两帧”的策略,Gemini 也用 `ResizeObserver` 和实测高度维护 offsets。qwen-code 需要把 resize 视为一等场景,而不是异常路径。 + +3. **要为 sticky bottom 与 copy/search mode 预留语义** + Gemini 的 `VirtualizedList` 暴露 `isStickingToBottom`、`stableScrollback`、`copyModeEnabled`;Claude 也把 sticky signal 视为核心状态。qwen-code 若未来要支持 transcript 搜索、selection 或 copy mode,不应把虚拟滚动写成只服务普通聊天输出的最小实现。 + +4. **初期只建议在 fullscreen / alternate buffer 路径启用** + Gemini 的经验表明,这类滚动容器最适合全屏或 buffer 模式;main-screen 路径继续用 `Static` + pending 区域更保守。 + +**参考**:Claude Code 的 `` 和 `useVirtualScroll` 形成了完整的滚动/贴底/overscan/resize 体系;Gemini CLI 的 `ScrollableList` / `VirtualizedList` 则证明这一层可以先在 alternate/fullscreen 路径落地。 **建议**:先评估 Phase 1-2 的优化效果,若长会话性能仍是痛点再实施。 @@ -507,38 +551,54 @@ $$ **建议**:仅作为概念验证(POC),不纳入正式路线图。 -## 3. 竞品参考 +## 3. 竞品参考与路线校准 -### Claude Code 渲染架构 +### 3.1 Gemini CLI:滚动和渲染模式先行 -| 能力 | 实现方式 | -| ------------- | ---------------------------------------------------------- | -| Markdown 解析 | `marked` 库 + LRU token 缓存(500 条) | -| 快速路径 | 正则检测无 MD 语法 → 跳过 `marked.lexer()`(大多数短回复) | -| 流式优化 | 在块边界分割,仅重解析最后一个块 | -| 代码高亮 | `` 包裹的可选 CLI 语法高亮 | -| 表格 | React 组件 `` + flexbox 布局 | -| 超链接 | OSC 8 终端超链接 | -| 样式池化 | StylePool: ANSI 码集内化为整数 ID + 转换缓存 | -| 字符池化 | CharPool: ASCII 快速路径 + Map 缓存 | +Gemini CLI 在 parser 架构上并没有比 qwen-code 更先进,但它在长会话和渲染模式上已经形成了可借鉴的组合: -**关键差异**:Claude Code 使用 `marked`(成熟的 GFM 解析器)而非自定义正则,并通过 LRU 缓存 + 快速路径跳过 + 流式块分割实现了高效的流式渲染。 +| 能力 | 实现方式 | +| --- | --- | +| 长会话容器 | `ScrollableList` / `VirtualizedList` | +| item 级稳定渲染 | `StaticRender` | +| 高度测量 | `ResizeObserver` | +| 贴底行为 | `scrollAnchor` + `isStickingToBottom` | +| scrollback / copy mode | `stableScrollback` / `copyModeEnabled` | + +**关键差异**:Gemini 说明“渲染扩展”不仅是 parser 选择,还包括长会话容器和消息呈现模式。 + +### 3.2 Claude Code:parser、streaming 与虚拟滚动一体化 + +| 能力 | 实现方式 | +| --- | --- | +| Markdown 解析 | `marked` 库 + LRU token 缓存(500 条) | +| 快速路径 | 正则检测无 MD 语法 → 跳过 `marked.lexer()` | +| 流式优化 | `StreamingMarkdown` 稳定前缀,仅重解析最后一个块 | +| 代码高亮 | `` 包裹的可选 CLI 语法高亮 | +| 表格 | React 组件 `` | +| 超链接 | OSC 8 终端超链接 | +| 长会话 | `ScrollBox` + `useVirtualScroll` + `VirtualMessageList` | + +**关键差异**:Claude Code 将 parser、streaming、高亮、虚拟滚动视为同一套渲染架构的一部分,因此能在长会话中同时保持功能完整和性能稳定。 ## 4. 实施优先级与里程碑 -| 优先级 | 方案 | 周次 | 风险 | 预期收益 | -| ------ | ------------------------- | ----- | ------ | ------------------------- | -| P0 | Markdown token/block 缓存 | 3 | 低 | 解析耗时显著下降 | -| P0 | 代码高亮缓存 + 同步基线/异步预热 | 3 | 中 | 重复渲染消除,降低大块代码成本 | -| P1 | 切换到 marked 解析器 | 7-8 | 中 | GFM 基础能力增强 | -| P1 | ANSI 16 色默认 + 能力检测 | 4 | 中 | 修复透明终端兼容性 | -| P2 | OSC 8 终端超链接 | 9-10 | 低 | URL 可点击 | -| P2 | 虚拟滚动 | 13-15 | 高 | 长会话性能 | -| P3 | LaTeX 数学公式 | 15-16 | 中 | 数学内容渲染 | -| 远期 | Web 渲染探索 | TBD | 探索性 | 富文本能力 | +| 优先级 | 方案 | 周次 | 风险 | 预期收益 | +| ------ | ----------------------------------- | ----- | ------ | ------------------------- | +| P0 | Markdown token/block 缓存 | 3 | 低 | 解析耗时显著下降 | +| P0 | 代码高亮缓存 + 同步基线/异步预热 | 3 | 中 | 重复渲染消除,降低大块代码成本 | +| P1 | ANSI 16 色默认 + 能力检测 | 4 | 中 | 修复透明终端兼容性 | +| P1 | 切换到 marked 解析器 | 7-8 | 中 | GFM 基础能力增强 | +| P1 | streaming stable prefix / suffix | 7-8 | 中 | 流式重解析成本显著下降 | +| P2 | OSC 8 终端超链接 | 9-10 | 低 | URL 可点击 | +| P2 | fullscreen / alternate 路径虚拟滚动 | 13-15 | 高 | 长会话性能 | +| P3 | LaTeX 数学公式 | 15-16 | 中 | 数学内容渲染 | +| 远期 | Web 渲染探索 | TBD | 探索性 | 富文本能力 | ## 5. 验证方案 +除本节外,实施前还应对照 `06-implementation-rollout-checklist.md` 中“渲染与扩展验收清单”的退出标准。 + ### 5.1 渲染性能基准 ```typescript @@ -577,7 +637,9 @@ Markdown fixture 测试集,验证所有支持的格式正确渲染: - 分割线 - 引用块 - streaming partial blocks(未闭合代码块、未闭合表格、未闭合列表) +- stable prefix / unstable suffix 切换场景 - HTML 输入(默认转义/忽略策略) +- resize 后高度缓存与虚拟滚动 range 稳定性 ### 5.3 主题兼容性 diff --git a/docs/design/tui-optimization/04-gemini-cli-research.md b/docs/design/tui-optimization/04-gemini-cli-research.md new file mode 100644 index 000000000..1a450ae21 --- /dev/null +++ b/docs/design/tui-optimization/04-gemini-cli-research.md @@ -0,0 +1,393 @@ +# Gemini CLI 源码调研:TUI 架构、性能与交互 + +> 调研对象:`/Users/gawain/Documents/codebase/opensource/gemini-cli` +> 目标:从启动、渲染、防闪烁、滚动、Markdown、MCP、终端协议等维度提炼可迁移经验,并标注不应直接照搬的部分。 + +**事实边界**:除特别注明的外部终端资料外,本文件结论均基于本地源码树当前状态。上游若发生重构、文件移动或行为变更,需要重新核对后再引用到实施文档中。 + +## 1. 结论摘要 + +Gemini CLI 的 TUI 并不是“靠一个点优化起来”的,而是把优化拆成了四层: + +1. **启动层**:入口尽量把重模块推迟到需要时再加载,例如 `packages/cli/src/gemini.tsx` 动态 `import('./interactiveCli.js')` +2. **渲染模式层**:同一套 UI 支持 `alternateBuffer`、`terminalBuffer`、`renderProcess`、`incrementalRendering` 等多种模式 +3. **交互/长会话层**:在 alternate/terminal buffer 模式下使用 `ScrollableList` / `VirtualizedList`,并通过 `ResizeObserver`、`StaticRender`、批量滚动维持稳定性 +4. **观测层**:有 `startupProfiler`、`onRender` profiling、`useFlickerDetector()` 等现成观察点 + +但 Gemini CLI 也并非全线领先: + +- Markdown 仍是**自定义正则解析器**,不是成熟 AST parser +- 代码高亮仍是**同步 lowlight/common**,不是懒加载 grammar +- `refreshStatic()` 仍然存在 `clearTerminal` 路径 +- `config.initialize()` 仍在 React mount 后执行,启动口径依然需要额外 instrumentation + +因此,对 qwen-code 来说,Gemini 更像是“**渲染模式、滚动和观测层的强参考实现**”,而不是 Markdown / parser 架构的最终答案。 + +## 2. 关键文件地图 + +| 维度 | 文件 | +| --- | --- | +| 入口与启动 | `packages/cli/src/gemini.tsx` | +| 交互式 UI render | `packages/cli/src/interactiveCli.tsx` | +| 主状态容器 | `packages/cli/src/ui/AppContainer.tsx` | +| 主内容区 | `packages/cli/src/ui/components/MainContent.tsx` | +| 流式输出处理 | `packages/cli/src/ui/hooks/useGeminiStream.ts` | +| 闪烁观测 | `packages/cli/src/ui/hooks/useFlickerDetector.ts` | +| 虚拟滚动 | `packages/cli/src/ui/components/shared/VirtualizedList.tsx` | +| 滚动容器 | `packages/cli/src/ui/components/shared/ScrollableList.tsx` | +| Markdown | `packages/cli/src/ui/utils/MarkdownDisplay.tsx` | +| 代码高亮 | `packages/cli/src/ui/utils/CodeColorizer.tsx` | +| 表格渲染 | `packages/cli/src/ui/utils/TableRenderer.tsx` | +| 终端能力检测 | `packages/cli/src/ui/utils/terminalCapabilityManager.ts` | +| MCP 状态展示 | `packages/cli/src/ui/hooks/useMcpStatus.ts` | + +## 3. 启动与初始化路径 + +### 3.1 入口把 React/Ink 推迟到真正需要时 + +`packages/cli/src/gemini.tsx` 的一个关键动作,是**主入口不直接顶层导入完整交互 UI**。它在确认要进入交互模式后,才动态导入 `interactiveCli.js`。这类拆分直接减少了冷启动阶段的 JS 解析和模块求值成本。 + +对 qwen-code 的启发: + +- 可以把 `Ink`、`AppContainer`、大型 UI util 从 CLI 主入口延后加载 +- 如果未来引入 `marked`、更重的高亮或图像能力,必须维持这种“主路径瘦身”策略,否则 bundle 体积很快反噬首屏时间 + +### 3.2 pre-render 初始化与 post-render 初始化明确分层 + +Gemini CLI 在 render 前会做一部分初始化,例如: + +- `loadSettings()` +- `initializeApp(config, settings)`,内部执行 auth、theme 校验、IDE 背景连接等 +- `startupProfiler.start('cli_startup')` + +但**真正的 `config.initialize()` 并不在 render 前完成**。它在 `packages/cli/src/ui/AppContainer.tsx` 中通过 effect 执行: + +- 检查 `config.isInitialized()` +- `await config.initialize()` +- `setConfigInitialized(true)` +- 然后再 `startupProfiler.flush(config)` + +这说明 Gemini 也把“首屏出现”和“完整初始化完成”拆开了。对 qwen-code 的直接启示有两条: + +1. 当前文档必须明确区分 `first_paint` 与 `config_initialize_end` +2. “把启动测快”不能只盯着 render 前 profiler,因为 render 后的 `config.initialize()` 可能才是长尾 + +### 3.3 render 选项是 Gemini 的重要分水岭 + +`packages/cli/src/interactiveCli.tsx` 向 Ink render 传入了多组开关: + +- `alternateBuffer` +- `terminalBuffer` +- `renderProcess` +- `incrementalRendering` +- `standardReactLayoutTiming` + +其中 `incrementalRendering` 不是全局无条件开启,而是要求: + +- settings 未显式关闭 +- 使用 alternate buffer +- 非 shpool 场景 + +这说明 Gemini 团队已经接受一个现实:**不同终端、不同会话模式,最优渲染策略并不相同**。qwen-code 当前文档也应改成同样的思路,不再把“单一渲染模式”当作默认前提。 + +### 3.4 终端能力检测前置且覆盖输入/颜色/协议 + +`packages/cli/src/ui/utils/terminalCapabilityManager.ts` 做的事情比“看几个 env var”更激进,它会主动 query 终端能力: + +- Kitty keyboard 协议 +- `OSC 11` 背景色查询 +- 终端 name/version +- 设备属性 sentinel +- `modifyOtherKeys` + +并在退出时恢复: + +- kitty keyboard / modifyOtherKeys +- bracketed paste +- mouse modes + +对 qwen-code 的启发: + +- Theme 自动选择不能只依赖 `TERM` / `COLORTERM` +- 输入协议升级不应只靠静态 allowlist +- 终端能力检测应视为基础设施,而不是散落在主题或 keybinding 里的临时逻辑 + +## 4. 渲染模式与防闪烁策略 + +### 4.1 Gemini 已经把“不同输出模式”产品化 + +Gemini 不是只依赖 ``。它在 render 选项和 UI 组件层形成了一个模式矩阵: + +| 模式 | 主要特征 | 适用价值 | +| --- | --- | --- | +| main screen | 依赖 Ink 标准流式区域 | 兼容性最好,但最容易闪烁 | +| alternate buffer | 全屏会话、滚动交互更自然 | 适合长对话和复制模式 | +| terminal buffer | 支持更强滚动/回看语义 | 适合稳定 scrollback 场景 | +| render process | 把 render 工作转移到独立过程 | 为高负载场景预留余地 | +| incremental rendering | 配合特定 buffer 模式降低全量刷新 | 直接面向防闪烁 | + +这给 qwen-code 的直接建议是:**文档不要只讨论某个优化点,而要把“渲染模式开关”本身列为一等设计对象**。 + +### 4.2 渐进转 Static 已经是 Gemini 的核心流式优化 + +`packages/cli/src/ui/hooks/useGeminiStream.ts` 中最值得借鉴的实现,是通过 `findLastSafeSplitPoint()` 把流式内容分成: + +- 已稳定部分:写入 history / Static +- 尾部未稳定部分:保留在 pending 区域 + +它在源码注释里明确把这件事定义为: + +- 提升性能 +- 尽量把内容挪进 `` +- 减少 re-render 和 flickering + +这与 qwen-code 当前文档的方向一致,但 Gemini 给了两个更具体的经验: + +1. **边界必须是 Markdown-safe 的**,不能只按字符数切 +2. 这不是最终解法,只是减轻动态区域压力的中层方案 + +### 4.3 Gemini 已经有 flicker observability,而不只是“肉眼觉得闪” + +`packages/cli/src/ui/hooks/useFlickerDetector.ts` 每次 render 后都会: + +- `measureElement(rootUiRef.current)` +- 比较渲染高度与终端高度 +- 在 `constrainHeight` 为真且高度越界时: + - `recordFlickerFrame(config)` + - `appEvents.emit(AppEvent.Flicker)` + +这意味着 Gemini 已经把“渲染超出终端高度”视为可记录的 bug 信号,而不是仅靠用户主观反馈。 + +对 qwen-code 的建议非常直接: + +- 先补 `flicker frame`、`clearTerminal count`、`writes/sec` +- 再谈具体优化优先级 + +### 4.4 `refreshStatic()` 仍然是 Gemini 的已知弱点 + +Gemini 的 `refreshStatic()` 逻辑并不完美。`packages/cli/src/ui/AppContainer.tsx` 中: + +- 如果当前不在 alternate buffer 且没启用 terminal buffer +- 就会 `stdout.write(ansiEscapes.clearTerminal)` +- 然后增加 `historyRemountKey` + +同时它会在这些场景反复触发: + +- banner 变化 +- editor 关闭 +- width resize(300ms debounce) +- 若干 UI 状态切换 + +这说明 Gemini 虽然在滚动/模式层做得很强,但**main-screen 的静态区刷新仍然有整屏清除代价**。这也是 qwen-code 不应照搬的点。 + +## 5. 滚动、长会话与交互 + +### 5.1 `MainContent` 已经有两套内容呈现路径 + +`packages/cli/src/ui/components/MainContent.tsx` 中,Gemini 会根据模式选择: + +- main-screen 路径:`` + pending 区域 +- alternate/terminal buffer 路径:`` + +并在 terminal buffer 模式下使用: + +- `renderStatic` +- `isStaticItem` +- `overflowToBackbuffer` + +这说明 Gemini 已经把“长会话滚动”和“普通消息流式输出”拆成两类场景处理,而不是逼同一个组件兼容全部模式。 + +### 5.2 `VirtualizedList` 是重量级实现,不是简单 windowing + +`packages/cli/src/ui/components/shared/VirtualizedList.tsx` 有几个很值得记录的实现细节: + +- 使用 `ResizeObserver` 同时观察容器尺寸和 item 高度 +- 为每个 item 维护实际高度缓存,结合 `estimatedItemHeight()` 计算 offsets +- 维护 `scrollAnchor` +- 维护 `isStickingToBottom` +- 使用 `useBatchedScroll()` 处理 scrollTop 更新 +- 支持 `StaticRender` +- 支持 `overflowToBackbuffer` +- 支持 `stableScrollback` +- 支持 `copyModeEnabled` + +这不是“只渲染可见窗口”那么简单,而是在处理: + +- 动态高度 item +- 贴底行为 +- scrollback 稳定性 +- 复制模式 +- backbuffer 输出 + +对 qwen-code 的含义是:如果未来要做虚拟滚动,**至少要先明确是要解决哪一组问题**。一个只做 `slice(visibleRange)` 的轻量实现,无法直接覆盖长会话中的 sticky bottom、tool 输出和 copy mode 需求。 + +### 5.3 `ScrollableList` 是可复用的交互容器抽象 + +Gemini 将滚动行为和虚拟化行为分层: + +- `ScrollableList` 负责交互语义与外层容器 +- `VirtualizedList` 负责 item 级测量和窗口化 + +这是 qwen-code 当前文档值得吸收的结构性建议:**不要把虚拟滚动逻辑塞进 `MainContent` 本体**,否则后续 tool 输出、prompt 历史、selection list 都会复制同一套复杂逻辑。 + +## 6. Markdown、代码高亮、表格与主题 + +### 6.1 Markdown 仍是正则解析器 + +Gemini 的 `packages/cli/src/ui/utils/MarkdownDisplay.tsx` 仍在使用逐行正则: + +- `headerRegex` +- `codeFenceRegex` +- `ulItemRegex` +- `olItemRegex` +- `tableRowRegex` +- `tableSeparatorRegex` + +并没有看到类似 Claude 的 token cache / AST parser 架构。 + +所以对 qwen-code 来说,Gemini 在这一层的价值主要是: + +- 证明“即便滚动和 buffer 做得很好,parser 依然可能成为短板” +- 说明不能把“我们像 Gemini 一样”当成 Markdown 方向的充分论据 + +### 6.2 代码高亮仍是同步 lowlight/common + +`packages/cli/src/ui/utils/CodeColorizer.tsx` 里: + +- `createLowlight(common)` +- `colorizeCode()` 同步返回 ReactNode +- 没语言时仍可能走 `highlightAuto()` + +也就是说,Gemini 并没有解决“高亮 grammar 过重”和“同步 render 路径无法懒加载”的根本矛盾。qwen-code 当前文档对这点的判断是正确的,应保留。 + +### 6.3 表格渲染已经相当成熟 + +`packages/cli/src/ui/utils/TableRenderer.tsx` 体现出另一个现实:Gemini 对表格渲染已经投入了不少工程量,尤其在: + +- ANSI 宽度处理 +- CJK 宽度处理 +- wrap / alignment +- 窄屏 fallback + +因此 qwen-code 的建议也应继续保持:表格不是当前阶段的最大架构机会,除非能拿出新的可复现缺陷。 + +### 6.4 主题检测与终端背景联动值得借鉴 + +Gemini 不只是提供主题,还会根据终端背景查询进行联动。相关链路包含: + +- `terminalCapabilityManager` 的 `OSC 11` 背景查询 +- `useTerminalTheme` +- 对“重复相同背景报告不重复刷新”的测试保护 + +对 qwen-code 的启示: + +- 主题选择应当有“自动能力检测 + 避免重复刷新”的设计 +- 不要让主题探测本身成为触发 `refreshStatic()` 的噪声源 + +## 7. MCP、工具可用性与启动后状态 + +### 7.1 MCP 状态已经是事件驱动的 + +`packages/cli/src/ui/hooks/useMcpStatus.ts` 通过: + +- `coreEvents.on(CoreEvent.McpClientUpdate, onChange)` +- 读取 `config.getMcpClientManager().getDiscoveryState()` +- 读取 server count + +向 UI 暴露: + +- `discoveryState` +- `mcpServerCount` +- `isMcpReady` + +这意味着 Gemini 在 UI 层已经有了“渐进状态展示”的基础设施,而不是所有状态都绑在一次性 init promise 上。 + +### 7.2 用户体验上已经允许“先进入,再等待 MCP” + +`packages/cli/src/ui/AppContainer.tsx` 里存在非常明确的提示: + +> Waiting for MCP servers to initialize... Slash commands are still available and prompts will be queued. + +这说明 Gemini 的产品决策很明确: + +- UI 可以先起来 +- slash commands 先可用 +- prompt 可排队 +- MCP 不必阻塞整个会话出现 + +qwen-code 文档应该把这一点提升为显式建议,而不是只讨论内部 discover 时序。 + +### 7.3 但 Gemini 仍然存在“启动口径错位”问题 + +虽然 UI 可以先起来,但 `config.initialize()` 仍是 post-render 执行,这意味着: + +- 不补 instrumentation,就无法精确知道“用户能看到 UI”与“工具真正可用”的时间差 +- 如果只看 render 前 profile,会低估慢 MCP server 对整体体验的影响 + +所以 Gemini 提供了“可渐进可用”的产品经验,但并没有替代 qwen-code 当前文档里对 Phase 0 observability 的要求。 + +## 8. 对 qwen-code 的可执行建议 + +### 8.1 值得直接吸收的部分 + +1. **入口延迟加载交互 UI** + - 把重 UI 模块从主入口挪到动态 import +2. **把渲染模式开关提升为正式设计** + - `alternateBuffer` + - `terminalBuffer` + - `incrementalRendering` +3. **引入 flicker observability** + - 参考 `useFlickerDetector()` +4. **在全屏/alternate 模式优先落地滚动容器** + - 先从 `ScrollableList` 风格抽象做起 +5. **MCP 状态用事件流驱动 UI** + - 不要把“工具是否可用”缩减成一个布尔初始化结果 + +### 8.2 需要“改造后再借鉴”的部分 + +1. **渐进转 Static** + - 可以沿用 `findLastSafeSplitPoint()` 思路 + - 但要补 height-aware threshold、flush cadence、tool/thought 边界策略 +2. **终端主题自动检测** + - 可借鉴 `OSC 11` + - 但必须先明确 qwen-code 的主题/refreshStatic 回路 +3. **虚拟滚动** + - 先限制在 fullscreen / alternate buffer 场景 + - 避免直接把复杂度灌回 main-screen 基本路径 + +### 8.3 不建议直接照搬的部分 + +1. `refreshStatic()` 的 `clearTerminal` 路径 +2. 正则 Markdown parser +3. 同步 `lowlight(common)` 高亮架构 + +## 9. 如何在 qwen-code 中使用这份调研 + +这份调研最适合用来指导三类决策: + +1. **是否要先做“模式分层”而不是继续堆单点 patch** +2. **是否要把长会话滚动和主屏普通输出拆成不同路径** +3. **哪些 Gemini 做得不错,但不应被误认为 parser 或高亮架构的终局** + +在实际实施时,应与以下文档配套阅读: + +- `00-overview.md`:看总路线和阶段优先级 +- `02-screen-flickering.md`:看闪烁治理如何吸收 Gemini 的中层策略 +- `06-implementation-rollout-checklist.md`:看这些建议何时能进入灰度 +4. 把 post-render `config.initialize()` 当成“已解决启动问题” + +## 9. 对现有 TUI 优化文档的具体修订要求 + +本调研对现有设计文档提出三项硬性修订: + +1. `01-performance.md` + - 增加“Gemini 的入口延迟加载、post-render config.initialize、事件化 MCP 状态”分析 + - 把“渐进式 MCP 可用性”从纯内部时序问题升级成用户体验设计 +2. `02-screen-flickering.md` + - 增加 alternate/terminal buffer、incrementalRendering、flicker detector、`ScrollableList` / `VirtualizedList` 相关结论 +3. `03-rendering-extensibility.md` + - 明确写出:Gemini 在滚动与模式层领先,但在 Markdown / parser / highlighter 架构上并没有比 qwen-code 更先进 + +## 10. 一句话判断 + +Gemini CLI 最值得 qwen-code 借鉴的,不是它的 Markdown 或高亮,而是它把 **“不同终端模式、长会话滚动、MCP 渐进状态、闪烁观测”** 做成了系统设计。这些能力一旦补进 qwen-code,现有优化文档会从“点状 patch 清单”升级成真正的 TUI 架构方案。 diff --git a/docs/design/tui-optimization/05-claude-code-research.md b/docs/design/tui-optimization/05-claude-code-research.md new file mode 100644 index 000000000..f7673bb40 --- /dev/null +++ b/docs/design/tui-optimization/05-claude-code-research.md @@ -0,0 +1,496 @@ +# Claude Code 源码调研:自定义 Ink、滚动、渲染与 MCP + +> 调研对象:`/Users/gawain/Documents/codebase/opensource/claude-code` +> 目标:拆解 Claude Code 的 TUI 技术栈,识别哪些能力值得 qwen-code 吸收,哪些能力属于高维护成本的长期路线。 + +**事实边界**:除特别注明的外部终端资料外,本文件结论均基于本地源码树当前状态。上游若发生重构、文件移动或行为变更,需要重新核对后再引用到实施文档中。 + +## 1. 结论摘要 + +Claude Code 的 TUI 优势不是某一个组件特别强,而是它拥有一套**自定义终端渲染基础设施**: + +1. **启动层**:大量并行预取、feature-gated require、尽量避免把非关键路径阻塞在首屏之前 +2. **终端层**:自定义 Ink 实现,拥有自己的 screen buffer、diff、同步输出、硬件滚动、输入协议升级、XTVERSION 检测 +3. **滚动层**:`ScrollBox` + `useVirtualScroll` + `VirtualMessageList` 形成一套面向超长会话的滚动系统 +4. **渲染层**:`marked` + token cache + streaming block split + Suspense 高亮 +5. **状态层**:MCP 更新批量化、远端 transport 自动重连、工具/命令/资源变更通知增量刷入 UI + +对 qwen-code 来说,这份调研给出两个清晰判断: + +- **短中期可直接借鉴**:启动并行化、同步输出 gating、Markdown token cache、流式块边界、滚动量化、MCP 批量更新、终端能力探测 +- **长期高风险路线**:完全自研 Ink/diff renderer、DECSTBM scroll region、screen buffer + prevScreen blit、搜索/选择/滚动深度耦合的一整套基础设施 + +换句话说,Claude Code 不是一个“复制粘贴的目标实现”,而是一张非常清楚的技术路线图。 + +## 2. 关键文件地图 + +| 维度 | 文件 | +| --- | --- | +| 启动入口 | `src/main.tsx` | +| render options | `src/utils/renderOptions.ts` | +| 交互辅助 | `src/interactiveHelpers.tsx` | +| 自定义 Ink App | `src/ink/components/App.tsx` | +| 终端能力与输出 | `src/ink/terminal.ts` | +| frame diff | `src/ink/log-update.ts` | +| 输出缓冲 | `src/ink/output.ts` | +| isolated render | `src/ink/render-to-screen.ts` | +| 滚动容器 | `src/ink/components/ScrollBox.tsx` | +| 虚拟滚动 | `src/hooks/useVirtualScroll.ts` | +| 消息虚拟列表 | `src/components/VirtualMessageList.tsx` | +| 消息主视图 | `src/components/Messages.tsx` | +| Markdown | `src/components/Markdown.tsx` | +| 代码高亮 | `src/components/HighlightedCode.tsx` | +| query 主循环 | `src/query.ts` | +| 流式工具执行 | `src/services/tools/StreamingToolExecutor.ts` | +| MCP 连接上下文 | `src/services/mcp/MCPConnectionManager.tsx` | +| MCP 动态管理 | `src/services/mcp/useManageMCPConnections.ts` | + +## 3. 启动与 bootstrap 策略 + +### 3.1 启动入口大量利用“顶层并行副作用” + +`src/main.tsx` 在最前面就做了三件非常激进的事: + +1. `profileCheckpoint('main_tsx_entry')` +2. `startMdmRawRead()` +3. `startKeychainPrefetch()` + +源码注释写得很明确:这些副作用故意在其他重 imports 之前启动,好让: + +- MDM 配置读取 +- macOS keychain 读取 + +与后续的大量模块加载并行,而不是串行等待。 + +对 qwen-code 的启示: + +- 如果某些读取天然昂贵且结果稍后才用到,就应尽早 fire-and-forget +- 启动期的并行化不应只发生在 async 函数里,顶层副作用也是工具 + +### 3.2 feature-gated require 降低了首屏模块成本 + +`src/main.tsx` 大量使用: + +- `feature('FLAG') ? require('./module.js') : null` + +来做死代码消除和冷路径裁剪,比如: + +- coordinator mode +- assistant mode +- proactive mode +- transcript classifier + +这说明 Claude Code 非常重视一个事实:**即便功能很多,也不能让所有功能都参与冷启动路径**。这与 qwen-code 当前文档中的“产物体积优化”是强一致的,应在 `01-performance.md` 中明确加粗。 + +### 3.3 render options 兼顾 piped stdin 和交互 TTY + +`src/utils/renderOptions.ts` 提供 `getBaseRenderOptions()`: + +- 若 stdin 不是 TTY +- 且不是 CI / MCP / Windows +- 则尝试打开 `/dev/tty` 作为交互输入源 + +这说明 Claude Code 把“管道输入 + 交互 UI”视为正式场景。对 qwen-code 的启示: + +- 如果未来要增强 REPL / prompt queue / pasted transcript 场景,stdin override 机制应单独设计 +- 不要默认假设 `process.stdin` 永远等于交互输入 + +### 3.4 renderAndRun 把“首屏出现”和“后台预取”分开 + +`src/interactiveHelpers.tsx` 中: + +- 先 `root.render(element)` +- 再 `startDeferredPrefetches()` +- 再等待 `root.waitUntilExit()` + +这与 qwen-code 未来需要的方向高度一致:**关键路径和 deferred prefetch 必须明确分层**。像 MCP resource prefetch、analytics、插件扫描、技能索引等都应尽量晚于首屏。 + +## 4. 自定义终端与输出管线 + +### 4.1 `src/ink/terminal.ts`:把终端能力当成 first-class capability + +Claude Code 在 `src/ink/terminal.ts` 中定义了多种能力探测: + +- `isProgressReportingAvailable()`:判断 `OSC 9;4` 进度协议 +- `isSynchronizedOutputSupported()`:判断 DECSET 2026 同步输出 +- `isXtermJs()`:结合 `TERM_PROGRAM` 与 XTVERSION 判断 xterm.js 系终端 +- `supportsExtendedKeys()`:是否启用 kitty keyboard / modifyOtherKeys +- `hasCursorUpViewportYankBug()`:Windows / WT_SESSION 滚动 bug 检测 + +其中同步输出的策略非常务实: + +- tmux 直接视为不支持,避免“BSU/ESU 穿透外层终端但 atomicity 已被 tmux 打散”的伪支持 +- `SYNC_OUTPUT_SUPPORTED` 模块级计算一次,不在每帧重新判断 + +这比“看某个终端是不是大概率支持”更成熟。qwen-code 的 DECSET 2026 设计文档应补入这一层 gating 原则。 + +### 4.2 `src/ink/components/App.tsx`:输入协议升级和终端重连恢复 + +Claude 自定义 Ink App 在 raw mode 启用时做了很多事: + +- `EBP`:bracketed paste +- `EFE`:focus reporting +- `ENABLE_KITTY_KEYBOARD` +- `ENABLE_MODIFY_OTHER_KEYS` +- 通过 `TerminalQuerier` 异步发送 `xtversion()` + +并且它还处理了一个很现实的问题: + +- 通过 `STDIN_RESUME_GAP_MS = 5000` 检测 tmux detach / SSH reconnect / laptop wake +- gap 后重新 re-assert 终端模式 + +这是 qwen-code 当前设计里明显缺失的一层:**终端模式可能被外部环境悄悄重置,单次启动时设置一次并不够**。 + +### 4.3 `writeDiffToTerminal()` 已经是单 write + 可选同步输出 + +`src/ink/terminal.ts` 的 `writeDiffToTerminal()`: + +- 把 diff patches 序列化到一个字符串 buffer +- 可选前后包裹 `BSU` / `ESU` +- 最终只做一次 `terminal.stdout.write(buffer)` + +这与 qwen-code 当前的 `stdout.write` monkeypatch 完全不是一个层级: + +- qwen-code 是“拦截并改写已有输出” +- Claude 是“从 frame diff 到 terminal write 由自己控制” + +因此,短期内 qwen-code 应聚焦: + +- 输出层统计 +- 单帧 write 合并 +- 安全 gating + +而不是直接跳到“完全拥有 terminal write pipeline”。 + +## 5. Diff、双缓冲与硬件滚动 + +### 5.1 `src/ink/log-update.ts` 是 Claude 防闪烁的核心 + +这个文件做了几件关键事情: + +1. 维护 `prev` / `next` frame diff +2. 仅在必要时 full reset +3. 在 alt-screen + 安全条件下使用 DECSTBM scroll optimization +4. 在内容进入 scrollback 或 resize 复杂变化时,明确接受 full reset + +最重要的不是“它能 diff”,而是它对**哪些场景不值得 diff** 有很清楚的边界判断,例如: + +- viewport 缩短或宽度变化时直接 full reset +- 需要修改已经进入 scrollback 的行时直接 full reset +- 在 `decstbmSafe` 为假时,不冒险走 scroll region + +这对 qwen-code 文档的意义很大:未来如果做 diff patch,必须同时写清楚**退化到 full reset 的条件**。 + +### 5.2 DECSTBM 不是“有就赚”,它依赖 atomicity + +Claude 的 `log-update.ts` 注释写得很直白: + +- 如果 DECSTBM 到 diff 的序列不能被原子包裹 +- 终端会先显示“滚动了一半的中间状态” +- 反而形成可见的垂直跳跃 + +因此它把 `decstbmSafe` 作为一个显式条件。 + +这直接修正了很多文档里常见的误区:**硬件滚动只有在同步输出、缓冲与时序控制都具备时才值得开启**。qwen-code 的 `02-screen-flickering.md` 应明确把 DECSTBM 继续放在 Phase 3,而不是提前。 + +### 5.3 `src/ink/output.ts` 体现了“先收集操作,再写入 Screen” + +`Output` 不是直接往终端写,而是先收集操作: + +- `write` +- `blit` +- `shift` +- `clear` +- `noSelect` +- `clip` / `unclip` + +随后在 `get()` 阶段把这些操作真正落到 `Screen` buffer。 + +另一个非常关键的优化是它保留了跨帧 `charCache`: + +- grapheme cluster +- width +- styleId +- hyperlink + +这使得多数不变行在后续帧里不必重复 tokenize + stringWidth + style 处理。 + +对 qwen-code 的启示: + +- 即使暂时不做完整双缓冲,也可以先在 code/markdown 渲染层引入内容级 cache +- 未来如果做 screen buffer,char/style cache 会是成本回收的重要来源 + +## 6. 滚动与长会话体系 + +### 6.1 `ScrollBox` 的核心思想:滚动不走 React state + +`src/ink/components/ScrollBox.tsx` 是 Claude 长会话体验的关键组件。它的要点包括: + +- `scrollTo` / `scrollBy` 直接操作 DOM node 上的 `scrollTop` +- `pendingScrollDelta` 累积滚轮输入 +- `queueMicrotask()` 合并同一输入批次内的多次变更 +- `scheduleRenderFrom(el)` 只通知 renderer 重绘 +- `stickyScroll` 作为稳定信号,区分“手动打破贴底”与“渲染器跟随到底部” + +这是一种很明确的设计取舍: + +- 高频滚动事件不走 React state +- React 只负责在需要换 mounted range 时介入 + +这对 qwen-code 的虚拟滚动方案非常重要:**滚轮事件如果每 tick 都走 React setState,后面所有优化都会被抵消**。 + +### 6.2 `useVirtualScroll()` 把滚动性能问题拆到了常数级 + +`src/hooks/useVirtualScroll.ts` 里有一整套很值得记录的参数化策略: + +- `OVERSCAN_ROWS = 80` +- `SCROLL_QUANTUM = OVERSCAN_ROWS >> 1` +- `MAX_MOUNTED_ITEMS = 300` +- `SLIDE_STEP = 25` + +并且做了多项高价值细节处理: + +- 用 `useSyncExternalStore` 订阅滚动 +- snapshot 用 **quantized target scrollTop**,不是每个 wheel tick 都触发 React commit +- resize 时**按列宽比例缩放高度缓存**,而不是直接清空 +- resize 后冻结旧 range 两帧,避免 mount churn 二次闪烁 +- 使用 clamp bounds 防止异步重挂载期间出现空白 spacer + +这是 Claude 在“长会话 + 动态高度消息”问题上最有参考价值的部分。qwen-code 未来做虚拟滚动时,应该优先借鉴这里的: + +1. scroll quantization +2. resize height scaling +3. frozen range +4. clamp bounds + +而不是只抄一个 overscan list。 + +### 6.3 `VirtualMessageList` 不只负责滚动,还负责搜索/定位/hover 成本控制 + +`src/components/VirtualMessageList.tsx` 里还能看到一类容易被忽略的优化: + +- `fallbackLowerCache`:缓存可搜索文本的 lowercase 结果 +- `stickyPromptText()`:WeakMap 缓存 sticky prompt 文本 +- `scanElement()` / `MatchPosition`:为搜索高亮进行 isolated render + 精确定位 +- comment 中明确指出曾经的 per-item closure 造成 GC 压力,因此重构为稳定回调 + +这意味着 Claude 的“虚拟列表”并不是一个纯视觉容器,而是把: + +- 滚动 +- 搜索 +- 悬停 +- 点击 +- sticky header / sticky prompt + +全部视为同一个性能问题的一部分。 + +对 qwen-code 的含义是:如果未来要做 transcript 搜索、copy mode、message actions,最好从一开始就和虚拟滚动的设计一起考虑。 + +## 7. Markdown、高亮与流式渲染 + +### 7.1 `Markdown.tsx` 是 Claude 最可直接迁移的设计之一 + +`src/components/Markdown.tsx` 同时做了四件事: + +1. `marked` lexer 解析 Markdown +2. `TOKEN_CACHE_MAX = 500` 的模块级 token cache +3. 快速路径 `hasMarkdownSyntax()`,无语法迹象时跳过完整 lexer +4. 表格 token 走 ``,非表格内容走 `formatToken()` + +这对 qwen-code 的启示极为直接: + +- `marked` 迁移不该只讨论“parser 能不能工作” +- 应把 token cache、plain-text fast path、table special-case 一起设计 + +### 7.2 `StreamingMarkdown` 证明“块级稳定前缀”是成熟路径 + +Claude 的 `StreamingMarkdown` 实现与 Gemini 的 `findLastSafeSplitPoint()` 思路相通,但更彻底: + +- `stablePrefixRef` 持有只增不减的稳定前缀 +- 每次仅对“不稳定尾部”调用 `marked.lexer()` +- 最后一个 top-level block 视为 growing block +- stable 部分和 unstable 部分分别渲染 + +对 qwen-code 的结论是: + +- 我们现有的“安全分割点”方向是对的 +- 但文档需要明确最终目标是 **stable prefix + unstable suffix** +- 这可以同时服务于性能和防闪烁 + +### 7.3 高亮是异步资源,但 UI 不必阻塞 + +Claude 的 `Markdown` 组件使用: + +- `getCliHighlightPromise()` +- `}>` + +这形成了一个非常实用的策略: + +- 首帧先用无高亮版本保证内容出现 +- 高亮资源就绪后再增强 + +同时 `HighlightedCode.tsx` 也体现了类似思路: + +- 尝试 `expectColorFile()` +- 成功则按 theme + width render +- 失败走 `HighlightedCodeFallback` + +qwen-code 当前文档对“同步基线 + 异步预热”的方案,与 Claude 的现实做法是一致的,可以更有底气地推进。 + +## 8. MCP 管理与渐进更新 + +### 8.1 `MCPConnectionManager` 只是 context,真正的核心在 `useManageMCPConnections()` + +`src/services/mcp/MCPConnectionManager.tsx` 很轻,它主要把: + +- `reconnectMcpServer` +- `toggleMcpServer` + +暴露给 UI。 + +真正重要的是 `src/services/mcp/useManageMCPConnections.ts`。 + +### 8.2 MCP 更新是批量刷入的,而不是每次变更都 setState + +Claude 在 `useManageMCPConnections.ts` 里把 MCP 状态更新做成了**16ms 批处理窗口**: + +- `MCP_BATCH_FLUSH_MS = 16` +- 收集 `PendingUpdate[]` +- `setTimeout(flushPendingUpdates, 16)` +- 一次性更新 clients / tools / commands / resources + +这点非常值得 qwen-code 借鉴,因为它解决的是一个常见但隐蔽的问题: + +- 多个 server 同时回调 connect / tool list changed / resource list changed +- 如果每次都 setState,会引发 UI 抖动和重复 render + +### 8.3 对 MCP 通知协议的支持很完整 + +Claude 会监听多类 MCP 通知: + +- `ToolListChangedNotificationSchema` +- `PromptListChangedNotificationSchema` +- `ResourceListChangedNotificationSchema` +- channel / permission 相关通知 +- elicitation handler + +并在变更时: + +- 清对应 cache +- 拉取新 tools / commands / resources +- 增量更新 AppState + +这对 qwen-code 的启示是:**MCP 不应该只在启动 discover 一次**。一旦要支持真正长期运行的 TUI,会话期间的工具/资源变更也要有设计。 + +### 8.4 远端 transport 自动重连是重要的产品级能力 + +`useManageMCPConnections.ts` 还做了: + +- 对远端 transport 的自动重连 +- 指数退避 +- 最大尝试次数 +- server disable / enable 时取消旧 timer +- 手动 reconnect / toggle + +这意味着 Claude 把 MCP server 当作“长期存在、可能断线”的运行中依赖,而不是一次性启动资源。qwen-code 当前文档在这部分仍偏向“启动阶段问题”,需要补成“生命周期问题”。 + +## 9. Query / Tool 执行与 UI 稳定性 + +### 9.1 `StreamingToolExecutor` 把工具执行并发和 UI 顺序解耦 + +`src/services/tools/StreamingToolExecutor.ts` 的几个关键点: + +- 并发安全工具可并行执行 +- 非并发安全工具必须独占 +- 结果按工具到达顺序缓冲并依次吐出 +- progress messages 单独即时产出 +- streaming fallback / user interruption / sibling error 有不同的 synthetic error 路径 + +这件事对 TUI 性能很重要,因为它避免了“工具执行状态改变顺序混乱,UI 到处特判”的局面。qwen-code 如果未来增强 tool 流式显示,也应把执行调度与渲染顺序分层处理。 + +### 9.2 `query.ts` 显示 Claude 把流式、compact、tool、budget 当成一个整体状态机 + +`src/query.ts` 不是单纯的 API stream loop,它把这些都融合在一个 query state machine 中: + +- auto compact +- reactive compact +- tool orchestration +- token budget +- stop hooks +- streaming tool executor + +对 qwen-code 文档的启示是:一些“看似 UI 问题”的闪烁和卡顿,根源可能在 query / tool / compact 的事件节奏。如果只在组件层打补丁,最终收益会受限。 + +## 10. 如何在 qwen-code 中使用这份调研 + +这份调研最适合用来指导三类判断: + +1. **哪些能力可以作为短中期参考实现** + 例如同步输出 gating、token cache、stable prefix、MCP batch flush。 + +2. **哪些能力属于长期路线而不是近期承诺** + 例如完全自研 diff renderer、DECSTBM scroll region、深度耦合的搜索/选择/滚动体系。 + +3. **什么时候必须把“退化条件”写进设计** + Claude 的很多收益不是来自“总能更聪明地 diff”,而是来自“知道什么时候该 full reset、什么时候该保守禁用”。 + +在实际实施时,应与以下文档配套阅读: + +- `01-performance.md`:看冷启动、MCP 生命周期与 deferred work 如何落地 +- `02-screen-flickering.md`:看同步输出与底层 render 路线如何分阶段推进 +- `03-rendering-extensibility.md`:看 parser、streaming、高亮、虚拟滚动如何吸收 Claude 的经验 +- `06-implementation-rollout-checklist.md`:看哪些结论能进入当前灰度,哪些仍只能作为长期方向 + +## 10. 对 qwen-code 的可执行建议 + +### 10.1 近期就能吸收的能力 + +1. **启动并行化** + - 参考顶层预取、副作用并行、feature-gated require +2. **终端能力 gating** + - 支持 synchronized output / extended keys / xterm.js 检测 +3. **Markdown token cache + plain-text fast path** +4. **Streaming stable prefix / unstable suffix** +5. **MCP 批量状态更新** +6. **虚拟滚动的 scroll quantization / resize scaling / clamp 思路** + +### 10.2 中期可以部分迁移的能力 + +1. `ScrollBox` 风格的 DOM-mutation scroll path +2. transcript 搜索与虚拟列表协同设计 +3. 远端 MCP reconnect / list-changed 增量更新 +4. fallback-first 的异步高亮加载 + +### 10.3 长期才值得考虑的能力 + +1. 自定义 screen buffer +2. prevScreen blit +3. full diff patch pipeline +4. DECSTBM scroll region +5. 完整替换 Ink 内核 + +## 11. 不建议直接照搬的部分 + +1. **完整自定义 Ink 栈** + - 维护成本极高 + - 与 Claude 其他基础设施深度耦合 +2. **把滚动、搜索、选择、message actions 一次性全做** + - qwen-code 应先聚焦长会话滚动和防闪烁主路径 +3. **在现阶段直接上 DECSTBM** + - 没有同步输出与 frame ownership 做前提,会适得其反 + +## 12. 对现有 TUI 优化文档的具体修订要求 + +1. `01-performance.md` + - 加入 Claude 的启动并行化、top-level prefetch、feature-gated import 经验 + - 把 MCP 设计从“discover once”扩展到“批量更新 + 运行期变更 + reconnect” +2. `02-screen-flickering.md` + - 强调 synchronized output 的 gating 原则 + - 明确 DECSTBM 只能放在同步输出和 diff patch 之后 +3. `03-rendering-extensibility.md` + - 增加 `marked` token cache、fast path、streaming stable prefix + - 增加虚拟滚动实现细节,避免停留在概念层 + +## 13. 一句话判断 + +Claude Code 提供的最大价值,不是“它已经把 CLI 做得很复杂”,而是它把 **启动、终端、滚动、渲染、MCP 生命周期** 串成了一套完整的工程体系。qwen-code 不需要复制它的整套内核,但完全可以沿着同一张路线图,分阶段把收益最大的能力先补起来。 diff --git a/docs/design/tui-optimization/06-implementation-rollout-checklist.md b/docs/design/tui-optimization/06-implementation-rollout-checklist.md new file mode 100644 index 000000000..1f4f75353 --- /dev/null +++ b/docs/design/tui-optimization/06-implementation-rollout-checklist.md @@ -0,0 +1,206 @@ +# TUI 优化实施与灰度清单 + +> 本文档给 `00-05` 各设计/调研文档补齐实施门槛、验收标准、灰度顺序和回滚条件。目标不是重复方案细节,而是把“什么时候能开始做、做到什么算完成、什么情况下必须停下来”写清楚。 + +## 1. 使用方式 + +这份清单按四个层次组织: + +1. **通用前置条件**:所有 TUI 优化都必须满足的共性门槛 +2. **工作流验收清单**:启动/MCP、闪烁、渲染扩展各自的完成标准 +3. **灰度策略**:先开给谁、在哪些终端/场景开、如何扩大范围 +4. **回滚条件**:什么信号一出现就应降级或撤回 + +推荐执行顺序: + +1. 先完成通用前置条件 +2. 再按工作流分支实施 +3. 每一项变更进入灰度前,都在本清单中打勾 +4. 任何高风险功能默认要求特性开关 + +## 2. 通用前置条件 + +以下条件未满足前,不应启动高风险改造: + +- [ ] 启动 profile 口径已明确,且团队知道当前 profiler 是否运行在 sandbox child process +- [ ] 输出层 counters 已可用:`stdout_write_count`、`stdout_bytes`、`clear_terminal_count`、`erase_lines_optimized_count` +- [ ] 至少有一个固定基准场景可重复采样 10 次以上 +- [ ] 至少有一组慢 MCP server、长 Markdown 输出、长代码块输出的回归场景 +- [ ] 所有高风险变更都有显式回退开关 +- [ ] 文档中引用的外部终端能力结论都区分了“已由官方资料证明”和“仅待实机验证” + +## 3. 启动与 MCP 验收清单 + +对应文档: + +- `00-overview.md` +- `01-performance.md` +- `04-gemini-cli-research.md` +- `05-claude-code-research.md` + +### 3.1 Phase 0 观测基线 + +- [ ] `first_paint` 可记录 +- [ ] `input_enabled` 可记录 +- [ ] `config_initialize_start/end` 可记录 +- [ ] `mcp_server_ready:` 可记录 +- [ ] `mcp_all_servers_settled` 可记录 +- [ ] `gemini_tools_updated` 可记录 +- [ ] profile 输出区分 `interactive` / `non_interactive` +- [ ] profile 输出能标识是否来自 sandbox child process + +### 3.2 冷启动优化 + +- [ ] `loadSettingsAsync()` 仅接入启动主路径,不改变旧同步调用点签名 +- [ ] `initializeApp()` 的拆分没有引入 auth / IDE 初始化时序回归 +- [ ] 入口延迟加载不改变非交互路径、测试 harness、CLI 子命令行为 +- [ ] 至少在“无 MCP”“1 个快速 MCP”“3 个 MCP(含 1 个慢 server)”三组场景下完成前后对比 + +### 3.3 渐进式 MCP 可用性 + +- [ ] 启动阶段使用 `skipDiscovery` + 增量发现,不再 fire-and-forget 调用全量 `discoverMcpTools()` +- [ ] 单 server replace 不会清空其他 server 的 tools/prompts +- [ ] `ConfigInitDisplay` 或等价 UI 能区分“连接进度”和“工具可用性” +- [ ] 每个 server ready 后的 `GeminiClient.setTools()` 刷新具备 debounce +- [ ] 进行中的模型请求不会因 tools 刷新中途改变工具集合 +- [ ] discovery timeout 与 tool-call timeout 已拆分 +- [ ] 运行期 `refreshMemory()/refreshTools()` 不再默认全量 `restartMcpServers()` + +### 3.4 启动/MCP 退出标准 + +以下条件同时满足时,可认为启动/MCP 改造完成一阶段: + +- [ ] 无 MCP 场景启动无退化 +- [ ] 快速 MCP server 的首工具注册时间显著早于 `mcp_all_servers_settled` +- [ ] 慢 MCP server 失败/超时不会让已可用 tools 消失 +- [ ] runtime refresh 不再引发全量 tool 抖动 +- [ ] 所有新增行为可通过特性开关关闭 + +## 4. 闪烁治理验收清单 + +对应文档: + +- `02-screen-flickering.md` +- `04-gemini-cli-research.md` +- `05-claude-code-research.md` + +### 4.1 前置判断 + +- [ ] 团队已明确区分 Ink 的 `eraseLines` 重绘问题和 `refreshStatic() -> clearTerminal` 问题 +- [ ] 已有基础 flicker 指标或可替代观测数据 +- [ ] 已有 main-screen、alternate/fullscreen、tmux、SSH 四类场景的最小回归样例 + +### 4.2 同步输出(DECSET 2026) + +- [ ] 默认启用前已有 runtime probe 或终端家族 allowlist +- [ ] `bsu_frame_count === esu_frame_count` +- [ ] Buffer/string/callback 三类 `stdout.write()` 调用语义均已回归验证 +- [ ] screen reader 场景明确不安装或不启用该优化 +- [ ] tmux / SSH 组合路径未被直接默认开启 +- [ ] `QWEN_CODE_LEGACY_RENDERING=1` 可完整回退 + +### 4.3 流式节流 + +- [ ] content stream 节流已覆盖 +- [ ] thought stream 节流已覆盖 +- [ ] stream end / cancel / tool call / confirm dialog 前会强制 flush +- [ ] shell output 现有节流行为不退化 + +### 4.4 `refreshStatic()` 与渲染模式分层 + +- [ ] `refreshStatic()` 的触发来源已梳理清楚 +- [ ] main-screen 路径与 alternate/fullscreen 路径的目标分离 +- [ ] resize 导致的重排不会默认演变为整屏 `clearTerminal` +- [ ] active view / compact toggle / manual clear 三类路径分别有回归样例 + +### 4.5 闪烁治理退出标准 + +- [ ] 正常流式输出场景 `stdout.write` 频率显著下降 +- [ ] 已支持的终端中肉眼可见的帧撕裂明显减轻 +- [ ] 未纳入 allowlist 的终端不出现明显退化 +- [ ] 所有高风险策略可单独回退 + +## 5. 渲染与扩展验收清单 + +对应文档: + +- `03-rendering-extensibility.md` +- `04-gemini-cli-research.md` +- `05-claude-code-research.md` + +### 5.1 Markdown / parser + +- [ ] 不缓存 `ReactNode` +- [ ] parser cache key 不保留完整超长原文引用 +- [ ] 已定义 plain-text fast path +- [ ] 已定义 streaming stable prefix / unstable suffix 策略 +- [ ] HTML policy、GFM extension policy、partial block policy 已写明 + +### 5.2 代码高亮 + +- [ ] 当前帧仍保留同步 fallback,不在 render 路径直接 `await` +- [ ] 高亮缓存 key 覆盖 language、theme、width、settings 版本 +- [ ] `highlightAuto()` 有长度和 grammar 集合限制 +- [ ] pending streaming 代码块不会触发最重路径 + +### 5.3 虚拟滚动 + +- [ ] 仅在 fullscreen / alternate 路径先行 +- [ ] wheel/scroll 高频输入不直接驱动 React 高频 state 更新 +- [ ] resize 后高度缓存有策略,不是简单全量清空 +- [ ] sticky bottom / copy mode / search mode 的语义预留已写明 +- [ ] 不出现 blank spacer / mounted range 抖动 + +### 5.4 渲染扩展退出标准 + +- [ ] Markdown fixture 测试覆盖旧 parser 与新 parser 共同边界 +- [ ] 表格、代码块、列表、未闭合块在 streaming 场景不退化 +- [ ] 高亮资源未就绪时内容仍优先可见 +- [ ] 长会话场景 CPU / commit 次数有可测下降 + +## 6. 灰度顺序 + +建议的灰度顺序如下: + +1. **内部 dogfood** + - instrumentation + - `loadSettingsAsync` + - 启动前初始化并行化 + - 流式节流 + +2. **受控特性开关** + - MCP 渐进可用性 + - runtime MCP incremental refresh + - Markdown token/block cache + - 高亮缓存 / 预热 + +3. **按终端家族定向开启** + - DECSET 2026 同步输出 + - ANSI 16 色默认主题检测 + +4. **最后灰度** + - `marked` parser 切换 + - fullscreen / alternate 虚拟滚动 + - 更激进的 render pipeline 改造 + +## 7. 回滚条件 + +出现以下任一情况时,应暂停扩大灰度,必要时回滚: + +- 启动 profile 无法稳定复现或口径混乱 +- `GeminiClient.setTools()` 刷新导致进行中的请求出现工具不一致 +- runtime refresh 仍触发全量 tool 抖动 +- 未纳入 allowlist 的终端出现明显闪屏或输出损坏 +- `stdout.write()` callback / return value 语义被破坏 +- 新 parser 在未闭合 Markdown、长代码块、表格场景出现功能性回归 +- 虚拟滚动出现 blank spacer、sticky bottom 失效、copy/search mode 退化 + +## 8. 提交前检查 + +每次文档或实现准备提交前,建议至少确认: + +- [ ] 文档中的 API 名称与当前源码一致 +- [ ] 代码示意若不是现有 API,已明确标注“概念实现”或“建议 wrapper” +- [ ] 外部终端支持结论带有来源或明确标注“待验证” +- [ ] `git diff --check` 通过 +- [ ] 新增文档已纳入索引