docs: harden tui optimization design

This commit is contained in:
秦奇 2026-04-21 16:25:42 +08:00
parent 88efd775db
commit e2be1bd548
7 changed files with 1549 additions and 145 deletions

View file

@ -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 的意义 |
| --- | --- | --- | --- |
| 启动策略 | 入口动态导入交互 UIrender 前后初始化分层 | 顶层并行预取feature-gated requiredeferred 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) | 实施门禁、验收、灰度与回滚清单 |

View file

@ -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<void> {
- 单个 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<void> {
```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 子命令的模块求值顺序

View file

@ -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 PatchPhase 3
@ -355,36 +409,53 @@ class ScreenBuffer {
- xterm.js5 行以下即时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 的终端:默认不启用同步输出,仍保留开关和终端矩阵验证

View file

@ -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 {
**现状**:所有历史消息通过 `<Static>` 追加到终端 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 的 `<ScrollBox>` 组件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 的 `<ScrollBox>``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()`(大多数短回复) |
| 流式优化 | 在块边界分割,仅重解析最后一个块 |
| 代码高亮 | `<Suspense>` 包裹的可选 CLI 语法高亮 |
| 表格 | React 组件 `<MarkdownTable>` + 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 Codeparser、streaming 与虚拟滚动一体化
| 能力 | 实现方式 |
| --- | --- |
| Markdown 解析 | `marked` 库 + LRU token 缓存500 条) |
| 快速路径 | 正则检测无 MD 语法 → 跳过 `marked.lexer()` |
| 流式优化 | `StreamingMarkdown` 稳定前缀,仅重解析最后一个块 |
| 代码高亮 | `<Suspense>` 包裹的可选 CLI 语法高亮 |
| 表格 | React 组件 `<MarkdownTable>` |
| 超链接 | 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 主题兼容性

View file

@ -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 不是只依赖 `<Static>`。它在 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 区域
它在源码注释里明确把这件事定义为:
- 提升性能
- 尽量把内容挪进 `<Static />`
- 减少 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 resize300ms debounce
- 若干 UI 状态切换
这说明 Gemini 虽然在滚动/模式层做得很强,但**main-screen 的静态区刷新仍然有整屏清除代价**。这也是 qwen-code 不应照搬的点。
## 5. 滚动、长会话与交互
### 5.1 `MainContent` 已经有两套内容呈现路径
`packages/cli/src/ui/components/MainContent.tsx`Gemini 会根据模式选择:
- main-screen 路径:`<Static>` + pending 区域
- alternate/terminal buffer 路径:`<ScrollableList>`
并在 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 架构方案。

View file

@ -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 bufferchar/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 走 `<MarkdownTable>`,非表格内容走 `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()`
- `<Suspense fallback={<MarkdownBody highlight={null} />}>`
这形成了一个非常实用的策略:
- 首帧先用无高亮版本保证内容出现
- 高亮资源就绪后再增强
同时 `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 不需要复制它的整套内核,但完全可以沿着同一张路线图,分阶段把收益最大的能力先补起来。

View file

@ -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:<name>` 可记录
- [ ] `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` 通过
- [ ] 新增文档已纳入索引