diff --git a/docs/design/tui-optimization/00-overview.md b/docs/design/tui-optimization/00-overview.md index 193d6a377..6b5a08f0e 100644 --- a/docs/design/tui-optimization/00-overview.md +++ b/docs/design/tui-optimization/00-overview.md @@ -4,14 +4,16 @@ ## 1. 背景与动机 -qwen-code 的 TUI 层基于 **Ink 6.2.3 + React 19** 构建,当前面临三个系统性挑战: +qwen-code 的 TUI 层基于 **Ink 6.2.3 + React 19** 构建,当前面临三个系统性挑战。下列问题需要先用源码口径校准后再实施,避免优化目标与真实瓶颈错位: -1. **启动性能**:启动流程串行执行,配置 MCP Server 时尤为缓慢,用户需等待所有 Server 连接完成后才能使用工具 +1. **启动性能**:启动流程包含多段串行初始化;交互式模式下 `config.initialize()` 在 UI 首次渲染后执行,配置 MCP Server 时工具声明和实际可用性仍会被慢 Server、工具注册刷新和 Gemini tools 更新路径影响 2. **屏幕闪烁**:Ink 的全量重绘机制导致流式输出时严重闪烁,在 tmux/SSH 环境下尤为突出(社区报告高达 4,000-6,700 次/秒的滚动事件) -3. **渲染能力与可扩展性**:自定义正则 Markdown 解析器功能受限,缺少 LaTeX 数学公式、终端超链接等支持,主题系统硬编码 hex 颜色导致部分终端兼容性问题 +3. **渲染能力与可扩展性**:自定义正则 Markdown 解析器功能受限,缺少 LaTeX 数学公式、终端超链接等支持;主题系统默认 hex 主题可能影响透明背景终端 这些问题在 GitHub Issues 中被大量报告(qwen-code#1778, #2748, #2877; claude-code#9935, #37283, #14641 等),是当前最主要的用户体验痛点。 +**重要校准**:当前启动分析器只覆盖 UI render 之前的 checkpoint,尚未覆盖交互式 `config.initialize()`、MCP 首个工具注册、全部 MCP 发现完成、Gemini tools 声明刷新等阶段。因此本文档的实施顺序必须先补观测,再用真实数据确认优先级。 + ## 2. 现状分析 ### 2.1 当前架构 @@ -35,8 +37,8 @@ Entry (gemini.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 | Promise.all 并行连接,10分钟超时 | `packages/core/src/tools/mcp-client-manager.ts` | -| 启动分析 | 环境变量开启的 checkpoint 记录器 | `packages/cli/src/utils/startupProfiler.ts` | +| MCP | 跨 Server 并行发现;整体仍等待全部完成,默认 10 分钟超时;工具注册和 Gemini tools 刷新需要拆开设计 | `packages/core/src/tools/mcp-client-manager.ts` | +| 启动分析 | 环境变量开启的 checkpoint 记录器;当前主要覆盖 render 前阶段 | `packages/cli/src/utils/startupProfiler.ts` | ### 2.2 竞品分析:Claude Code @@ -50,7 +52,7 @@ Claude Code 使用**自研的 Ink 深度定制版本**(非 npm 库),包含 | 布局检测 | 布局稳定时窄范围 diff,变化时全量重绘 | 无 diff,始终全量 | | 样式池化 | StylePool 整数 ID 内化 + 转换缓存 | 无,每次重新计算 | | Markdown | marked 库 + LRU 令牌缓存(500条) | 自定义正则,无缓存 | -| MCP 启动 | 提前并行启动 + Promise.race 超时 | UI 渲染后才启动 | +| MCP 启动 | 提前并行启动 + Promise.race 超时 | UI 渲染后初始化,跨 Server 并行但整体等待 | ### 2.3 社区反馈汇总 @@ -58,7 +60,7 @@ Claude Code 使用**自研的 Ink 深度定制版本**(非 npm 库),包含 | ---------- | ----------------------------------------------- | -------- | | 屏幕闪烁 | qwen-code#1778, #2748; claude-code#9935, #37283 | 高 | | 启动慢 | qwen-code#2748; claude-code#5653, #29201 | 高 | -| 表格渲染 | claude-code#14641, #22311 | 中 | +| 表格渲染 | claude-code#14641, #22311;qwen-code 当前已有 ANSI/CJK 回归测试,需以可复现缺陷为准 | 中 | | 主题/颜色 | qwen-code#2877; claude-code#34702, #15771 | 中 | | 窄屏问题 | claude-code#13504, #18493, #5408 | 中 | | LaTeX 支持 | claude-code#21433 | 低 | @@ -67,33 +69,40 @@ Claude Code 使用**自研的 Ink 深度定制版本**(非 npm 库),包含 | 工作流 | 核心问题 | 关键指标 | 依赖关系 | | -------------- | -------------------------------------- | ------------------------------ | -------------------------- | -| **启动性能** | 串行启动流程;MCP 阻塞工具可用性 | 可交互时间 (TTI) | 独立,最先启动 | -| **屏幕闪烁** | Ink 全量重绘;无同步输出 | 闪烁事件/秒,stdout 字节/帧 | 部分依赖启动性能(节流) | -| **渲染与扩展** | 正则解析器脆弱;缺少格式支持;主题限制 | 格式覆盖率,渲染耗时,可配置性 | 依赖闪烁修复(稳定输出层) | +| **观测基线** | 现有 profile 不覆盖 render 后初始化和输出层 | first paint、TTI、MCP 首工具、stdout writes/sec | 所有优化的前置条件 | +| **启动性能** | 串行启动流程;MCP 工具声明刷新不完整 | first paint、input enabled、首个 MCP 工具可被模型使用 | 依赖观测基线 | +| **屏幕闪烁** | Ink 全量重绘;无同步输出 | 闪烁事件/秒,stdout writes/sec、clearTerminal 次数 | 依赖输出层观测 | +| **渲染与扩展** | 正则解析器脆弱;缺少格式支持;主题限制 | 格式覆盖率,parse/highlight 耗时,可配置性 | 依赖稳定输出层 | -**执行顺序**:启动性能(最独立)-> 屏幕闪烁(解锁渲染改进)-> 渲染与扩展(基于稳定输出层) +**执行顺序**:观测基线 -> 屏幕闪烁低风险治理 -> 启动/MCP 渐进可用 -> 渲染缓存与扩展。MCP 与渲染可并行推进,但必须共享同一套指标口径。 ## 4. 分阶段实施计划 -### Phase 1:快速见效(第 1-4 周) +### Phase 0:观测基线(第 1 周) + +| 变更 | 工作流 | 风险 | 预期收益 | +| ---- | ------ | ---- | -------- | +| 扩展 startup profiler:first paint、input enabled、`config.initialize()`、首个/全部 MCP 工具、Gemini tools 刷新 | 性能 | 低 | 避免用 render 前指标误判启动瓶颈 | +| 为 stdout 输出层增加 counters:writes/sec、bytes/sec、`clearTerminal` 次数、eraseLines 优化次数、BSU/ESU 平衡 | 闪烁 | 低 | 后续防闪烁方案可量化验收 | + +### Phase 1:快速见效(第 2-5 周) | 周次 | 变更 | 工作流 | 风险 | 预期收益 | | ---- | ----------------------------------------------------------- | ------ | ---- | --------------------------------- | -| 1 | 同步输出 DECSET 2026 | 闪烁 | 低 | 消除大部分可见闪烁 | -| 1 | 流式更新节流(60ms 批处理) | 闪烁 | 低 | stdout.write 从 50+/秒降至 <20/秒 | -| 2 | Markdown 解析结果缓存 | 渲染 | 低 | 缓存命中时渲染耗时降低 70%+ | -| 2 | 代码高亮缓存 + 语法库懒加载 | 渲染 | 低 | 启动加速 + 重复渲染消除 | -| 3 | 并行配置加载(异步 I/O) | 性能 | 低 | 配置加载耗时降低 30-50% | -| 3 | 启动分析器增强 | 性能 | 低 | 持续监控回归 | -| 4 | 并行化 UI 前初始化(i18n 与 config 并行 + auth 与其他并行) | 性能 | 低 | 启动时间减少 200-400ms | -| 4 | ANSI 16 色默认主题检测 | 渲染 | 中 | 修复透明终端兼容性 | +| 2 | 同步输出 DECSET 2026(先 instrumentation,再默认开启或特性开关) | 闪烁 | 中 | 消除大部分可见帧撕裂 | +| 2 | 流式更新节流(content + thought;结束/取消/工具调用时立即 flush) | 闪烁 | 低 | stdout.write 从 50+/秒降至 <20/秒 | +| 3 | Markdown token/block 缓存(不缓存 ReactNode) | 渲染 | 低 | 缓存命中时解析耗时显著下降 | +| 3 | 代码高亮缓存 + `highlightAuto` 限制/预热策略 | 渲染 | 中 | 重复渲染消除,降低大块代码成本 | +| 4 | `loadSettingsAsync` 渐进引入,保留同步 wrapper | 性能 | 中 | 配置加载耗时降低,避免大范围破坏 | +| 5 | 并行化 UI 前初始化(i18n 与 config 并行 + auth 与其他并行) | 性能 | 低 | 启动时间减少 200-400ms | +| 5 | ANSI 16 色默认主题检测 | 渲染 | 中 | 改善透明终端兼容性 | ### Phase 2:架构改进(第 5-10 周) | 周次 | 变更 | 工作流 | 风险 | | ---- | ------------------------------------ | ------ | ---- | -| 5-6 | 渐进式 MCP 可用性 + 超时控制 | 性能 | 中 | -| 6-7 | 动态内容高度管理 + 渐进提升到 Static | 闪烁 | 中 | +| 6-7 | 渐进式 MCP 可用性 + Gemini tools debounce 刷新 | 性能 | 中 | +| 7 | 动态内容高度阈值优化 + 现有渐进提升增强 | 闪烁 | 中 | | 7-8 | 切换到 marked 解析器(特性开关) | 渲染 | 中 | | 8-9 | 智能 refreshStatic()(定向更新) | 闪烁 | 中 | | 9-10 | OSC 8 终端超链接 | 渲染 | 低 | @@ -114,13 +123,13 @@ Claude Code 使用**自研的 Ink 深度定制版本**(非 npm 库),包含 - **已有兼容**:`QWEN_CODE_LEGACY_ERASE_LINES=1` 保留用于擦除行优化的回退 - **主题**:仅默认选择变更,所有 hex 颜色主题保留可用 - **解析器**:特性开关控制,旧解析器作为过渡期回退 -- **MCP**:所有 Server 快速响应时行为不变 +- **MCP**:所有 Server 快速响应时行为等价;慢 Server 不再阻塞快 Server,但工具声明只保证从下一次模型请求开始生效 ## 6. 验证策略 -1. **自动化基准测试**:启动耗时、渲染时间、stdout 字节/帧 +1. **自动化基准测试**:启动分段耗时、渲染时间、stdout writes/sec、stdout 字节/帧 2. **多终端视觉测试**:iTerm2、Terminal.app、WezTerm、kitty、Windows Terminal、tmux -3. **回归检测**:滚动启动 profile 对比(增强后的分析器) +3. **回归检测**:滚动启动 profile 对比;MCP 首工具/全工具可用时间对比 4. **边界场景**:窄终端 (< 40 列)、超长输出 (5000+ 行)、CJK 内容、tmux/SSH 5. **特性开关**:Phase 2+ 所有变更可安全回滚 diff --git a/docs/design/tui-optimization/01-performance.md b/docs/design/tui-optimization/01-performance.md index 92b5b0bf2..9a9f2209e 100644 --- a/docs/design/tui-optimization/01-performance.md +++ b/docs/design/tui-optimization/01-performance.md @@ -6,7 +6,7 @@ ### 1.1 启动流程现状 -启动入口位于 `packages/cli/src/gemini.tsx` 的 `main()` 函数(第 290 行),执行一个**严格串行**的初始化管线: +启动入口位于 `packages/cli/src/gemini.tsx` 的 `main()` 函数(第 290 行),执行一个包含多段串行等待的初始化管线: ``` T0: profileCheckpoint('main_entry') ← 第 291 行 @@ -40,7 +40,7 @@ T0: profileCheckpoint('main_entry') ← 第 291 行 ### 1.2 各阶段耗时分析 -基于启动分析器(`packages/cli/src/utils/startupProfiler.ts`)的 checkpoint 数据和代码分析: +当前启动分析器(`packages/cli/src/utils/startupProfiler.ts`)只记录到 UI render 前后的粗粒度 checkpoint;交互式模式下 `config.initialize()` 是在 `AppContainer` mount 后的 effect 中执行,现有 profile 文件并不会直接覆盖这段耗时。因此下表是**源码路径推导 + 需补充 instrumentation 验证的初始估计**,不能作为最终性能基线。 | 阶段 | 估计耗时 | I/O 操作 | 瓶颈类型 | | --------------------------------- | ---------- | ------------------ | ------------ | @@ -55,12 +55,22 @@ T0: profileCheckpoint('main_entry') ← 第 291 行 | config.initialize() | 500ms-5s+ | 文件扫描 + MCP | MCP 子进程 | | MCP 发现 | 500ms-10s+ | 子进程启动 + 网络 | 网络延迟 | +**必须补齐的指标口径**: + +- `first_paint`:Ink 首次 render 完成 +- `input_enabled`:用户可以输入且不会被启动阶段阻塞 +- `config_initialize_start/end`:交互式初始化耗时 +- `mcp_first_tool_registered`:首个 MCP Server 完成 discover 并注册工具 +- `mcp_all_servers_settled`:所有 MCP Server 成功、失败或超时 +- `gemini_tools_updated`:Gemini client 的 tools declaration 已刷新,可被下一次请求使用 + **关键发现**: 1. Settings 加载使用 `fs.readFileSync` 串行读取多个文件(`packages/cli/src/config/settings.ts`) -2. `loadCliConfig()` 和 `initializeApp()` 之间无数据依赖,但串行执行 -3. MCP 发现虽然跨 Server 并行(`Promise.all`),但位于 `config.initialize()` → `createToolRegistry()` → `discoverAllTools()` 调用链中,被前置的 FileDiscovery、Git、Hook 等初始化阻塞 -4. MCP 默认超时 10 分钟(`MCP_DEFAULT_TIMEOUT_MSEC = 10 * 60 * 1000`),一个慢 Server 会拖慢整个工具可用性 +2. `initializeApp()` 依赖 `loadCliConfig()` 产出的 `config`,不能整体并行;可优化的是 i18n 与 `loadCliConfig()` 并行,以及 config 就绪后 auth、startup warnings、Kitty 检测等独立步骤并行 +3. MCP 发现跨 Server 并行(`Promise.all`),但 `discoverAllMcpTools()` 仍等待所有 Server settle 后才把 discovery state 标记为完成;UI 只能看到整体完成/失败语义,缺少首工具和逐 Server 可用指标 +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 秒 ### 1.3 MCP 初始化详细分析 @@ -98,28 +108,66 @@ async discoverAllMcpTools(cliConfig: Config): Promise { **问题**: -- `await Promise.all(discoveryPromises)` 意味着最慢的 Server 决定整体完成时间 -- 工具注册发生在所有 Server 发现完成后,而非逐个注册 -- 默认超时 10 分钟过长,用户需等待不可接受的时间 +- `await Promise.all(discoveryPromises)` 意味着最慢的 Server 决定整体 discovery 完成时间 +- 单个 Server 的工具会在 `client.discover(cliConfig)` 完成时注册,但 ToolRegistry 没有对外暴露稳定的“server ready / tools changed”事件语义 +- `GeminiClient.setTools()` 只在 chat 初始化或显式调用时刷新 tools declaration;后续 MCP 工具动态加入后,如果不额外调用,模型不会自动拿到新工具 +- `ToolRegistry.discoverMcpTools()` 当前会先清理 discovered tools/prompts,不适合直接作为 fire-and-forget 的渐进发现入口 +- 默认超时 10 分钟对 discovery 过长,但对长耗时 tool call 可能合理,必须拆开配置和默认值 - 发现流程在 `config.initialize()` → `createToolRegistry()` → `registry.discoverAllTools()` 调用链中被前置初始化步骤阻塞 ## 2. 解决方案 +### 2.0 [P0] 启动观测基线先行 + +**目标**:先把启动过程拆成可验证的指标,再执行并行化和 MCP 渐进加载,避免用 render 前 checkpoint 推断 render 后瓶颈。 + +**新增 checkpoint/event**: + +| 指标 | 触发位置 | 用途 | +| ---- | -------- | ---- | +| `first_paint` | `startInteractiveUI()` render 完成后 | 衡量用户首次看到 UI 的时间 | +| `input_enabled` | AppContainer 可接收输入时 | 衡量真实可交互时间 | +| `config_initialize_start/end` | `AppContainer` 调用 `config.initialize()` 前后 | 覆盖当前 profiler 盲区 | +| `tool_registry_created` | `Config.createToolRegistry()` 完成后 | 区分内置工具就绪与 MCP 发现 | +| `mcp_server_ready:` | 单个 MCP Server discover 完成并注册工具后 | 衡量首工具/逐 Server 可用性 | +| `mcp_all_servers_settled` | MCP 发现全部成功、失败或超时后 | 衡量整体完成时间 | +| `gemini_tools_updated` | `GeminiClient.setTools()` 完成后 | 确认模型下一次请求能看到新工具 | + +**输出层指标**: + +- `stdout_write_count`、`stdout_bytes`、`writes_per_second` +- `clear_terminal_count` +- `erase_lines_optimized_count` +- `bsu_frame_count`、`esu_frame_count`、不平衡帧数 + +**影响范围**: + +- `packages/cli/src/utils/startupProfiler.ts` +- `packages/cli/src/gemini.tsx` +- `packages/cli/src/ui/AppContainer.tsx` +- `packages/core/src/config/config.ts` +- `packages/core/src/tools/mcp-client-manager.ts` +- `packages/cli/src/ui/utils/terminalRedrawOptimizer.ts` + ### 2.1 [P0] 并行 Settings 加载 **现状**:`loadSettings()` 在 `packages/cli/src/config/settings.ts` 中通过 `fs.readFileSync` 串行读取系统默认、系统配置、用户配置、工作区配置等 4-5 个 JSON 文件。 +同时需要注意,`loadSettings()` 不只是读文件:它还包含 JSON 恢复、损坏配置重命名、迁移持久化、`loadEnvironment()` 调用等副作用,并且被命令、设置对话框和测试大量复用。因此不能直接把现有同步函数改成异步签名并要求所有调用点一次性迁移。 + **方案**: -1. 将 `fs.readFileSync` 替换为 `fs.promises.readFile` -2. 使用 `Promise.all` 并行读取所有配置文件 -3. 读取完成后再执行串行的合并逻辑(合并本身很快,瓶颈在 I/O) -4. 将 `loadSettings()` 签名从同步改为异步 +1. 新增 `loadSettingsAsync()`,仅用于 CLI 启动主路径 +2. 抽出“读取多个 settings 文件”的纯 I/O 层,使用 `Promise.all` 并行读取 +3. 保留现有 `loadSettings()` 同步 wrapper,供命令、设置对话框、测试继续使用 +4. 读取完成后复用同一套合并、迁移、恢复、`loadEnvironment()` 逻辑,确保副作用顺序不变 +5. 迁移稳定后再评估是否统一异步化所有调用点 **影响范围**: - `packages/cli/src/config/settings.ts` — 核心修改 -- `packages/cli/src/gemini.tsx:293` — 调用处加 `await`(`main()` 已是 async) +- `packages/cli/src/gemini.tsx:293` — 启动主路径改用 `await loadSettingsAsync()` +- 设置对话框、命令和测试暂不强制迁移,避免大范围行为变化 **预期收益**:Settings 加载阶段耗时降低 30-50%(从 ~150ms 降至 ~80ms)。 @@ -130,6 +178,13 @@ QWEN_CODE_PROFILE_STARTUP=1 qwen-code --prompt "test" # 对比 after_load_settings 阶段耗时 ``` +**回归约束**: + +- 损坏 settings 文件仍会按原策略备份/恢复 +- settings migration 仍只执行一次,且写回顺序不变 +- `loadEnvironment()` 必须在 merged settings 形成后执行 +- 同步调用点在第一阶段行为不变 + ### 2.2 [P0] 并行化 UI 前初始化 **现状**:`loadCliConfig()` 之后,`initializeApp(config, settings)` 串行执行 i18n、auth、IDE 连接。而 `initializeApp` 依赖 `config` 参数,因此不能与 `loadCliConfig` 并行。但 `initializeApp` 内部的子步骤可以并行化,且启动警告收集、Kitty 协议检测等与 `initializeApp` 无依赖关系。 @@ -170,42 +225,48 @@ const [_auth, startupWarnings, userWarnings, _kitty] = await Promise.all([ ### 2.3 [P1] 渐进式 MCP 可用性 -**现状**:所有 MCP Server 完成发现后才统一注册工具,用户在此之前无法使用任何 MCP 工具。 +**现状校准**: + +- `McpClient.discover()` 会在单个 Server discover 完成后把工具注册进 ToolRegistry,因此“所有 Server 完成后才统一注册工具”并不准确 +- 但 `discoverAllMcpTools()` 仍等待所有 Server settle 后才完成,慢 Server 会拖延整体 discovery state、初始化完成语义和 UI 反馈 +- `ToolRegistry.discoverMcpTools()` 会先 `removeDiscoveredTools()` 并清空 prompt registry,不适合作为异步 fire-and-forget 入口,否则可能短暂移除已可用工具 +- `GeminiClient.setTools()` 不会在 MCP 工具动态加入时自动触发;不刷新 tools declaration 时,模型下一次请求仍可能看不到新工具 **方案**: -1. **提前启动 MCP 发现**:在 config 加载完成后立即开始 MCP 发现(fire-and-forget),不等 UI 渲染 -2. **逐 Server 注册**:每个 Server 发现完成后立即注册其工具到 ToolRegistry,而非等待所有 Server -3. **合理超时**:将发现阶段默认超时从 10 分钟降至 30 秒,支持 `serverConfig.timeout` 覆盖 -4. **UI 进度指示**:添加 "N/M MCP Servers 已连接" 状态显示 +1. **内置工具先可用**:交互式启动时先 `createToolRegistry({ skipDiscovery: true })`,完成内置工具、命令和必要 prompt 的初始化 +2. **MCP 后台发现**:在 registry 创建后启动 MCP 发现任务,但不走会全量清空 discovered tools 的 `discoverMcpTools()` 路径 +3. **逐 Server 原子注册**:为每个 Server 使用“仅移除该 Server 旧工具/prompt → connect → discover → 注册新工具”的原子路径;优先复用或扩展 `discoverToolsForServer()` +4. **tools declaration 刷新**:每个 Server ready 后触发 `toolRegistryChanged` 事件,并 debounce 调用 `config.getGeminiClient().setTools()`;只保证下一次模型请求使用新工具,不修改进行中的请求 +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`): ```typescript -async discoverAllMcpTools( +async discoverMcpToolsProgressively( cliConfig: Config, - onServerReady?: (name: string) => void // 新增:逐 Server 回调 + onServerReady?: (name: string) => void, + onToolsChanged?: () => void, ): Promise { - // ... 省略前置代码 ... - const discoveryPromises = Object.entries(servers).map( async ([name, config]) => { + if (cliConfig.isMcpServerDisabled(name)) return; + const client = new McpClient(name, config, ...); this.clients.set(name, client); try { - // 使用 Promise.race 限制单 Server 超时 await Promise.race([ (async () => { + // 只清理当前 server 的旧工具/prompt,不能清空全局 discovered tools + this.toolRegistry.removeDiscoveredToolsForServer(name); await client.connect(); await client.discover(cliConfig); - onServerReady?.(name); // 立即通知该 Server 就绪 + onServerReady?.(name); + onToolsChanged?.(); })(), - new Promise((_, reject) => - setTimeout(() => reject(new Error(`MCP server "${name}" discovery timeout`)), - config.timeout ?? 30_000 // 30秒默认超时 - ) - ), + timeout(discoveryTimeoutFor(config)), ]); } catch (error) { /* 记录但不阻塞 */ } }, @@ -218,48 +279,59 @@ async discoverAllMcpTools( **实现路径**: -代码审查发现 `createToolRegistry()` 已支持 `skipDiscovery` 选项(`config.ts` 第 2584-2586 行),且 `discoverMcpTools()` 方法可独立调用(`tool-registry.ts` 第 385-393 行)。因此可以实现两阶段初始化: +代码审查发现 `createToolRegistry()` 已支持 `skipDiscovery` 选项,但不能直接 fire-and-forget 调用现有 `discoverMcpTools()`,因为它会清理所有 discovered tools/prompts。应实现一个新的渐进入口或扩展现有 per-server 发现入口: ```typescript -// 阶段 1:快速创建工具注册表(跳过发现) +// 阶段 1:快速创建工具注册表(跳过 MCP discovery) await createToolRegistry({ skipDiscovery: true }); -// 阶段 2:异步 MCP 发现(fire-and-forget 或带回调) -void toolRegistry.discoverMcpTools(onServerReady); +// 阶段 2:异步 MCP 发现;server ready 后 debounce 刷新 Gemini tools +const refreshGeminiTools = debounce(() => config.getGeminiClient().setTools(), 100); +void toolRegistry.discoverMcpToolsProgressively({ + onServerReady, + onToolsChanged: refreshGeminiTools, +}); ``` **影响范围**: -- `packages/core/src/tools/mcp-client-manager.ts` — 添加渐进回调、超时控制 +- `packages/core/src/tools/mcp-client-manager.ts` — 添加渐进发现入口、逐 Server 超时控制、server ready 事件 - `packages/core/src/config/config.ts` — 利用已有的 `skipDiscovery` 选项,在 `initialize()` 中跳过 MCP,另行启动 -- `packages/core/src/tools/tool-registry.ts` — 利用已有的 `discoverMcpTools()` 方法 -- `packages/cli/src/ui/AppContainer.tsx` — 添加 MCP 连接状态显示 +- `packages/core/src/tools/tool-registry.ts` — 添加不清空全局 discovered tools 的 per-server discover/replace API +- `packages/core/src/core/client.ts` — 暴露或复用 `setTools()`,支持 debounce 刷新 +- `packages/cli/src/ui/AppContainer.tsx` / `ConfigInitDisplay.tsx` — 扩展 MCP 连接状态显示到初始化后 **预期收益**: -- 首个工具可用时间从 "等待所有 Server" 降至 "最快 Server 响应时间"(通常 < 2秒) +- 首个 MCP 工具注册时间从 "等待所有 Server" 降至 "最快 Server 响应时间"(通常 < 2秒) +- 首个 MCP 工具被模型可见的时间 = server ready + debounce 后 `GeminiClient.setTools()` 完成 - 慢 Server 不再阻塞其他 Server 的工具使用 **风险点**: -- 工具列表在会话中动态变化(逐渐增加),需确保 LLM prompt 中的工具描述能动态更新 -- 超时降低可能导致网络慢的环境误判 Server 不可用 → 通过配置项允许用户调整 +- 工具列表在会话中动态变化,需确保 LLM tools declaration 能从下一次请求开始动态更新 +- 正在进行的模型请求不应中途变更工具集合,避免工具调用/响应不一致 +- 超时降低可能导致网络慢的环境误判 Server 不可用,应只作用于 discovery,并保留配置项允许用户调整 +- per-server 替换必须是原子的,避免短暂删除其他 Server 工具或 prompts ### 2.4 [P1] 启动分析器增强 -**现状**:`packages/cli/src/utils/startupProfiler.ts` 仅记录粗粒度 phase 边界,无法定位 `config.initialize()` 内部的具体瓶颈。 +**现状**:`packages/cli/src/utils/startupProfiler.ts` 仅记录粗粒度 phase 边界,并且交互式模式下在 UI render 前后 finalize,无法定位 `config.initialize()`、MCP 首工具注册、Gemini tools 刷新的具体瓶颈。 **方案**: -1. 在 `config.initialize()` 内部添加子 checkpoint:`file_discovery_init`、`git_init`、`prompt_registry_init`、`mcp_discovery_start`、`mcp_discovery_end` -2. 在每个 checkpoint 记录 `process.memoryUsage().heapUsed` -3. 添加 `--startup-profile` CLI 参数(比环境变量更易用) -4. 保存滚动 10 次运行历史到 `~/.qwen/startup-perf/`,支持回归检测 +1. 将 2.0 中定义的指标接入 startup profiler,profile 生命周期延长到交互式初始化完成或显式 timeout +2. 在 `config.initialize()` 内部添加子 checkpoint:`file_discovery_init`、`git_init`、`prompt_registry_init`、`tool_registry_created`、`mcp_discovery_start`、`mcp_first_tool_registered`、`mcp_all_servers_settled` +3. 在每个 checkpoint 记录 `process.memoryUsage().heapUsed` +4. 添加 `--startup-profile` CLI 参数(比环境变量更易用),但保留 `QWEN_CODE_PROFILE_STARTUP=1` +5. 保存滚动 10 次运行历史到 `~/.qwen/startup-perf/`,支持回归检测 +6. 在 profile 中标记 `interactive` / `non_interactive`,避免把两种启动路径混合比较 **影响范围**: - `packages/cli/src/utils/startupProfiler.ts` — 增强记录能力 - `packages/core/src/config/config.ts` — 添加子 checkpoint 调用 +- `packages/cli/src/ui/AppContainer.tsx` — render 后初始化 checkpoint ### 2.5 [P2] 产物体积优化 @@ -268,7 +340,7 @@ void toolRegistry.discoverMcpTools(onServerReady); **方案**: 1. 使用 `source-map-explorer` 或 `esbuild-analyzer` 分析体积构成 -2. **lowlight 语法库懒加载**:当前 `CodeColorizer.tsx:9` 通过 `import { common } from 'lowlight'` 一次性加载约 40 种语言语法,改为按需注册 +2. **代码高亮依赖优化**:当前 `CodeColorizer.tsx:9` 通过 `import { common } from 'lowlight'` 一次性加载约 40 种语言语法;懒加载需要配合渲染层同步/异步边界设计,不能直接在同步 `colorizeCode()` 中引入 `await` 3. 未使用主题定义的 tree-shaking 4. 考虑将代码高亮拆分为独立 chunk 或 worker @@ -277,7 +349,7 @@ void toolRegistry.discoverMcpTools(onServerReady); - 构建配置 - `packages/cli/src/ui/utils/CodeColorizer.tsx` — 延迟加载语法 -**预期收益**:`processUptimeAtT0Ms`(V8 解析时间)减少 20%+。 +**预期收益**:`processUptimeAtT0Ms`(V8 解析时间)减少 20%+。该项与 `03-rendering-extensibility.md` 的代码高亮缓存/预热方案联动实施。 ## 3. 竞品参考 @@ -316,10 +388,11 @@ const module = feature('FLAG') ? require('./module.js') : null; | 优先级 | 方案 | 周次 | 风险 | 预期改善 | | ------ | ------------------ | ---- | ---- | -------------------- | -| P0 | 并行 Settings 加载 | 3 | 低 | 配置加载耗时 -30~50% | -| P0 | 并行化 UI 前初始化 | 4 | 低 | TTI -200~400ms | -| P1 | 渐进式 MCP 可用性 | 5-6 | 中 | 首工具可用 < 2s | -| P1 | 启动分析器增强 | 3 | 低 | 持续监控能力 | +| P0 | 启动观测基线 | 1 | 低 | 指标口径可信 | +| P0 | 并行 Settings 加载 | 4 | 中 | 配置加载耗时 -30~50% | +| P0 | 并行化 UI 前初始化 | 5 | 低 | TTI -200~400ms | +| P1 | 渐进式 MCP 可用性 | 6-7 | 中 | 首工具可见 < 2s | +| P1 | 启动分析器增强 | 1-2 | 低 | 持续监控能力 | | P2 | 产物体积优化 | 10 | 中 | 冷启动 -20% | ## 5. 验证方案 @@ -333,9 +406,13 @@ QWEN_CODE_PROFILE_STARTUP=1 qwen-code --prompt "test" # 重点关注指标: # - processUptimeAtT0Ms: V8 模块解析时间 # - after_load_settings: 配置加载完成时间 +# - first_paint: UI 首次渲染 +# - input_enabled: 可输入时间 +# - config_initialize_start/end: render 后初始化耗时 # - before_render: UI 渲染前总耗时 -# - 新增: mcp_first_tool_available: 首个 MCP 工具可用时间 -# - 新增: mcp_all_tools_available: 所有 MCP 工具可用时间 +# - mcp_first_tool_registered: 首个 MCP 工具注册时间 +# - gemini_tools_updated: 首个 MCP 工具被模型下一次请求可见 +# - mcp_all_servers_settled: 所有 MCP Server 完成/失败/超时 ``` ### 5.2 测试场景 @@ -343,14 +420,16 @@ QWEN_CODE_PROFILE_STARTUP=1 qwen-code --prompt "test" | 场景 | 期望行为 | | ---------------------------- | ---------------------------------------------- | | 无 MCP Server | 启动时间不受 MCP 影响 | -| 1 个快速 MCP Server | 工具在 < 2s 内可用 | -| 3 个 MCP Server(1 慢 2 快) | 快速 Server 工具立即可用,慢 Server 超时后降级 | +| 1 个快速 MCP Server | 工具在 < 2s 内注册,并在 `setTools()` 后对下一次模型请求可见 | +| 3 个 MCP Server(1 慢 2 快) | 快速 Server 工具先注册,慢 Server 超时后降级;其他工具不被清空 | | MCP Server 连接失败 | 错误记录但不阻塞启动 | | 网络不可用 | 超时后优雅降级,显示警告 | | 冷启动 vs 热启动 | 两种场景均有改善 | +| 正在进行的模型请求中 MCP 工具变化 | 当前请求工具集合不变,下一次请求看到更新 | ### 5.3 向后兼容 -- `loadSettings()` 签名变更需更新所有调用点 -- MCP 超时降低需提供配置项允许用户恢复长超时 -- 渐进式工具注册需确保不破坏现有的工具描述生成逻辑 +- 第一阶段不修改 `loadSettings()` 同步签名,新增 `loadSettingsAsync()` 给启动主路径使用 +- MCP discovery 超时降低需提供配置项允许用户恢复长超时;tool call 超时不随 discovery 默认值改变 +- 渐进式工具注册需确保不破坏现有的工具描述生成逻辑,并通过 `GeminiClient.setTools()` debounce 刷新 +- 不直接使用会全局清空 discovered tools/prompts 的 `ToolRegistry.discoverMcpTools()` 作为后台渐进入口 diff --git a/docs/design/tui-optimization/02-screen-flickering.md b/docs/design/tui-optimization/02-screen-flickering.md index 3fab16685..91711c963 100644 --- a/docs/design/tui-optimization/02-screen-flickering.md +++ b/docs/design/tui-optimization/02-screen-flickering.md @@ -60,8 +60,9 @@ Ink 6.2.3 的渲染模型决定了闪烁问题的根源: | ---------------- | -------------------------------------- | -------- | ---------------------------- | | 流式输出 | 每个内容 chunk 触发 React re-render | 高 | `useGeminiStream` hook | | 长输出超屏 | 动态内容高度 > 终端行数 | 严重 | Ink 内部 `eraseLines` 路径 | -| 终端 resize | `refreshStatic()` 调用 `clearTerminal` | 中 | `AppContainer.tsx:1508-1517` | -| Compact 模式切换 | 历史合并触发 `refreshStatic()` | 中 | `MainContent.tsx:80` | +| 终端宽度 resize | `refreshStatic()` 调用 `clearTerminal`;当前 effect 主要依赖宽度变化 | 中 | `AppContainer.tsx` resize effect | +| Compact 模式切换 | 历史合并、settings dialog、快捷键切换触发 `refreshStatic()` | 中 | `MainContent` / `SettingsDialog` / `AppContainer` | +| 手动清屏/视图切换 | `/clear`、active view 切换触发全屏刷新 | 中 | `slashCommandProcessor` / `DefaultAppLayout` | | 窄屏布局抖动 | 布局重算导致内容高度反复变化 | 中 | Ink 布局引擎 | | tmux/SSH | 终端复用器放大闪烁效果 | 严重 | 终端环境因素 | @@ -95,13 +96,13 @@ CSI ? 2026 l ← End Synchronized Update(刷新显示) | Windows Terminal | 1.18+ | | Contour | 0.3.0+ | | tmux | 3.4+ (透传) | -| 不支持的终端 | 静默忽略序列,零副作用 | +| 不支持的终端 | 通常会忽略未知私有 CSI 序列;仍需按终端和 tmux/SSH 组合验证 | -**实现方案**: +**落地步骤**:先在现有的 `terminalRedrawOptimizer.ts` 中加入输出指标,再根据指标决定采用“单 write 包裹”还是“帧缓冲合并”。 -在现有的 `terminalRedrawOptimizer.ts` 中扩展 `optimizedWrite`。 +**前置 instrumentation**:在默认启用前,必须先统计 Ink 每帧对应的 `stdout.write()` 次数、每次 write 的字节数、chunk 类型(string / Buffer)和 callback 语义。当前优化器的 `optimizeMultilineEraseLines()` 只能处理**单次 string write 内**的 ANSI 序列折叠,不能据此假设每帧一定只有一次 write。 -**需要确认的前提**:Ink 是否每帧只调用一次 `stdout.write()`?当前优化器的 `optimizeMultilineEraseLines()` 处理的是**单次 write 内**的 ANSI 序列折叠,这暗示 Ink 大概率将完整帧内容在单次 write 中输出。如果确认如此,BSU/ESU 可直接包裹单次 write: +**实现方案**:先在现有 `terminalRedrawOptimizer.ts` 中扩展 `optimizedWrite`,但需保证 BSU/ESU 成对、可禁用、且不改变 Buffer/callback 行为。 ```typescript const BSU = '\x1b[?2026h'; // Begin Synchronized Update @@ -125,16 +126,23 @@ const optimizedWrite = function ( }; ``` -**如果 Ink 每帧多次 write**:需要改用帧缓冲策略 — 在 idle tick 中收集所有 write 调用,合并后统一输出。实现更复杂但同样可行。 +**如果 Ink 每帧多次 write**:不要简单给每个 write 都包 BSU/ESU。应改用帧缓冲策略:在 microtask/idle tick 中收集同一帧的 write 调用,合并后统一输出,并记录合并前后的 writes/sec 和 bytes/sec。 -**验证步骤**:在优化器中添加临时计数器,统计单次 React render 触发多少次 `stdout.write()`。 +**验证步骤**: + +1. 在优化器中添加 counters,统计单次 React render 触发多少次 `stdout.write()` +2. 覆盖 string、Buffer、带 encoding、带 callback 的 `stdout.write()` 调用形态 +3. 覆盖 screen reader 开启时不安装优化器的路径 +4. 覆盖 `ansiEscapes.clearTerminal`、`eraseLines`、普通文本输出三类路径 +5. 检查 `bsu_frame_count === esu_frame_count`,异常时自动关闭同步输出 **影响范围**:仅 `packages/cli/src/ui/utils/terminalRedrawOptimizer.ts` -**风险评估**:**极低** +**风险评估**:**中低** -- 不支持的终端静默忽略 BSU/ESU 序列 -- 可通过 `QWEN_CODE_LEGACY_ERASE_LINES=1` 完全禁用 +- 不支持的终端通常忽略 BSU/ESU,但不能宣称零风险,需覆盖 tmux/SSH/Windows Terminal/Terminal.app +- 可通过 `QWEN_CODE_LEGACY_ERASE_LINES=1` 或 `QWEN_CODE_LEGACY_RENDERING=1` 禁用 +- stdout monkeypatch 是全局副作用,必须保证原始 `write()` 语义不变 - Claude Code 在 `src/ink/terminal.ts` 中已使用相同方案 **预期收益**: @@ -147,7 +155,7 @@ const optimizedWrite = function ( **现状**:LLM 流式输出的每个内容 chunk 都触发 React 状态更新。虽然不是逐 token 更新(而是按 API 返回的 chunk 粒度),但在高速流式输出时仍可能产生每秒 50+ 次 re-render。人眼对文本更新的感知频率约 15-20fps,大量渲染被浪费。 -**方案**:在流式 hook 中实现 chunk 缓冲 + 定时刷新。 +**方案**:在流式 hook 中实现 chunk 缓冲 + 定时刷新。需要覆盖 content stream 和 thought stream;shell 命令输出已有 1s 级节流,应作为现状保留并单独验证。 ```typescript // packages/cli/src/ui/hooks/useGeminiStream.ts(概念实现) @@ -174,7 +182,7 @@ const onContentChunk = useCallback( [flushBuffer], ); -// 流结束时立即刷新 +// 流结束、取消、工具调用开始、需要展示确认框时立即刷新 const onStreamEnd = useCallback(() => { if (flushTimerRef.current) clearTimeout(flushTimerRef.current); flushBuffer(); @@ -183,29 +191,33 @@ const onStreamEnd = useCallback(() => { **影响范围**:`packages/cli/src/ui/hooks/useGeminiStream.ts` -**具体切入点**:`handleContentEvent()` 回调(第 664 行),该函数在每个内容 chunk 到达时调用 `setPendingHistoryItem()`(第 690 行)触发 React 状态更新。节流层应在 `setPendingHistoryItem` 调用之前缓冲内容。 +**具体切入点**: + +- `handleContentEvent()`:在 `setPendingHistoryItem()` 前缓冲 content chunk +- thought stream 更新路径:使用同一套缓冲/flush 机制,避免思考内容绕过节流 +- shell command output:保留现有 `OUTPUT_UPDATE_INTERVAL_MS = 1000`,只补指标和回归测试 **风险评估**:低 - 60ms 延迟对用户不可感知 -- 流结束时立即刷新确保最终内容完整 +- 流结束、取消、工具调用、确认框展示前立即刷新,确保 UI 状态不滞后 - 如有问题可调整 `FLUSH_INTERVAL_MS` 或通过环境变量禁用 **预期收益**:`stdout.write` 调用从 50+/秒降至 < 20/秒,直接减少 60%+ 的渲染开销。 ### 2.3 [P1] 动态内容高度管理 + 渐进提升 -**现状**:当流式内容超过终端高度时,Ink 触发全屏重绘。`constrainHeight` 状态标志虽然存在(`AppContainer.tsx:1482`),但计算不够精确。 +**现状校准**:当流式内容超过终端高度时,Ink 可能触发全屏重绘。源码中已经存在渐进提升的雏形:`useGeminiStream` 在 content 和 thought 流中调用 `findLastSafeSplitPoint()`,把安全分割点之前的内容加入 history/static,只保留尾部 pending 内容在动态区域。当前缺口不是“从零实现提升”,而是提升阈值、覆盖范围和刷新频率不够可控。 -**方案**:实现"渐进提升"(Progressive Promotion)模式 — 随着流式内容增长,将已完成的块从动态区域提升到 `` 区域。 +**方案**:增强现有"渐进提升"(Progressive Promotion)模式 — 随着流式内容增长,将已完成的块从动态区域提升到 `` 区域,并把触发条件从纯文本边界升级为“渲染高度 + 时间间隔 + 安全 Markdown 边界”。 **核心逻辑**: ``` 流式输出开始 ├─ 新 token 追加到 pendingContent - ├─ 检查 pendingContent 高度 vs 可用动态区域高度 - │ ├─ 高度安全 → 继续累积 + ├─ 估算 pendingContent 渲染高度 vs 可用动态区域高度 + │ ├─ 高度安全且未超过最小间隔 → 继续累积 │ └─ 接近阈值 → │ ├─ 使用 findLastSafeSplitPoint() 找到安全分割点 │ ├─ 分割点之前的内容 → 提升到 history (Static) @@ -219,18 +231,25 @@ const onStreamEnd = useCallback(() => { - 优先在段落边界 `\n\n` 分割 - 回退到行边界 `\n` +**增强点**: + +- 使用 `availableTerminalHeight`、`contentWidth` 和渲染行数估算 pending 高度 +- 对 content stream、thought stream、tool 输出摘要分别设置阈值 +- 加入最小提升间隔(如 300-500ms),避免频繁写入 `` +- 只在安全 Markdown 边界分割;代码块、列表、表格中保守不切 + **影响范围**: -- `packages/cli/src/ui/components/MainContent.tsx` — 实现提升逻辑 +- `packages/cli/src/ui/components/MainContent.tsx` — 提供可用动态高度和 pending 高度约束 - `packages/cli/src/ui/AppContainer.tsx` — 改进高度计算 -- `packages/cli/src/ui/hooks/useGeminiStream.ts` — 在流式处理中调用分割 +- `packages/cli/src/ui/hooks/useGeminiStream.ts` — 增强现有分割/提升逻辑 **风险评估**:中 - 分割可能导致部分 Markdown 上下文丢失(如跨段落的列表)→ 通过保守的分割策略缓解 - 频繁提升可能导致 `` 闪烁 → 设置最小提升间隔(如 500ms) -**预期收益**:动态内容始终控制在终端高度内,永远不触发 Ink 的全屏重绘路径。 +**预期收益**:动态内容尽量控制在终端高度内,显著降低 Ink 全屏重绘路径触发概率。 ### 2.4 [P1] 智能 refreshStatic() @@ -246,10 +265,12 @@ const refreshStatic = useCallback(() => { 触发场景: -- 终端 resize(300ms debounce):`AppContainer.tsx:1508-1517` -- Compact 模式合并:`MainContent.tsx:80` -- 手动清屏:`AppContainer.tsx:1200` -- Auth 变更:`AppContainer.tsx:498` +- 终端宽度 resize(当前主要依赖 `terminalWidth`,高度变化不应触发静态区重排) +- Compact 模式合并:`MainContent` +- Compact 设置变更:`SettingsDialog` +- Compact 快捷键切换:`AppContainer` +- 手动清屏:`/clear` / `clearScreen()` +- Active view 切换:`DefaultAppLayout` **方案**: @@ -260,7 +281,7 @@ const refreshStatic = useCallback(() => { debounce(() => { // 不再 clearTerminal,仅更新布局尺寸 updateTerminalDimensions(); - // 只在宽度变化时才需要重新渲染(高度变化不影响已渲染内容) + // 只在宽度变化时才需要重新渲染(高度变化不影响已渲染内容换行) if (widthChanged) { refreshStatic(); // 宽度变化时仍需全量重绘(行包装会变) } @@ -354,9 +375,10 @@ Claude Code 的自研 Ink 内核提供了五层防闪烁保护: | 优先级 | 方案 | 周次 | 风险 | 预期收益 | | ------ | -------------------- | ----- | ---- | ------------------------- | -| P0 | 同步输出 DECSET 2026 | 1 | 极低 | 消除帧撕裂,tmux 效果显著 | -| P0 | 流式更新节流 60ms | 1 | 低 | stdout.write -60%+ | -| P1 | 渐进提升到 Static | 6-7 | 中 | 消除长输出全屏闪烁 | +| P0 | 输出层 instrumentation | 1 | 低 | 指标口径可信 | +| P0 | 同步输出 DECSET 2026 | 2 | 中低 | 消除帧撕裂,tmux 效果显著 | +| P0 | 流式更新节流 60ms | 2 | 低 | stdout.write -60%+ | +| P1 | 现有渐进提升增强 | 7 | 中 | 降低长输出全屏闪烁 | | P1 | 智能 refreshStatic() | 8-9 | 中 | resize 不再全屏闪烁 | | P2 | 双缓冲 + diff patch | 11-13 | 高 | stdout 字节/帧 -80% | | P2 | DECSTBM 滚动区域 | 13+ | 高 | 滚动性能接近原生 | @@ -369,6 +391,8 @@ Claude Code 的自研 Ink 内核提供了五层防闪烁保护: | ---------------------------- | ----------- | -------------------- | ------------ | | stdout.write 调用/秒(流式) | 50+ | < 20 | < 16 | | stdout 字节/帧(增量更新) | 全帧大小 | 全帧大小(同步包裹) | 仅变更 cell | +| clearTerminal 次数(正常流式) | 未知 | 0 | 0 | +| BSU/ESU 平衡 | 无 | 100% 成对 | 100% 成对 | | tmux 滚动事件/秒 | 4,000-6,700 | < 100 | < 20 | | 可见闪烁(主观) | 严重 | 轻微/无 | 无 | @@ -382,11 +406,13 @@ Claude Code 的自研 Ink 内核提供了五层防闪烁保护: | 窄屏 (< 40 列) | 将终端缩至 30 列 | 布局优雅降级,无抖动 | | tmux 内运行 | tmux 分屏环境 | 滚动事件 < 100/秒 | | SSH 远程 | 高延迟网络 | 闪烁不加剧 | -| kitty/WezTerm | 支持 DECSET 2026 的终端 | 零帧撕裂 | +| kitty/WezTerm | 支持 DECSET 2026 的终端 | 无明显帧撕裂 | | Terminal.app | 不支持 DECSET 2026 | 行为不变(不退化) | +| screen reader | `config.getScreenReader()` 开启 | 不安装 stdout 优化器 | +| Buffer write/callback | 直接写 stdout 的外部路径 | `write()` 返回值和 callback 行为不变 | ### 5.3 向后兼容 - `QWEN_CODE_LEGACY_ERASE_LINES=1`:禁用所有 stdout 拦截优化(已有) - `QWEN_CODE_LEGACY_RENDERING=1`:新增,禁用同步输出 + 节流 -- 不支持 DECSET 2026 的终端:序列被静默忽略,零风险 +- 不支持 DECSET 2026 的终端:通常忽略未知序列,但仍需保留开关和终端矩阵验证 diff --git a/docs/design/tui-optimization/03-rendering-extensibility.md b/docs/design/tui-optimization/03-rendering-extensibility.md index e432eee5f..ca6de4347 100644 --- a/docs/design/tui-optimization/03-rendering-extensibility.md +++ b/docs/design/tui-optimization/03-rendering-extensibility.md @@ -33,7 +33,7 @@ for (let i = 0; i < lines.length; i++) { 1. **无解析缓存**:每次 React re-render 都对完整文本重新解析。流式输出时,每新增一个 token 就重新解析所有已累积文本 2. **功能受限**:不支持 GFM 任务列表、脚注、嵌套格式、定义列表等 -3. **正则脆弱性**:边界情况处理不完整,如表格与 CJK 字符的交互、嵌套代码块等 +3. **正则脆弱性**:边界情况处理不完整,如嵌套代码块、复杂列表、未闭合流式 Markdown 等 4. **性能线性退化**:文本越长,每帧解析耗时线性增长 ### 1.2 代码高亮现状 @@ -55,11 +55,17 @@ const lowlightInstance = createLowlight(common); // 启动时加载 ~40 种语 `packages/cli/src/ui/utils/TableRenderer.tsx`(540 行): -**问题**: +**源码校准**: -- CJK/宽字符的列宽计算存在 bug(GitHub 反馈) -- 特定终端宽度下表格消失或错位 -- 对齐方式(`:---:` 等)的解析与渲染存在边缘情况 +- 当前实现已经使用 `wrap-ansi`、`strip-ansi` 和 string-width 缓存处理 ANSI/CJK 宽度 +- 已有基本表格、CJK、ANSI、宽度边界和 vertical fallback 的回归测试 +- 因此表格不应作为 Phase 1 的主要重构目标;除非有 qwen-code 当前版本可复现缺陷,否则以补 fixture 和保护现有能力为主 + +**仍需验证的风险**: + +- 与新 Markdown token/cache 层集成后,表格 token 到现有 `TableRenderer` 的输入是否保持一致 +- 极窄宽度、混合 ANSI + CJK + emoji 场景是否仍能触发 vertical fallback +- marked 迁移后对齐语法、转义 pipe、代码 span 中 pipe 的处理是否与当前渲染兼容 ### 1.4 主题系统现状 @@ -95,20 +101,29 @@ export const QwenDark: Theme = { ## 2. 解决方案 -### 2.1 [P0] Markdown 解析结果缓存 +### 2.1 [P0] Markdown token/block 缓存 **目标**:消除流式输出时的重复解析开销。 -**方案**:实现 block 级别的 LRU 缓存。 +**关键约束**:不能缓存 `React.ReactNode[]`。`MarkdownDisplay` 的最终渲染受 `isPending`、`availableTerminalHeight`、`contentWidth`、`textColor`、主题、代码行号设置等 props/settings 影响;按文本 hash 缓存 ReactNode 会导致 resize、主题切换、pending 高度裁剪和行号开关后复用错误结果。 + +**方案**:实现 block 级别的 LRU 缓存,但缓存对象是 token/block 元数据,而不是 ReactNode。 **设计**: ```typescript // 新增缓存层 const PARSE_CACHE_MAX = 500; -const parseCache = new LRUCache(PARSE_CACHE_MAX); +const parseCache = new LRUCache(PARSE_CACHE_MAX); -function parseMarkdownBlocks(text: string): React.ReactNode[] { +interface ParsedMarkdownBlock { + type: 'paragraph' | 'heading' | 'code' | 'table' | 'list' | 'hr'; + raw: string; + attrs: Record; + children?: ParsedMarkdownBlock[]; +} + +function parseMarkdownBlocks(text: string): ParsedMarkdownBlock[] { const cacheKey = hashContent(text); const cached = parseCache.get(cacheKey); if (cached) return cached; @@ -118,6 +133,13 @@ function parseMarkdownBlocks(text: string): React.ReactNode[] { parseCache.set(cacheKey, blocks); return blocks; } + +function renderMarkdownBlocks( + blocks: ParsedMarkdownBlock[], + props: MarkdownDisplayProps, +): React.ReactNode[] { + // 根据当前 width/theme/pending/height/settings 渲染,不能跨 props 复用 +} ``` **流式优化**:利用现有的 `findLastSafeSplitPoint()` 实现增量解析。 @@ -128,24 +150,32 @@ function parseMarkdownBlocks(text: string): React.ReactNode[] { 缓存命中(不重解析) 缓存命中 重新解析(仅此块) ```` +**缓存 key**: + +- parse cache:`hash(rawBlock)` + parser version +- render 辅助缓存(如纯文本 wrap 结果):必须额外包含 `contentWidth`、theme identity、`isPending`、height constraint、settings 版本 +- 不把完整原始长字符串作为 key 保存,避免内存放大 + **影响范围**:`packages/cli/src/ui/utils/MarkdownDisplay.tsx` -**预期收益**:缓存命中时解析耗时降低 70%+。对于 1000 行的流式输出,每帧仅需解析最后一个不完整块(通常 < 50 行),而非全部 1000 行。 +**预期收益**:缓存命中时解析耗时显著下降。对于 1000 行的流式输出,每帧仅需解析最后一个不完整块(通常 < 50 行),而非全部 1000 行。 -**参考**:Claude Code 使用模块级 LRU 缓存(500 条目),key 为内容 hash,避免保留完整字符串引用。 +**参考**:Claude Code 使用模块级 LRU 缓存(500 条目),key 为内容 hash,避免保留完整字符串引用;qwen-code 应采用 token/block 级缓存以适配 Ink props 驱动渲染。 ### 2.2 [P0] 代码高亮优化 -**方案 A:语法库懒加载** +**关键约束**:当前 `colorizeCode()` 是同步函数,直接返回 ReactNode;因此不能在 render 路径中直接 `await ensureLanguage()`。语法库懒加载必须配合 Suspense、预热队列或“当前帧纯文本 fallback,下一帧高亮增强”的状态模型,否则会破坏 Ink 同步渲染路径。 + +**方案 A:同步基线 + 异步预热** ```typescript // 当前(急切加载) import { common, createLowlight } from 'lowlight'; const lowlightInstance = createLowlight(common); -// 优化后(按需加载) +// 优化方向:保留小型同步基础语法,稀有语法异步预热 import { createLowlight } from 'lowlight'; -const lowlightInstance = createLowlight(); // 空实例 +const lowlightInstance = createLowlight(BASELINE_GRAMMARS); const GRAMMAR_LOADERS: Record Promise> = { javascript: () => import('highlight.js/lib/languages/javascript'), @@ -154,42 +184,66 @@ const GRAMMAR_LOADERS: Record Promise> = { // ... 常用语言 }; -async function ensureLanguage(lang: string): Promise { - if (lowlightInstance.registered(lang)) return true; +function requestLanguageWarmup(lang: string): void { + if (lowlightInstance.registered(lang)) return; const loader = GRAMMAR_LOADERS[lang]; - if (!loader) return false; - const grammar = await loader(); - lowlightInstance.register(lang, grammar.default); - return true; + if (!loader) return; + void loader().then((grammar) => { + lowlightInstance.register(lang, grammar.default); + emitHighlightCacheInvalidated(lang); + }); } ``` +**渲染策略**: + +- 已注册语言:同步高亮 +- 未注册但可加载语言:本帧纯文本/简化高亮,同时触发 warmup;下一次 render 使用高亮 +- 未指定语言:限制 `highlightAuto()` 的输入大小和语言集合,超大代码块直接纯文本,避免遍历所有 grammar +- pending streaming 代码块:默认不做昂贵高亮,完成后再高亮 + **方案 B:高亮结果缓存** ```typescript -const highlightCache = new LRUCache(200); +const highlightCache = new LRUCache(200); -function cachedHighlight(code: string, lang: string): HastNode { - const key = `${lang}:${hashContent(code)}`; +function cachedHighlight(input: HighlightInput): HighlightResult { + const key = [ + input.language ?? 'auto', + input.themeId, + input.showLineNumbers, + input.contentWidth, + input.availableTerminalHeight ?? 'none', + hashContent(input.code), + ].join(':'); const cached = highlightCache.get(key); if (cached) return cached; - const result = lowlightInstance.highlight(lang, code); + const result = highlightSynchronously(input); highlightCache.set(key, result); return result; } ``` +**缓存 key 必须包含**: + +- code hash、language/auto mode、registered grammar version +- theme identity / color palette +- `showLineNumbers` +- `contentWidth` +- `availableTerminalHeight` 或裁剪后的 line range +- pending vs completed 状态(pending 可直接禁用缓存或单独缓存) + **影响范围**:`packages/cli/src/ui/utils/CodeColorizer.tsx` **预期收益**: -- 懒加载:减少启动时模块加载量,降低内存占用 +- 同步基线 + 异步预热:减少启动时模块加载量,降低内存占用,同时不破坏同步 render - 缓存:对已完成代码块的重复渲染耗时降至 O(1) ### 2.3 [P1] 切换到 marked 解析器 -**动机**:当前自定义正则解析器的功能和鲁棒性已接近上限。`marked` 是 Claude Code 的选择,提供完整的 GFM 支持和流式友好的 lexer API。 +**动机**:当前自定义正则解析器的功能和鲁棒性已接近上限。`marked` 是 Claude Code 的选择,提供成熟的 block/inline lexer API,可作为 v2 渲染器候选。但迁移必须先定义安全策略和流式不完整语法策略,不能只替换 parser。 **架构设计**: @@ -220,22 +274,29 @@ return [...cachedBlocks.flat(), ...lastBlockTokens]; **新增 GFM 能力**: | 能力 | marked 支持 | 当前解析器 | |---|---|---| -| 标准表格 | 完整 | 部分 | -| 任务列表 `- [x]` | 是 | 否 | -| 脚注 `[^1]` | 是 | 否 | +| 标准表格 | 是,需映射到现有 `TableRenderer` | 已有自定义实现 | +| 任务列表 `- [x]` | 是,需自定义 Ink renderer | 否 | +| 脚注 `[^1]` | 需通过扩展/插件策略验证,不作为首批默认承诺 | 否 | | 删除线 `~~text~~` | 是 | 是 | | 自动链接 | 是 | 部分 | -| HTML 内联 | 可配置 | 仅 `` | -| 嵌套格式 | 完整 | 受限 | +| HTML 内联 | parser 可识别;qwen-code 需默认转义或忽略,不能直接渲染 HTML | 仅 `` | +| 嵌套格式 | 更完整,但需 fixture 验证 Ink renderer 行为 | 受限 | + +**必须先定的策略**: + +- HTML policy:默认忽略或转义 HTML;不允许把 marked 输出的 HTML 当作安全内容直接渲染 +- Extension policy:脚注、定义列表等非首批能力需单独开关和 fixture,不在 v2 默认承诺里混入 +- Streaming policy:未闭合代码块、表格、列表时,最后一个 block 允许降级为纯文本或 v1 行解析,避免 token 结构抖动 +- Compatibility policy:现有 `InlineMarkdownRenderer` 的 `[text](url)` 输出形态、表格 fallback、代码块裁剪行为必须有 fixture 对照 **迁移策略**: 1. 添加 `marked` 依赖 2. 创建 `MarkdownDisplayV2.tsx`,使用 marked lexer + 自定义 renderer -3. 通过设置项 `ui.markdownRenderer: 'v1' | 'v2'` 切换(默认 v1) -4. 编写 Markdown fixture 测试集,对比两个渲染器输出 -5. 渐进切换默认值到 v2,保留 v1 作为回退 -6. 稳定后移除 v1 +3. 默认关闭,通过设置项 `ui.markdownRenderer: 'v1' | 'v2'` 和环境变量双重切换 +4. 编写 Markdown fixture 测试集,对比两个渲染器输出,重点覆盖 streaming partial blocks +5. 内部 dogfood 后渐进切换默认值到 v2,保留 v1 作为回退 +6. 稳定两个小版本后再评估移除 v1 **影响范围**: @@ -247,6 +308,8 @@ return [...cachedBlocks.flat(), ...lastBlockTokens]; - marked 的 token 结构与当前组件的 props 接口需要适配 - 流式 markdown 中的不完整语法可能导致 marked 产生不同的 token 结构 +- marked 本身不负责 HTML sanitize,必须由 qwen-code renderer 定义安全策略 +- 添加依赖会影响 bundle 体积,需要纳入 `processUptimeAtT0Ms` 和 bundle analyzer - 缓解:保留 v1 作为回退,充分测试后再切换默认值 ### 2.4 [P1] 主题系统 — ANSI 16 色默认 + 终端能力检测 @@ -297,6 +360,8 @@ function queryTerminalBackground(): Promise<'light' | 'dark' | 'unknown'> { } ``` +OSC 11 查询会向终端请求背景色响应,可能与用户输入流、tmux/SSH 组合和非交互输出产生副作用。该能力只作为 opt-in 进阶功能,不作为默认启动路径的一部分;默认策略应优先基于 `NO_COLOR`、`FORCE_COLOR`、`COLORTERM`、`TERM` 和用户显式主题设置。 + **影响范围**: - `packages/cli/src/ui/themes/theme-manager.ts` — 添加能力检测,修改默认主题选择 @@ -323,7 +388,7 @@ ESC ] 8 ; ; ST ← 结束超链接 \x1b]8;;https://example.com\x07Click here\x1b]8;;\x07 ``` -**支持的终端**:iTerm2, kitty, WezTerm, Windows Terminal, Hyper, foot, Contour 等。不支持的终端仅显示文本,无副作用。 +**支持的终端**:iTerm2, kitty, WezTerm, Windows Terminal, Hyper, foot, Contour 等。不支持或禁用 OSC 8 的场景应保持当前纯文本 fallback。 **实现**: @@ -340,6 +405,13 @@ function wrapHyperlink(url: string, text: string): string { - 自动检测的 URL → OSC 8 包裹 - 文件路径 → `file://` URL 包裹(如工具输出中的文件路径) +**安全与兼容**: + +- URL 必须过滤控制字符和 OSC 终止符,避免注入额外 escape sequence +- 仅允许明确协议白名单(如 `http:`, `https:`, `file:`),其他协议按纯文本渲染 +- 不支持 OSC 8 或禁用超链接时,保持当前 `text (url)` 的可复制 fallback +- 在 screen reader 模式下默认使用纯文本 fallback + **影响范围**: - `packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx` — 链接渲染修改 @@ -456,9 +528,9 @@ $$ | 优先级 | 方案 | 周次 | 风险 | 预期收益 | | ------ | ------------------------- | ----- | ------ | ------------------------- | -| P0 | Markdown 解析缓存 | 2 | 低 | 解析耗时 -70%(缓存命中) | -| P0 | 代码高亮缓存 + 懒加载 | 2 | 低 | 启动加速 + 重复渲染消除 | -| P1 | 切换到 marked 解析器 | 7-8 | 中 | GFM 完整支持 | +| 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 | 高 | 长会话性能 | @@ -499,10 +571,13 @@ Markdown fixture 测试集,验证所有支持的格式正确渲染: - 标题(H1-H4) - 代码块(带语言标注 + 无语言 + 嵌套) - 表格(基本 + 对齐 + CJK 内容 + 宽字符) +- 表格回归(ANSI + CJK + emoji、极窄宽度、vertical fallback、代码 span 中 pipe) - 列表(有序 + 无序 + 嵌套 + 混合) - 内联格式(加粗 + 斜体 + 代码 + 链接 + 删除线) - 分割线 - 引用块 +- streaming partial blocks(未闭合代码块、未闭合表格、未闭合列表) +- HTML 输入(默认转义/忽略策略) ### 5.3 主题兼容性