docs: refine tui optimization design

This commit is contained in:
秦奇 2026-04-20 21:18:29 +08:00
parent 4eb19a94c0
commit 88efd775db
4 changed files with 342 additions and 153 deletions

View file

@ -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/宽字符的列宽计算存在 bugGitHub 反馈)
- 特定终端宽度下表格消失或错位
- 对齐方式(`:---:` 等)的解析与渲染存在边缘情况
- 当前实现已经使用 `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<string, React.ReactNode[]>(PARSE_CACHE_MAX);
const parseCache = new LRUCache<string, ParsedMarkdownBlock[]>(PARSE_CACHE_MAX);
function parseMarkdownBlocks(text: string): React.ReactNode[] {
interface ParsedMarkdownBlock {
type: 'paragraph' | 'heading' | 'code' | 'table' | 'list' | 'hr';
raw: string;
attrs: Record<string, unknown>;
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<string, () => Promise<any>> = {
javascript: () => import('highlight.js/lib/languages/javascript'),
@ -154,42 +184,66 @@ const GRAMMAR_LOADERS: Record<string, () => Promise<any>> = {
// ... 常用语言
};
async function ensureLanguage(lang: string): Promise<boolean> {
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<string, HastNode>(200);
const highlightCache = new LRUCache<string, HighlightResult>(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 内联 | 可配置 | 仅 `<u>` |
| 嵌套格式 | 完整 | 受限 |
| HTML 内联 | parser 可识别qwen-code 需默认转义或忽略,不能直接渲染 HTML | 仅 `<u>` |
| 嵌套格式 | 更完整,但需 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 主题兼容性