diff --git a/docs/design/tui-optimization/00-overview.md b/docs/design/tui-optimization/00-overview.md new file mode 100644 index 000000000..193d6a377 --- /dev/null +++ b/docs/design/tui-optimization/00-overview.md @@ -0,0 +1,133 @@ +# TUI 优化方案总览 + +> 本文档是 qwen-code TUI 优化的整体方案概览,详细设计分别见三个子文档。 + +## 1. 背景与动机 + +qwen-code 的 TUI 层基于 **Ink 6.2.3 + React 19** 构建,当前面临三个系统性挑战: + +1. **启动性能**:启动流程串行执行,配置 MCP Server 时尤为缓慢,用户需等待所有 Server 连接完成后才能使用工具 +2. **屏幕闪烁**:Ink 的全量重绘机制导致流式输出时严重闪烁,在 tmux/SSH 环境下尤为突出(社区报告高达 4,000-6,700 次/秒的滚动事件) +3. **渲染能力与可扩展性**:自定义正则 Markdown 解析器功能受限,缺少 LaTeX 数学公式、终端超链接等支持,主题系统硬编码 hex 颜色导致部分终端兼容性问题 + +这些问题在 GitHub Issues 中被大量报告(qwen-code#1778, #2748, #2877; claude-code#9935, #37283, #14641 等),是当前最主要的用户体验痛点。 + +## 2. 现状分析 + +### 2.1 当前架构 + +``` +Entry (gemini.tsx) + -> Ink render() 挂载 React 组件树 + -> AppContainer (状态管理中枢, ~2400行) + -> DefaultAppLayout + -> MainContent (Static/Dynamic 分离) + -> MarkdownDisplay (自定义正则解析器) + -> CodeColorizer (lowlight 语法高亮) + -> TableRenderer (Markdown 表格) + -> Composer (输入区) +``` + +| 模块 | 技术方案 | 关键文件 | +| -------- | --------------------------------- | ------------------------------------------------------ | +| 渲染框架 | Ink 6.2.3 (npm 库) + React 19 | `packages/cli/src/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 | Promise.all 并行连接,10分钟超时 | `packages/core/src/tools/mcp-client-manager.ts` | +| 启动分析 | 环境变量开启的 checkpoint 记录器 | `packages/cli/src/utils/startupProfiler.ts` | + +### 2.2 竞品分析:Claude Code + +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 渲染后才启动 | + +### 2.3 社区反馈汇总 + +| 问题类别 | 代表性 Issues | 严重程度 | +| ---------- | ----------------------------------------------- | -------- | +| 屏幕闪烁 | qwen-code#1778, #2748; claude-code#9935, #37283 | 高 | +| 启动慢 | qwen-code#2748; claude-code#5653, #29201 | 高 | +| 表格渲染 | claude-code#14641, #22311 | 中 | +| 主题/颜色 | qwen-code#2877; claude-code#34702, #15771 | 中 | +| 窄屏问题 | claude-code#13504, #18493, #5408 | 中 | +| LaTeX 支持 | claude-code#21433 | 低 | + +## 3. 三大工作流概览 + +| 工作流 | 核心问题 | 关键指标 | 依赖关系 | +| -------------- | -------------------------------------- | ------------------------------ | -------------------------- | +| **启动性能** | 串行启动流程;MCP 阻塞工具可用性 | 可交互时间 (TTI) | 独立,最先启动 | +| **屏幕闪烁** | Ink 全量重绘;无同步输出 | 闪烁事件/秒,stdout 字节/帧 | 部分依赖启动性能(节流) | +| **渲染与扩展** | 正则解析器脆弱;缺少格式支持;主题限制 | 格式覆盖率,渲染耗时,可配置性 | 依赖闪烁修复(稳定输出层) | + +**执行顺序**:启动性能(最独立)-> 屏幕闪烁(解锁渲染改进)-> 渲染与扩展(基于稳定输出层) + +## 4. 分阶段实施计划 + +### Phase 1:快速见效(第 1-4 周) + +| 周次 | 变更 | 工作流 | 风险 | 预期收益 | +| ---- | ----------------------------------------------------------- | ------ | ---- | --------------------------------- | +| 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 色默认主题检测 | 渲染 | 中 | 修复透明终端兼容性 | + +### Phase 2:架构改进(第 5-10 周) + +| 周次 | 变更 | 工作流 | 风险 | +| ---- | ------------------------------------ | ------ | ---- | +| 5-6 | 渐进式 MCP 可用性 + 超时控制 | 性能 | 中 | +| 6-7 | 动态内容高度管理 + 渐进提升到 Static | 闪烁 | 中 | +| 7-8 | 切换到 marked 解析器(特性开关) | 渲染 | 中 | +| 8-9 | 智能 refreshStatic()(定向更新) | 闪烁 | 中 | +| 9-10 | OSC 8 终端超链接 | 渲染 | 低 | +| 10 | 产物体积优化 | 性能 | 中 | + +### Phase 3:深度结构性改造(第 11-16 周) + +| 周次 | 变更 | 工作流 | 风险 | +| ----- | ------------------------------- | ------ | ------ | +| 11-13 | 双缓冲 + diff patch(Ink 扩展) | 闪烁 | 高 | +| 13-15 | 消息历史虚拟滚动 | 渲染 | 高 | +| 15-16 | LaTeX/数学公式渲染 | 渲染 | 中 | +| 远期 | Web 渲染探索(混合架构) | 渲染 | 探索性 | + +## 5. 向后兼容策略 + +- **环境变量**:`QWEN_CODE_LEGACY_RENDERING=1` 可整体关闭所有渲染优化 +- **已有兼容**:`QWEN_CODE_LEGACY_ERASE_LINES=1` 保留用于擦除行优化的回退 +- **主题**:仅默认选择变更,所有 hex 颜色主题保留可用 +- **解析器**:特性开关控制,旧解析器作为过渡期回退 +- **MCP**:所有 Server 快速响应时行为不变 + +## 6. 验证策略 + +1. **自动化基准测试**:启动耗时、渲染时间、stdout 字节/帧 +2. **多终端视觉测试**:iTerm2、Terminal.app、WezTerm、kitty、Windows Terminal、tmux +3. **回归检测**:滚动启动 profile 对比(增强后的分析器) +4. **边界场景**:窄终端 (< 40 列)、超长输出 (5000+ 行)、CJK 内容、tmux/SSH +5. **特性开关**:Phase 2+ 所有变更可安全回滚 + +## 7. 子文档索引 + +| 文档 | 说明 | +| ---------------------------------------------------------------- | --------------------------- | +| [01-performance.md](./01-performance.md) | 启动性能与 MCP 优化详细设计 | +| [02-screen-flickering.md](./02-screen-flickering.md) | 屏幕闪烁问题分析与解决方案 | +| [03-rendering-extensibility.md](./03-rendering-extensibility.md) | 渲染性能与可扩展性设计 | diff --git a/docs/design/tui-optimization/01-performance.md b/docs/design/tui-optimization/01-performance.md new file mode 100644 index 000000000..92b5b0bf2 --- /dev/null +++ b/docs/design/tui-optimization/01-performance.md @@ -0,0 +1,356 @@ +# TUI 优化:启动性能与 MCP + +> 详细设计文档 1/3 — 解决启动缓慢问题,尤其是配置了 MCP Server 的场景。 + +## 1. 问题分析 + +### 1.1 启动流程现状 + +启动入口位于 `packages/cli/src/gemini.tsx` 的 `main()` 函数(第 290 行),执行一个**严格串行**的初始化管线: + +``` +T0: profileCheckpoint('main_entry') ← 第 291 行 + │ + ├─ loadSettings() [同步, 读取 4-5 个 JSON] ← 第 293 行 + ├─ cleanupCheckpoints() [异步, 等待完成] ← 第 294 行 + ├─ parseArguments() [异步, yargs 解析] ← 第 297 行 + ├─ dns.setDefaultResultOrder() ← 第 310 行 + ├─ themeManager.loadCustomThemes() [同步] ← 第 315 行 + │ + ├─ Sandbox 检查 + 可能的进程重启 ← 第 328-415 行 + │ ├─ loadSandboxConfig() [异步, 文件 I/O] + │ ├─ loadCliConfig() [异步, 仅用于沙箱场景] + │ ├─ validateAuth() [异步, 可能触发网络请求] + │ └─ start_sandbox() 或 relaunchAppInChildProcess() + │ + ├─ loadCliConfig() [异步, 合并所有配置源] ← 第 445 行 + ├─ initializeApp() [异步: i18n + auth + IDE] ← 第 507 行 + ├─ 收集启动警告 ← 第 518-535 行 + ├─ Kitty 协议检测 [异步] ← 第 543 行 + ├─ startInteractiveUI() [渲染 React 树] ← 第 544 行 + │ + └─ config.initialize() [UI 渲染后, MCP 发现在此] ← config.ts 第 872 行 + ├─ FileDiscoveryService 初始化 + ├─ GitService 初始化 + ├─ PromptRegistry 初始化 + ├─ ExtensionManager 初始化 + ├─ HookSystem 初始化 + └─ discoverAllMcpTools() [MCP 发现] +``` + +### 1.2 各阶段耗时分析 + +基于启动分析器(`packages/cli/src/utils/startupProfiler.ts`)的 checkpoint 数据和代码分析: + +| 阶段 | 估计耗时 | I/O 操作 | 瓶颈类型 | +| --------------------------------- | ---------- | ------------------ | ------------ | +| 模块加载(V8 解析 23.7MB bundle) | 200-500ms | 1 次磁盘读取 | CPU + I/O | +| Settings 加载 | 50-200ms | 4-5 次文件读取 | 串行 I/O | +| 参数解析 | 10-30ms | 无 | CPU | +| 主题加载 | 5-10ms | 无 | CPU | +| Sandbox/进程重启检查 | 10-50ms | 1-2 次文件读取 | I/O | +| loadCliConfig() | 50-100ms | 2-3 次文件读取 | 合并操作 | +| initializeApp() | 50-200ms | LSP 发现(如启用) | I/O + 网络 | +| UI 渲染 | 100-300ms | 无 | React 初始化 | +| config.initialize() | 500ms-5s+ | 文件扫描 + MCP | MCP 子进程 | +| MCP 发现 | 500ms-10s+ | 子进程启动 + 网络 | 网络延迟 | + +**关键发现**: + +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 会拖慢整个工具可用性 + +### 1.3 MCP 初始化详细分析 + +MCP 客户端管理器位于 `packages/core/src/tools/mcp-client-manager.ts`: + +```typescript +// discoverAllMcpTools() 关键流程 +async discoverAllMcpTools(cliConfig: Config): Promise { + await this.stop(); // 清理已有连接 + + const servers = populateMcpServerCommand( + cliConfig.getMcpServers() || {}, + cliConfig.getMcpServerCommand(), + ); + + this.discoveryState = MCPDiscoveryState.IN_PROGRESS; + + // 跨 Server 并行 — 这一点已经做得不错 + 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 { + await client.connect(); // 子进程启动 / TCP 连接 + await client.discover(cliConfig); // 工具枚举 + } catch (error) { /* 记录但不阻塞 */ } + }, + ); + + await Promise.all(discoveryPromises); // 等待所有 Server + this.discoveryState = MCPDiscoveryState.COMPLETED; +} +``` + +**问题**: + +- `await Promise.all(discoveryPromises)` 意味着最慢的 Server 决定整体完成时间 +- 工具注册发生在所有 Server 发现完成后,而非逐个注册 +- 默认超时 10 分钟过长,用户需等待不可接受的时间 +- 发现流程在 `config.initialize()` → `createToolRegistry()` → `registry.discoverAllTools()` 调用链中被前置初始化步骤阻塞 + +## 2. 解决方案 + +### 2.1 [P0] 并行 Settings 加载 + +**现状**:`loadSettings()` 在 `packages/cli/src/config/settings.ts` 中通过 `fs.readFileSync` 串行读取系统默认、系统配置、用户配置、工作区配置等 4-5 个 JSON 文件。 + +**方案**: + +1. 将 `fs.readFileSync` 替换为 `fs.promises.readFile` +2. 使用 `Promise.all` 并行读取所有配置文件 +3. 读取完成后再执行串行的合并逻辑(合并本身很快,瓶颈在 I/O) +4. 将 `loadSettings()` 签名从同步改为异步 + +**影响范围**: + +- `packages/cli/src/config/settings.ts` — 核心修改 +- `packages/cli/src/gemini.tsx:293` — 调用处加 `await`(`main()` 已是 async) + +**预期收益**:Settings 加载阶段耗时降低 30-50%(从 ~150ms 降至 ~80ms)。 + +**验证方式**: + +```bash +QWEN_CODE_PROFILE_STARTUP=1 qwen-code --prompt "test" +# 对比 after_load_settings 阶段耗时 +``` + +### 2.2 [P0] 并行化 UI 前初始化 + +**现状**:`loadCliConfig()` 之后,`initializeApp(config, settings)` 串行执行 i18n、auth、IDE 连接。而 `initializeApp` 依赖 `config` 参数,因此不能与 `loadCliConfig` 并行。但 `initializeApp` 内部的子步骤可以并行化,且启动警告收集、Kitty 协议检测等与 `initializeApp` 无依赖关系。 + +**方案**:拆分 `initializeApp()`,将其内部子步骤与其他独立步骤并行执行。 + +**拆分 initializeApp 内部**(`packages/cli/src/core/initializer.ts` 第 37-58 行): + +```typescript +// 当前 initializeApp 内部是串行的: +// await initializeI18n(...) // 不依赖 config +// await performInitialAuth(config, authType) // 依赖 config +// if (ideMode) await ideClient.connect() // 依赖 config + +// 优化:i18n 可以与 loadCliConfig 并行 +const [config, _i18n] = await Promise.all([ + loadCliConfig(settings.merged, argv, ...), + initializeI18n(settings.merged), // 仅依赖 settings,不依赖 config +]); + +// config 就绪后,auth 与其他步骤并行 +const [_auth, startupWarnings, userWarnings, _kitty] = await Promise.all([ + performInitialAuth(config, authType), + getStartupWarnings(), + getUserStartupWarnings(settings), + detectKittyProtocol(), +]); +``` + +**影响范围**: + +- `packages/cli/src/gemini.tsx`(第 440-550 行)— 重组初始化顺序 +- `packages/cli/src/core/initializer.ts`(第 37-58 行)— 拆分 `initializeApp()` 为独立可组合的函数 + +**前置条件**:已验证 `initializeI18n` 仅依赖 `settings.merged` 中的语言设置,不依赖 `config`,可与 `loadCliConfig` 并行。`performInitialAuth` 依赖 `config`,必须等 config 就绪后执行。 + +**预期收益**:`before_render` checkpoint 耗时减少 200-400ms(主要来自 i18n 与 config 并行 + auth 与警告/检测并行)。 + +### 2.3 [P1] 渐进式 MCP 可用性 + +**现状**:所有 MCP Server 完成发现后才统一注册工具,用户在此之前无法使用任何 MCP 工具。 + +**方案**: + +1. **提前启动 MCP 发现**:在 config 加载完成后立即开始 MCP 发现(fire-and-forget),不等 UI 渲染 +2. **逐 Server 注册**:每个 Server 发现完成后立即注册其工具到 ToolRegistry,而非等待所有 Server +3. **合理超时**:将发现阶段默认超时从 10 分钟降至 30 秒,支持 `serverConfig.timeout` 覆盖 +4. **UI 进度指示**:添加 "N/M MCP Servers 已连接" 状态显示 + +**核心代码变更**(`packages/core/src/tools/mcp-client-manager.ts`): + +```typescript +async discoverAllMcpTools( + cliConfig: Config, + onServerReady?: (name: string) => void // 新增:逐 Server 回调 +): Promise { + // ... 省略前置代码 ... + + const discoveryPromises = Object.entries(servers).map( + async ([name, config]) => { + const client = new McpClient(name, config, ...); + this.clients.set(name, client); + + try { + // 使用 Promise.race 限制单 Server 超时 + await Promise.race([ + (async () => { + await client.connect(); + await client.discover(cliConfig); + onServerReady?.(name); // 立即通知该 Server 就绪 + })(), + new Promise((_, reject) => + setTimeout(() => reject(new Error(`MCP server "${name}" discovery timeout`)), + config.timeout ?? 30_000 // 30秒默认超时 + ) + ), + ]); + } catch (error) { /* 记录但不阻塞 */ } + }, + ); + + await Promise.all(discoveryPromises); + this.discoveryState = MCPDiscoveryState.COMPLETED; +} +``` + +**实现路径**: + +代码审查发现 `createToolRegistry()` 已支持 `skipDiscovery` 选项(`config.ts` 第 2584-2586 行),且 `discoverMcpTools()` 方法可独立调用(`tool-registry.ts` 第 385-393 行)。因此可以实现两阶段初始化: + +```typescript +// 阶段 1:快速创建工具注册表(跳过发现) +await createToolRegistry({ skipDiscovery: true }); + +// 阶段 2:异步 MCP 发现(fire-and-forget 或带回调) +void toolRegistry.discoverMcpTools(onServerReady); +``` + +**影响范围**: + +- `packages/core/src/tools/mcp-client-manager.ts` — 添加渐进回调、超时控制 +- `packages/core/src/config/config.ts` — 利用已有的 `skipDiscovery` 选项,在 `initialize()` 中跳过 MCP,另行启动 +- `packages/core/src/tools/tool-registry.ts` — 利用已有的 `discoverMcpTools()` 方法 +- `packages/cli/src/ui/AppContainer.tsx` — 添加 MCP 连接状态显示 + +**预期收益**: + +- 首个工具可用时间从 "等待所有 Server" 降至 "最快 Server 响应时间"(通常 < 2秒) +- 慢 Server 不再阻塞其他 Server 的工具使用 + +**风险点**: + +- 工具列表在会话中动态变化(逐渐增加),需确保 LLM prompt 中的工具描述能动态更新 +- 超时降低可能导致网络慢的环境误判 Server 不可用 → 通过配置项允许用户调整 + +### 2.4 [P1] 启动分析器增强 + +**现状**:`packages/cli/src/utils/startupProfiler.ts` 仅记录粗粒度 phase 边界,无法定位 `config.initialize()` 内部的具体瓶颈。 + +**方案**: + +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/`,支持回归检测 + +**影响范围**: + +- `packages/cli/src/utils/startupProfiler.ts` — 增强记录能力 +- `packages/core/src/config/config.ts` — 添加子 checkpoint 调用 + +### 2.5 [P2] 产物体积优化 + +**现状**:`dist/cli.js` 约 23MB,V8 冷启动解析耗时不可忽略。 + +**方案**: + +1. 使用 `source-map-explorer` 或 `esbuild-analyzer` 分析体积构成 +2. **lowlight 语法库懒加载**:当前 `CodeColorizer.tsx:9` 通过 `import { common } from 'lowlight'` 一次性加载约 40 种语言语法,改为按需注册 +3. 未使用主题定义的 tree-shaking +4. 考虑将代码高亮拆分为独立 chunk 或 worker + +**影响范围**: + +- 构建配置 +- `packages/cli/src/ui/utils/CodeColorizer.tsx` — 延迟加载语法 + +**预期收益**:`processUptimeAtT0Ms`(V8 解析时间)减少 20%+。 + +## 3. 竞品参考 + +### Claude Code 启动优化策略 + +Claude Code 在 `src/main.tsx` 中实现了激进的并行初始化: + +```typescript +// MCP 配置提前并行加载 +const [localMcpPromise, claudeaiMcpPromise] = [ + loadLocalMcpConfig(), + loadClaudeAiMcpConfig(), +]; + +// 设置/信任对话框与 MCP 连接并行运行 +Promise.all([ensureMdmSettingsLoaded(), ensureKeychainPrefetchCompleted()]); + +// MCP 连接使用 Promise.race 超时保护 +Promise.race([claudeaiConnect, timeout]); + +// 渲染后延迟预取(fire-and-forget) +void prefetchAllMcpResources(); + +// 特性门控的懒加载 +const module = feature('FLAG') ? require('./module.js') : null; +``` + +**关键设计差异**: + +- Claude Code 的 MCP 配置加载在 UI 渲染**之前**就开始 +- 使用 `Promise.race` 而非等待所有 Server +- 非关键预取使用 `void` fire-and-forget 模式 +- 特性门控避免加载不需要的模块 + +## 4. 实施优先级与里程碑 + +| 优先级 | 方案 | 周次 | 风险 | 预期改善 | +| ------ | ------------------ | ---- | ---- | -------------------- | +| P0 | 并行 Settings 加载 | 3 | 低 | 配置加载耗时 -30~50% | +| P0 | 并行化 UI 前初始化 | 4 | 低 | TTI -200~400ms | +| P1 | 渐进式 MCP 可用性 | 5-6 | 中 | 首工具可用 < 2s | +| P1 | 启动分析器增强 | 3 | 低 | 持续监控能力 | +| P2 | 产物体积优化 | 10 | 中 | 冷启动 -20% | + +## 5. 验证方案 + +### 5.1 定量指标 + +```bash +# 启动 profile 对比 +QWEN_CODE_PROFILE_STARTUP=1 qwen-code --prompt "test" + +# 重点关注指标: +# - processUptimeAtT0Ms: V8 模块解析时间 +# - after_load_settings: 配置加载完成时间 +# - before_render: UI 渲染前总耗时 +# - 新增: mcp_first_tool_available: 首个 MCP 工具可用时间 +# - 新增: mcp_all_tools_available: 所有 MCP 工具可用时间 +``` + +### 5.2 测试场景 + +| 场景 | 期望行为 | +| ---------------------------- | ---------------------------------------------- | +| 无 MCP Server | 启动时间不受 MCP 影响 | +| 1 个快速 MCP Server | 工具在 < 2s 内可用 | +| 3 个 MCP Server(1 慢 2 快) | 快速 Server 工具立即可用,慢 Server 超时后降级 | +| MCP Server 连接失败 | 错误记录但不阻塞启动 | +| 网络不可用 | 超时后优雅降级,显示警告 | +| 冷启动 vs 热启动 | 两种场景均有改善 | + +### 5.3 向后兼容 + +- `loadSettings()` 签名变更需更新所有调用点 +- MCP 超时降低需提供配置项允许用户恢复长超时 +- 渐进式工具注册需确保不破坏现有的工具描述生成逻辑 diff --git a/docs/design/tui-optimization/02-screen-flickering.md b/docs/design/tui-optimization/02-screen-flickering.md new file mode 100644 index 000000000..3fab16685 --- /dev/null +++ b/docs/design/tui-optimization/02-screen-flickering.md @@ -0,0 +1,392 @@ +# TUI 优化:屏幕闪烁 + +> 详细设计文档 2/3 — 解决流式输出、窄屏、终端 resize 等场景下的屏幕闪烁问题。 + +## 1. 问题分析 + +### 1.1 闪烁的根本原因 + +Ink 6.2.3 的渲染模型决定了闪烁问题的根源: + +1. **全量重绘**:每次 React 状态变更,Ink 对整个动态区域执行 `eraseLines(N)` + 重新输出。`eraseLines` 会逐行发出 `ERASE_LINE + CURSOR_UP` 序列对,然后重写所有内容。 +2. **超高重绘频率**:流式输出时每个内容 chunk(可包含一到多个 token)触发一次状态更新和重绘,高频时可达 50+ 次/秒。 +3. **全屏回退路径**:当动态内容高度超过终端高度时,Ink 切换到 `clearTerminal` + 全量重写,产生整屏闪烁。 + +### 1.2 当前缓解措施 + +#### terminalRedrawOptimizer.ts + +位于 `packages/cli/src/ui/utils/terminalRedrawOptimizer.ts`,通过拦截 `stdout.write()` 优化 ANSI 序列: + +```typescript +// 核心优化:折叠重复的 ERASE_LINE + CURSOR_UP 序列 +// 原始序列(N 行): +// ESC[2K ESC[1A ESC[2K ESC[1A ... ESC[2K ESC[G +// 优化后: +// ESC[NA ESC[2K ESC[1B ESC[2K ESC[1B ... ESC[NA ESC[G +``` + +**局限**: + +- 仅优化光标移动模式,不减少实际输出字节数 +- 不解决 Ink 全量重绘的根本问题 +- 不支持同步输出协议 +- 对全屏清除路径无效 + +#### Static/Dynamic 分离 + +`packages/cli/src/ui/components/MainContent.tsx` 使用 Ink 的 `` 组件分离已完成内容和流式内容: + +```typescript +// Static 区域:已完成的历史消息,追加后不再更新 + + {(item) => } + + +// Dynamic 区域:当前流式内容,每帧重绘 + + {pendingHistoryItems.map((item) => )} + +``` + +**局限**: + +- 当流式内容本身超过终端高度时仍会触发全屏重绘 +- `refreshStatic()` 使用 `clearTerminal` 导致整屏闪烁(resize、compact 切换等场景) + +### 1.3 具体闪烁场景 + +| 场景 | 触发条件 | 严重程度 | 代码位置 | +| ---------------- | -------------------------------------- | -------- | ---------------------------- | +| 流式输出 | 每个内容 chunk 触发 React re-render | 高 | `useGeminiStream` hook | +| 长输出超屏 | 动态内容高度 > 终端行数 | 严重 | Ink 内部 `eraseLines` 路径 | +| 终端 resize | `refreshStatic()` 调用 `clearTerminal` | 中 | `AppContainer.tsx:1508-1517` | +| Compact 模式切换 | 历史合并触发 `refreshStatic()` | 中 | `MainContent.tsx:80` | +| 窄屏布局抖动 | 布局重算导致内容高度反复变化 | 中 | Ink 布局引擎 | +| tmux/SSH | 终端复用器放大闪烁效果 | 严重 | 终端环境因素 | + +### 1.4 社区反馈 + +- **qwen-code#1778**:流式输出时屏幕闪烁 +- **qwen-code#2748**:MCP 加载时闪烁 + 慢启动 +- **claude-code#9935**:tmux 中 4,000-6,700 次/秒滚动事件 +- **claude-code#37283**:长输出全屏闪烁 +- **claude-code#10794**:SSH 远程场景闪烁加剧 + +## 2. 解决方案 + +### 2.1 [P0] 同步输出 — DECSET 2026 + +**原理**:[同步输出协议](https://gist.github.com/christianparpart/d8a62cc1ab659194f6ca7e8e5b1b1814) 允许应用通过转义序列告知终端"我正在更新帧,请暂缓显示直到帧完成"。 + +``` +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+ (透传) | +| 不支持的终端 | 静默忽略序列,零副作用 | + +**实现方案**: + +在现有的 `terminalRedrawOptimizer.ts` 中扩展 `optimizedWrite`。 + +**需要确认的前提**:Ink 是否每帧只调用一次 `stdout.write()`?当前优化器的 `optimizeMultilineEraseLines()` 处理的是**单次 write 内**的 ANSI 序列折叠,这暗示 Ink 大概率将完整帧内容在单次 write 中输出。如果确认如此,BSU/ESU 可直接包裹单次 write: + +```typescript +const BSU = '\x1b[?2026h'; // Begin Synchronized Update +const ESU = '\x1b[?2026l'; // End Synchronized Update + +const optimizedWrite = function ( + this: NodeJS.WriteStream, + chunk: unknown, + encodingOrCallback?: BufferEncoding | ((error?: Error | null) => void), + callback?: (error?: Error | null) => void, +) { + let optimizedChunk = chunk; + if (typeof chunk === 'string') { + optimizedChunk = optimizeMultilineEraseLines(chunk); + // 检测是否包含帧更新(包含擦除序列即视为帧更新) + if (chunk.includes(ERASE_LINE) || chunk.includes('\x1b[2J')) { + optimizedChunk = BSU + optimizedChunk + ESU; + } + } + return originalWrite.call(this, optimizedChunk as string | Uint8Array, ...); +}; +``` + +**如果 Ink 每帧多次 write**:需要改用帧缓冲策略 — 在 idle tick 中收集所有 write 调用,合并后统一输出。实现更复杂但同样可行。 + +**验证步骤**:在优化器中添加临时计数器,统计单次 React render 触发多少次 `stdout.write()`。 + +**影响范围**:仅 `packages/cli/src/ui/utils/terminalRedrawOptimizer.ts` + +**风险评估**:**极低** + +- 不支持的终端静默忽略 BSU/ESU 序列 +- 可通过 `QWEN_CODE_LEGACY_ERASE_LINES=1` 完全禁用 +- Claude Code 在 `src/ink/terminal.ts` 中已使用相同方案 + +**预期收益**: + +- 消除大部分可见的帧撕裂和闪烁 +- tmux 场景下效果最为显著(从数千次/秒滚动事件降至帧率级别) +- 不改变渲染管线,仅改变终端侧行为 + +### 2.2 [P0] 流式更新节流 + +**现状**:LLM 流式输出的每个内容 chunk 都触发 React 状态更新。虽然不是逐 token 更新(而是按 API 返回的 chunk 粒度),但在高速流式输出时仍可能产生每秒 50+ 次 re-render。人眼对文本更新的感知频率约 15-20fps,大量渲染被浪费。 + +**方案**:在流式 hook 中实现 chunk 缓冲 + 定时刷新。 + +```typescript +// packages/cli/src/ui/hooks/useGeminiStream.ts(概念实现) + +const chunkBufferRef = useRef(''); +const flushTimerRef = useRef(null); +const FLUSH_INTERVAL_MS = 60; // ≈16fps,足够文本展示 + +const flushBuffer = useCallback(() => { + if (chunkBufferRef.current) { + setStreamingContent((prev) => prev + chunkBufferRef.current); + chunkBufferRef.current = ''; + } + flushTimerRef.current = null; +}, []); + +const onContentChunk = useCallback( + (chunk: string) => { + chunkBufferRef.current += chunk; + if (!flushTimerRef.current) { + flushTimerRef.current = setTimeout(flushBuffer, FLUSH_INTERVAL_MS); + } + }, + [flushBuffer], +); + +// 流结束时立即刷新 +const onStreamEnd = useCallback(() => { + if (flushTimerRef.current) clearTimeout(flushTimerRef.current); + flushBuffer(); +}, [flushBuffer]); +``` + +**影响范围**:`packages/cli/src/ui/hooks/useGeminiStream.ts` + +**具体切入点**:`handleContentEvent()` 回调(第 664 行),该函数在每个内容 chunk 到达时调用 `setPendingHistoryItem()`(第 690 行)触发 React 状态更新。节流层应在 `setPendingHistoryItem` 调用之前缓冲内容。 + +**风险评估**:低 + +- 60ms 延迟对用户不可感知 +- 流结束时立即刷新确保最终内容完整 +- 如有问题可调整 `FLUSH_INTERVAL_MS` 或通过环境变量禁用 + +**预期收益**:`stdout.write` 调用从 50+/秒降至 < 20/秒,直接减少 60%+ 的渲染开销。 + +### 2.3 [P1] 动态内容高度管理 + 渐进提升 + +**现状**:当流式内容超过终端高度时,Ink 触发全屏重绘。`constrainHeight` 状态标志虽然存在(`AppContainer.tsx:1482`),但计算不够精确。 + +**方案**:实现"渐进提升"(Progressive Promotion)模式 — 随着流式内容增长,将已完成的块从动态区域提升到 `` 区域。 + +**核心逻辑**: + +``` +流式输出开始 + ├─ 新 token 追加到 pendingContent + ├─ 检查 pendingContent 高度 vs 可用动态区域高度 + │ ├─ 高度安全 → 继续累积 + │ └─ 接近阈值 → + │ ├─ 使用 findLastSafeSplitPoint() 找到安全分割点 + │ ├─ 分割点之前的内容 → 提升到 history (Static) + │ └─ 分割点之后的内容 → 保留在 pending (Dynamic) + └─ 流结束 → 全部提升到 history +``` + +`findLastSafeSplitPoint()` 已存在于 `packages/cli/src/ui/utils/markdownUtilities.ts`,专为此类场景设计: + +- 不在代码块内部分割 +- 优先在段落边界 `\n\n` 分割 +- 回退到行边界 `\n` + +**影响范围**: + +- `packages/cli/src/ui/components/MainContent.tsx` — 实现提升逻辑 +- `packages/cli/src/ui/AppContainer.tsx` — 改进高度计算 +- `packages/cli/src/ui/hooks/useGeminiStream.ts` — 在流式处理中调用分割 + +**风险评估**:中 + +- 分割可能导致部分 Markdown 上下文丢失(如跨段落的列表)→ 通过保守的分割策略缓解 +- 频繁提升可能导致 `` 闪烁 → 设置最小提升间隔(如 500ms) + +**预期收益**:动态内容始终控制在终端高度内,永远不触发 Ink 的全屏重绘路径。 + +### 2.4 [P1] 智能 refreshStatic() + +**现状**:`refreshStatic()` 在 `AppContainer.tsx` 中通过 `clearTerminal`(完整的 `ESC[2J ESC[3J ESC[H`)实现全屏清除后重新挂载: + +```typescript +// AppContainer.tsx 当前实现 +const refreshStatic = useCallback(() => { + process.stdout.write(ansiEscapes.clearTerminal); + setHistoryRemountKey((prev) => prev + 1); // 触发 重新渲染 +}, []); +``` + +触发场景: + +- 终端 resize(300ms debounce):`AppContainer.tsx:1508-1517` +- Compact 模式合并:`MainContent.tsx:80` +- 手动清屏:`AppContainer.tsx:1200` +- Auth 变更:`AppContainer.tsx:498` + +**方案**: + +1. **Resize 优化**:仅重绘动态区域而非全屏清除 + + ```typescript + const handleResize = useCallback( + debounce(() => { + // 不再 clearTerminal,仅更新布局尺寸 + updateTerminalDimensions(); + // 只在宽度变化时才需要重新渲染(高度变化不影响已渲染内容) + if (widthChanged) { + refreshStatic(); // 宽度变化时仍需全量重绘(行包装会变) + } + }, 500), + [], + ); + ``` + +2. **Compact 模式合并**:使用增量更新而非全量重绘 + - 仅当合并确实改变了可见内容时触发刷新 + - 增加合并去抖动间隔 + +3. **增加 resize debounce 到 500ms**(从 300ms),因为 resize 事件通常成组到达 + +**影响范围**:`packages/cli/src/ui/AppContainer.tsx`(第 462-464, 1508-1517 行) + +### 2.5 [P2] 双缓冲 + Diff Patch(Phase 3) + +**现状**:Ink 每帧都向 stdout 写入完整的新内容。 + +**方案**:维护一个 2D 字符网格作为"后缓冲区",每次渲染时仅输出与当前缓冲区不同的单元格。 + +**架构设计**: + +``` +React 状态更新 + → Ink 渲染管线(产出新帧文本) + → ScreenBuffer.diff(oldFrame, newFrame) + → 产出 Patch 列表 [{row, col, content, style}] + → 序列化为最小 ANSI 序列 + → 单次 stdout.write(BSU + patches + ESU) +``` + +**核心数据结构**: + +```typescript +interface Cell { + char: string; // 单个字符/grapheme cluster + styleId: number; // 内化的样式 ID + hyperlinkId: number; // 内化的超链接 ID +} + +class ScreenBuffer { + private cells: Cell[][]; // rows × cols + private width: number; + private height: number; + + diff(newBuffer: ScreenBuffer): Patch[]; + apply(patches: Patch[]): string; // 生成 ANSI 序列 +} +``` + +**风险评估**:高 + +- 需要拦截 Ink 的输出层或 fork Ink +- 字符宽度计算(CJK、emoji)需要精确匹配 Ink 的计算 +- 样式边界的 diff 比纯文本 diff 复杂得多 + +**参考**:Claude Code 在 `src/ink/screen.ts` 中实现了完整的双缓冲 + StylePool + CharPool,是最成熟的参考实现。 + +**建议**:先评估 Phase 1 的同步输出 + 节流效果。如果已满足需求,可降低此方案优先级。 + +### 2.6 [P2] DECSTBM 滚动区域优化(Phase 3) + +**原理**:使用 CSI DECSTBM(Set Top and Bottom Margins)设定终端滚动区域,当内容需要滚动时发出 `CSI n S`(scroll up)指令,由终端硬件执行滚动而非重写整个视口。 + +**前置条件**:需要双缓冲(2.5)作为基础。 + +**参考**:Claude Code 的 `src/ink/render-node-to-output.ts` 实现了自适应 drain 策略: + +- xterm.js:5 行以下即时,12 行以上平滑步进 +- 原生终端:待处理行数的 3/4,最少 4 行 + +## 3. 竞品参考 + +### 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` | + +**关键洞察**:Claude Code 的经验表明,同步输出(第 3 层)是**单项收益最大**的优化,而双缓冲 + diff(第 1-2 层)提供了最彻底的解决方案。我们的 Phase 1 策略(同步输出 + 节流)可以以约 10% 的实现成本获得约 70% 的效果。 + +## 4. 实施优先级与里程碑 + +| 优先级 | 方案 | 周次 | 风险 | 预期收益 | +| ------ | -------------------- | ----- | ---- | ------------------------- | +| P0 | 同步输出 DECSET 2026 | 1 | 极低 | 消除帧撕裂,tmux 效果显著 | +| P0 | 流式更新节流 60ms | 1 | 低 | stdout.write -60%+ | +| P1 | 渐进提升到 Static | 6-7 | 中 | 消除长输出全屏闪烁 | +| P1 | 智能 refreshStatic() | 8-9 | 中 | resize 不再全屏闪烁 | +| P2 | 双缓冲 + diff patch | 11-13 | 高 | stdout 字节/帧 -80% | +| P2 | DECSTBM 滚动区域 | 13+ | 高 | 滚动性能接近原生 | + +## 5. 验证方案 + +### 5.1 定量指标 + +| 指标 | 当前估计 | Phase 1 目标 | Phase 3 目标 | +| ---------------------------- | ----------- | -------------------- | ------------ | +| stdout.write 调用/秒(流式) | 50+ | < 20 | < 16 | +| stdout 字节/帧(增量更新) | 全帧大小 | 全帧大小(同步包裹) | 仅变更 cell | +| tmux 滚动事件/秒 | 4,000-6,700 | < 100 | < 20 | +| 可见闪烁(主观) | 严重 | 轻微/无 | 无 | + +### 5.2 测试场景 + +| 场景 | 测试方法 | 验收标准 | +| -------------- | ----------------------- | -------------------- | +| 正常流式输出 | 生成 500 token 响应 | 无可见闪烁 | +| 超长输出 | 生成 5000+ 行响应 | 不触发全屏清除 | +| 终端 resize | 快速拖拽窗口大小 | 无全屏闪烁 | +| 窄屏 (< 40 列) | 将终端缩至 30 列 | 布局优雅降级,无抖动 | +| tmux 内运行 | tmux 分屏环境 | 滚动事件 < 100/秒 | +| SSH 远程 | 高延迟网络 | 闪烁不加剧 | +| kitty/WezTerm | 支持 DECSET 2026 的终端 | 零帧撕裂 | +| Terminal.app | 不支持 DECSET 2026 | 行为不变(不退化) | + +### 5.3 向后兼容 + +- `QWEN_CODE_LEGACY_ERASE_LINES=1`:禁用所有 stdout 拦截优化(已有) +- `QWEN_CODE_LEGACY_RENDERING=1`:新增,禁用同步输出 + 节流 +- 不支持 DECSET 2026 的终端:序列被静默忽略,零风险 diff --git a/docs/design/tui-optimization/03-rendering-extensibility.md b/docs/design/tui-optimization/03-rendering-extensibility.md new file mode 100644 index 000000000..e432eee5f --- /dev/null +++ b/docs/design/tui-optimization/03-rendering-extensibility.md @@ -0,0 +1,516 @@ +# TUI 优化:渲染性能与可扩展性 + +> 详细设计文档 3/3 — 提升渲染性能,支持更多格式,增强主题可配置性,探索远期方向。 + +## 1. 问题分析 + +### 1.1 Markdown 解析器现状 + +当前使用自定义正则逐行解析器(`packages/cli/src/ui/utils/MarkdownDisplay.tsx`,461 行): + +```typescript +// MarkdownDisplayInternal 核心循环 +const lines = text.split(/\r?\n/); +const headerRegex = /^ *(#{1,4}) +(.*)/; +const codeFenceRegex = /^ *(`{3,}|~{3,}) *(\w*?) *$/; +const ulItemRegex = /^([ \t]*)([-*+]) +(.*)/; +const olItemRegex = /^([ \t]*)(\d+)\. +(.*)/; +const hrRegex = /^ *([-*_] *){3,} *$/; +const tableRowRegex = /^\s*\|(.+)\|\s*$/; +const tableSeparatorRegex = + /^(?=.*\|)\s*\|?\s*(:?-+:?)\s*(\|\s*(:?-+:?)\s*)*\|?\s*$/; + +// 在循环中逐行用这 7 个正则匹配,无解析结果缓存 +for (let i = 0; i < lines.length; i++) { + // headerRegex.exec(line) + // codeFenceRegex.exec(line) + // ulItemRegex.exec(line) + // ... 逐个正则尝试匹配当前行 +} +``` + +**问题**: + +1. **无解析缓存**:每次 React re-render 都对完整文本重新解析。流式输出时,每新增一个 token 就重新解析所有已累积文本 +2. **功能受限**:不支持 GFM 任务列表、脚注、嵌套格式、定义列表等 +3. **正则脆弱性**:边界情况处理不完整,如表格与 CJK 字符的交互、嵌套代码块等 +4. **性能线性退化**:文本越长,每帧解析耗时线性增长 + +### 1.2 代码高亮现状 + +`packages/cli/src/ui/utils/CodeColorizer.tsx`(224 行): + +```typescript +import { common, createLowlight } from 'lowlight'; +const lowlightInstance = createLowlight(common); // 启动时加载 ~40 种语法 +``` + +**问题**: + +1. **急切加载**:`import { common }` 在模块级别加载约 40 种语言语法到内存,增加启动时间和内存占用 +2. **无高亮缓存**:每次渲染相同代码块都重新调用 `lowlight.highlight()` +3. **`highlightAuto()` 昂贵**:未指定语言时的自动检测需遍历所有已注册语法 + +### 1.3 表格渲染现状 + +`packages/cli/src/ui/utils/TableRenderer.tsx`(540 行): + +**问题**: + +- CJK/宽字符的列宽计算存在 bug(GitHub 反馈) +- 特定终端宽度下表格消失或错位 +- 对齐方式(`:---:` 等)的解析与渲染存在边缘情况 + +### 1.4 主题系统现状 + +`packages/cli/src/ui/themes/theme-manager.ts`: + +```typescript +// 大多数主题使用 hex 颜色 +export const QwenDark: Theme = { + name: 'QwenDark', + colors: { + Background: '#0b0e14', + Foreground: '#bfbdb6', + AccentBlue: '#39BAE6', + // ... + }, +}; +``` + +**问题**: + +1. **hex 颜色硬编码**:绕过终端调色板,破坏透明背景终端 +2. **无终端能力检测**:不区分 truecolor/256 色/16 色终端 +3. **仅 ANSI/ANSILight 使用 16 色**:但非默认主题 + +### 1.5 缺失的渲染能力 + +| 能力 | 现状 | 用户需求 | +| ------------------ | ------------------ | ----------------- | +| LaTeX 数学公式 | 不支持 | claude-code#21433 | +| 终端超链接 (OSC 8) | URL 渲染为纯文本 | 点击跳转 | +| 虚拟滚动 | 无,长会话性能退化 | 长会话场景 | +| 图表/图像 | 不支持 | 远期探索 | + +## 2. 解决方案 + +### 2.1 [P0] Markdown 解析结果缓存 + +**目标**:消除流式输出时的重复解析开销。 + +**方案**:实现 block 级别的 LRU 缓存。 + +**设计**: + +```typescript +// 新增缓存层 +const PARSE_CACHE_MAX = 500; +const parseCache = new LRUCache(PARSE_CACHE_MAX); + +function parseMarkdownBlocks(text: string): React.ReactNode[] { + const cacheKey = hashContent(text); + const cached = parseCache.get(cacheKey); + if (cached) return cached; + + // ... 现有解析逻辑 ... + const blocks = doParseBlocks(text); + parseCache.set(cacheKey, blocks); + return blocks; +} +``` + +**流式优化**:利用现有的 `findLastSafeSplitPoint()` 实现增量解析。 + +```` +全文: "# Title\n\nParagraph 1\n\nParagraph 2\n\n```code block..." + ├──── 已完成块 ────┤├── 已完成块 ──┤├── 当前块 ──┤ + 缓存命中(不重解析) 缓存命中 重新解析(仅此块) +```` + +**影响范围**:`packages/cli/src/ui/utils/MarkdownDisplay.tsx` + +**预期收益**:缓存命中时解析耗时降低 70%+。对于 1000 行的流式输出,每帧仅需解析最后一个不完整块(通常 < 50 行),而非全部 1000 行。 + +**参考**:Claude Code 使用模块级 LRU 缓存(500 条目),key 为内容 hash,避免保留完整字符串引用。 + +### 2.2 [P0] 代码高亮优化 + +**方案 A:语法库懒加载** + +```typescript +// 当前(急切加载) +import { common, createLowlight } from 'lowlight'; +const lowlightInstance = createLowlight(common); + +// 优化后(按需加载) +import { createLowlight } from 'lowlight'; +const lowlightInstance = createLowlight(); // 空实例 + +const GRAMMAR_LOADERS: Record Promise> = { + javascript: () => import('highlight.js/lib/languages/javascript'), + typescript: () => import('highlight.js/lib/languages/typescript'), + python: () => import('highlight.js/lib/languages/python'), + // ... 常用语言 +}; + +async function ensureLanguage(lang: string): Promise { + if (lowlightInstance.registered(lang)) return true; + const loader = GRAMMAR_LOADERS[lang]; + if (!loader) return false; + const grammar = await loader(); + lowlightInstance.register(lang, grammar.default); + return true; +} +``` + +**方案 B:高亮结果缓存** + +```typescript +const highlightCache = new LRUCache(200); + +function cachedHighlight(code: string, lang: string): HastNode { + const key = `${lang}:${hashContent(code)}`; + const cached = highlightCache.get(key); + if (cached) return cached; + + const result = lowlightInstance.highlight(lang, code); + highlightCache.set(key, result); + return result; +} +``` + +**影响范围**:`packages/cli/src/ui/utils/CodeColorizer.tsx` + +**预期收益**: + +- 懒加载:减少启动时模块加载量,降低内存占用 +- 缓存:对已完成代码块的重复渲染耗时降至 O(1) + +### 2.3 [P1] 切换到 marked 解析器 + +**动机**:当前自定义正则解析器的功能和鲁棒性已接近上限。`marked` 是 Claude Code 的选择,提供完整的 GFM 支持和流式友好的 lexer API。 + +**架构设计**: + +``` +输入文本 + ├─ 快速路径检测: /[#*`|[\->_~]|\n\n|^\d+\. / (无 MD 语法 → 纯文本渲染) + ├─ marked.lexer(text) → Token[] (AST) + └─ 自定义 Renderer: Token[] → React.ReactNode[] + ├─ heading → + ├─ code → (复用现有组件) + ├─ table → (复用现有组件) + ├─ list → (复用现有组件) + ├─ paragraph → (复用现有组件) + ├─ blockquote → + └─ ... 其他 token 类型 +``` + +**流式优化**: + +```typescript +// 仅对最后一个不完整块调用 marked.lexer() +const blocks = splitAtBlockBoundaries(streamingText); +const cachedBlocks = blocks.slice(0, -1).map((b) => getCachedTokens(b)); +const lastBlockTokens = marked.lexer(blocks[blocks.length - 1]); +return [...cachedBlocks.flat(), ...lastBlockTokens]; +``` + +**新增 GFM 能力**: +| 能力 | marked 支持 | 当前解析器 | +|---|---|---| +| 标准表格 | 完整 | 部分 | +| 任务列表 `- [x]` | 是 | 否 | +| 脚注 `[^1]` | 是 | 否 | +| 删除线 `~~text~~` | 是 | 是 | +| 自动链接 | 是 | 部分 | +| HTML 内联 | 可配置 | 仅 `` | +| 嵌套格式 | 完整 | 受限 | + +**迁移策略**: + +1. 添加 `marked` 依赖 +2. 创建 `MarkdownDisplayV2.tsx`,使用 marked lexer + 自定义 renderer +3. 通过设置项 `ui.markdownRenderer: 'v1' | 'v2'` 切换(默认 v1) +4. 编写 Markdown fixture 测试集,对比两个渲染器输出 +5. 渐进切换默认值到 v2,保留 v1 作为回退 +6. 稳定后移除 v1 + +**影响范围**: + +- 新增:`packages/cli/src/ui/utils/MarkdownDisplayV2.tsx` +- 修改:`packages/cli/src/ui/utils/MarkdownDisplay.tsx`(特性开关) +- 修改:`package.json`(添加 marked 依赖) + +**风险点**: + +- marked 的 token 结构与当前组件的 props 接口需要适配 +- 流式 markdown 中的不完整语法可能导致 marked 产生不同的 token 结构 +- 缓解:保留 v1 作为回退,充分测试后再切换默认值 + +### 2.4 [P1] 主题系统 — ANSI 16 色默认 + 终端能力检测 + +**目标**:默认使用 ANSI 16 色主题,确保兼容所有终端(包括透明背景、自定义配色方案)。 + +**终端能力检测逻辑**: + +```typescript +// packages/cli/src/ui/themes/theme-manager.ts + +function detectColorCapability(): 'truecolor' | '256' | '16' | 'none' { + if (process.env.NO_COLOR !== undefined) return 'none'; + if (process.env.FORCE_COLOR === '3') return 'truecolor'; + + const colorterm = process.env.COLORTERM?.toLowerCase(); + if (colorterm === 'truecolor' || colorterm === '24bit') return 'truecolor'; + + const term = process.env.TERM || ''; + if (term.includes('256color')) return '256'; + + return '16'; // 保守默认 +} + +function getDefaultTheme(): Theme { + const capability = detectColorCapability(); + switch (capability) { + case 'none': + return NoColorTheme; + case 'truecolor': + return QwenDark; // hex 颜色主题 + default: + return ANSI; // 16 色主题,尊重终端调色板 + } +} +``` + +**明暗主题自动检测**(进阶): + +```typescript +// 通过 OSC 11 查询终端背景色 +function queryTerminalBackground(): Promise<'light' | 'dark' | 'unknown'> { + return new Promise((resolve) => { + const timeout = setTimeout(() => resolve('unknown'), 1000); + process.stdout.write('\x1b]11;?\x07'); // OSC 11 查询 + // 解析响应判断明暗... + }); +} +``` + +**影响范围**: + +- `packages/cli/src/ui/themes/theme-manager.ts` — 添加能力检测,修改默认主题选择 +- `packages/cli/src/ui/themes/semantic-tokens.ts` — 确保 ANSI 主题的语义 token 完整 + +**向后兼容**: + +- 已在 settings 中显式设置主题的用户不受影响 +- 仅影响未设置主题的新用户或重置用户 +- 所有 hex 颜色主题仍可通过设置选择 + +### 2.5 [P2] OSC 8 终端超链接 + +**目标**:将 URL 和 Markdown 链接渲染为可点击的终端超链接。 + +**OSC 8 协议**: + +``` +ESC ] 8 ; params ; uri ST ← 开始超链接 +link text ← 显示文本 +ESC ] 8 ; ; ST ← 结束超链接 + +// 示例 +\x1b]8;;https://example.com\x07Click here\x1b]8;;\x07 +``` + +**支持的终端**:iTerm2, kitty, WezTerm, Windows Terminal, Hyper, foot, Contour 等。不支持的终端仅显示文本,无副作用。 + +**实现**: + +```typescript +// 新增工具函数 +function wrapHyperlink(url: string, text: string): string { + return `\x1b]8;;${url}\x07${text}\x1b]8;;\x07`; +} +``` + +在 `InlineMarkdownRenderer.tsx` 中集成: + +- `[text](url)` → OSC 8 包裹的可点击链接 +- 自动检测的 URL → OSC 8 包裹 +- 文件路径 → `file://` URL 包裹(如工具输出中的文件路径) + +**影响范围**: + +- `packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx` — 链接渲染修改 +- 新增:超链接工具函数模块 + +### 2.6 [P2] 消息历史虚拟滚动(Phase 3) + +**现状**:所有历史消息通过 `` 追加到终端 scrollback,长会话会产生大量渲染元素。 + +**方案设计**: + +``` +┌─────────────────────────────┐ +│ Overscan (上方 2 条) │ ← 预渲染但不可见 +├─────────────────────────────┤ +│ │ +│ 可见区域 (终端高度) │ ← 当前渲染 +│ │ +├─────────────────────────────┤ +│ Overscan (下方 2 条) │ ← 预渲染但不可见 +└─────────────────────────────┘ +│ 未渲染消息 (跳过) │ ← 按需加载 +``` + +**关键挑战**: + +- Ink 的 `` 是追加模式,无法移除已渲染内容 +- 需要切换到 alternate screen 模式或自行管理终端输出 +- 每条消息的高度需要预计算或缓存 + +**参考**:Claude Code 的 `` 组件(31KB)实现了完整的虚拟滚动 + DECSTBM 硬件滚动。 + +**建议**:先评估 Phase 1-2 的优化效果,若长会话性能仍是痛点再实施。 + +### 2.7 [P3] LaTeX/数学公式渲染 + +**场景**:代码辅助场景中,模型输出可能包含数学公式(如算法分析、信号处理等)。 + +**方案层次**: + +**Level 1:Unicode 数学符号替换(可行性高)** + +``` +$x^2 + y^2 = z^2$ → x² + y² = z² +$\alpha + \beta$ → α + β +$\frac{1}{2}$ → ½ +$\sum_{i=1}^{n}$ → Σᵢ₌₁ⁿ +``` + +使用 `tex-to-unicode` 库或自建映射表,覆盖常见数学符号。 + +**Level 2:块级公式语法高亮(可行性中)** + +``` +$$ +E = mc^2 +$$ +``` + +识别 `$$...$$` 块,使用语法高亮渲染 LaTeX 源码(类似代码块但标注为 `latex`)。 + +**Level 3:完整 KaTeX 渲染到终端(可行性低)** + +- 需要实现 KaTeX 的 AST 到终端渲染的转换 +- 终端能力有限(无下标对齐、无分数线等) +- 可能需要图像协议(Sixel/Kitty image protocol) + +**建议**:Phase 3 实现 Level 1 + Level 2,Level 3 作为远期探索。 + +### 2.8 [远期] Web 渲染探索 + +**动机**:终端能力终究有限,复杂的富文本渲染(图表、公式、交互式表格)在 Web 环境中更自然。 + +**探索方向**: + +1. **混合架构**:CLI 进程处理输入和工具执行,通过 WebSocket 将富文本内容推送到本地浏览器伴侣界面 +2. **Electron/Tauri 封装**:将终端嵌入 Web 壳中(类似 VS Code 终端),获得 CSS/SVG/Canvas 完整能力 +3. **Kitty Image Protocol**:在支持的终端中内联显示图像(图表截图、公式渲染图等) + +**收益**: + +- 完整 CSS 样式 +- SVG 图表 +- MathJax/KaTeX 数学公式 +- 交互式表格(排序、筛选) +- 图像内联显示 + +**风险**: + +- 增加系统复杂度和依赖 +- 偏离纯 CLI 工具的定位 +- 需要额外的安装步骤 + +**建议**:仅作为概念验证(POC),不纳入正式路线图。 + +## 3. 竞品参考 + +### Claude Code 渲染架构 + +| 能力 | 实现方式 | +| ------------- | ---------------------------------------------------------- | +| Markdown 解析 | `marked` 库 + LRU token 缓存(500 条) | +| 快速路径 | 正则检测无 MD 语法 → 跳过 `marked.lexer()`(大多数短回复) | +| 流式优化 | 在块边界分割,仅重解析最后一个块 | +| 代码高亮 | `` 包裹的可选 CLI 语法高亮 | +| 表格 | React 组件 `` + flexbox 布局 | +| 超链接 | OSC 8 终端超链接 | +| 样式池化 | StylePool: ANSI 码集内化为整数 ID + 转换缓存 | +| 字符池化 | CharPool: ASCII 快速路径 + Map 缓存 | + +**关键差异**:Claude Code 使用 `marked`(成熟的 GFM 解析器)而非自定义正则,并通过 LRU 缓存 + 快速路径跳过 + 流式块分割实现了高效的流式渲染。 + +## 4. 实施优先级与里程碑 + +| 优先级 | 方案 | 周次 | 风险 | 预期收益 | +| ------ | ------------------------- | ----- | ------ | ------------------------- | +| P0 | Markdown 解析缓存 | 2 | 低 | 解析耗时 -70%(缓存命中) | +| P0 | 代码高亮缓存 + 懒加载 | 2 | 低 | 启动加速 + 重复渲染消除 | +| P1 | 切换到 marked 解析器 | 7-8 | 中 | GFM 完整支持 | +| P1 | ANSI 16 色默认 + 能力检测 | 4 | 中 | 修复透明终端兼容性 | +| P2 | OSC 8 终端超链接 | 9-10 | 低 | URL 可点击 | +| P2 | 虚拟滚动 | 13-15 | 高 | 长会话性能 | +| P3 | LaTeX 数学公式 | 15-16 | 中 | 数学内容渲染 | +| 远期 | Web 渲染探索 | TBD | 探索性 | 富文本能力 | + +## 5. 验证方案 + +### 5.1 渲染性能基准 + +```typescript +// 测试用例 +const benchmarks = [ + { name: '短文本', content: '一段简短的回复', expectedParseMs: '<1' }, + { name: '500行 Markdown', content: generateMd(500), expectedParseMs: '<5' }, + { + name: '代码块×10', + content: generateCodeBlocks(10), + expectedParseMs: '<10', + }, + { + name: '大表格 (20×5)', + content: generateTable(20, 5), + expectedParseMs: '<5', + }, + { + name: '流式 1000 token', + content: simulateStream(1000), + expectedRerenders: '<20', + }, +]; +``` + +### 5.2 格式兼容性测试 + +Markdown fixture 测试集,验证所有支持的格式正确渲染: + +- 标题(H1-H4) +- 代码块(带语言标注 + 无语言 + 嵌套) +- 表格(基本 + 对齐 + CJK 内容 + 宽字符) +- 列表(有序 + 无序 + 嵌套 + 混合) +- 内联格式(加粗 + 斜体 + 代码 + 链接 + 删除线) +- 分割线 +- 引用块 + +### 5.3 主题兼容性 + +| 终端 | ANSI 16 色 | 256 色 | Truecolor | 透明背景 | +| ---------------- | ------------ | ------ | --------- | ------------- | +| iTerm2 | 正确 | 正确 | 正确 | ANSI 模式正确 | +| Terminal.app | 正确 | 正确 | N/A | ANSI 模式正确 | +| kitty | 正确 | 正确 | 正确 | ANSI 模式正确 | +| WezTerm | 正确 | 正确 | 正确 | ANSI 模式正确 | +| Windows Terminal | 正确 | 正确 | 正确 | ANSI 模式正确 | +| NO_COLOR 环境 | NoColor 主题 | — | — | — |