qwen-code/docs/design/tui-optimization/03-rendering-extensibility.md
2026-04-21 16:25:42 +08:00

653 lines
27 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# TUI 优化:渲染性能与可扩展性
> 详细设计文档 — 提升渲染性能,支持更多格式,增强主题可配置性,探索远期方向。
## 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. **正则脆弱性**:边界情况处理不完整,如嵌套代码块、复杂列表、未闭合流式 Markdown 等
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 行):
**源码校准**
- 当前实现已经使用 `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 主题系统现状
`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 渲染为纯文本 | 点击跳转 |
| 虚拟滚动 | 无,长会话性能退化 | 长会话场景 |
| 图表/图像 | 不支持 | 远期探索 |
### 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 缓存
**目标**:消除流式输出时的重复解析开销。
**关键约束**:不能缓存 `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, ParsedMarkdownBlock[]>(PARSE_CACHE_MAX);
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;
// ... 现有解析逻辑 ...
const blocks = doParseBlocks(text);
parseCache.set(cacheKey, blocks);
return blocks;
}
function renderMarkdownBlocks(
blocks: ParsedMarkdownBlock[],
props: MarkdownDisplayProps,
): React.ReactNode[] {
// 根据当前 width/theme/pending/height/settings 渲染,不能跨 props 复用
}
```
**流式优化**:利用现有的 `findLastSafeSplitPoint()` 实现增量解析。
````
全文: "# Title\n\nParagraph 1\n\nParagraph 2\n\n```code block..."
├──── 已完成块 ────┤├── 已完成块 ──┤├── 当前块 ──┤
缓存命中(不重解析) 缓存命中 重新解析(仅此块)
````
**缓存 key**
- parse cache`hash(rawBlock)` + parser version
- render 辅助缓存(如纯文本 wrap 结果):必须额外包含 `contentWidth`、theme identity、`isPending`、height constraint、settings 版本
- 不把完整原始长字符串作为 key 保存,避免内存放大
**影响范围**`packages/cli/src/ui/utils/MarkdownDisplay.tsx`
**预期收益**:缓存命中时解析耗时显著下降。对于 1000 行的流式输出,每帧仅需解析最后一个不完整块(通常 < 50 行),而非全部 1000 行。
**参考**Claude Code 使用模块级 LRU 缓存500 条目key 为内容 hash避免保留完整字符串引用qwen-code 应采用 token/block 级缓存以适配 Ink props 驱动渲染。
### 2.2 [P0] 代码高亮优化
**关键约束**:当前 `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(BASELINE_GRAMMARS);
const GRAMMAR_LOADERS: Record<string, () => Promise<any>> = {
javascript: () => import('highlight.js/lib/languages/javascript'),
typescript: () => import('highlight.js/lib/languages/typescript'),
python: () => import('highlight.js/lib/languages/python'),
// ... 常用语言
};
function requestLanguageWarmup(lang: string): void {
if (lowlightInstance.registered(lang)) return;
const loader = GRAMMAR_LOADERS[lang];
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, HighlightResult>(200);
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 = 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 的选择,提供成熟的 block/inline lexer API可作为 v2 渲染器候选。但迁移必须先定义安全策略和流式不完整语法策略,不能只替换 parser。
**架构设计**
```
输入文本
├─ 快速路径检测: /[#*`|[\->_~]|\n\n|^\d+\. / (无 MD 语法 → 纯文本渲染)
├─ marked.lexer(text) → Token[] (AST)
└─ 自定义 Renderer: Token[] → React.ReactNode[]
├─ heading → <Text bold>
├─ code → <RenderCodeBlock> (复用现有组件)
├─ table → <RenderTable> (复用现有组件)
├─ list → <RenderListItem> (复用现有组件)
├─ paragraph → <RenderInline> (复用现有组件)
├─ blockquote → <Box borderLeft>
└─ ... 其他 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 支持 | 当前解析器 |
|---|---|---|
| 标准表格 | 是,需映射到现有 `TableRenderer` | 已有自定义实现 |
| 任务列表 `- [x]` | 是,需自定义 Ink renderer | 否 |
| 脚注 `[^1]` | 需通过扩展/插件策略验证,不作为首批默认承诺 | 否 |
| 删除线 `~~text~~` | 是 | 是 |
| 自动链接 | 是 | 部分 |
| 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'` 和环境变量双重切换
4. 编写 Markdown fixture 测试集,对比两个渲染器输出,重点覆盖 streaming partial blocks
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`
- 修改:`packages/cli/src/ui/utils/MarkdownDisplay.tsx`(特性开关)
- 修改:`package.json`(添加 marked 依赖)
**风险点**
- marked 的 token 结构与当前组件的 props 接口需要适配
- 流式 markdown 中的不完整语法可能导致 marked 产生不同的 token 结构
- marked 本身不负责 HTML sanitize必须由 qwen-code renderer 定义安全策略
- 添加依赖会影响 bundle 体积,需要纳入 `processUptimeAtT0Ms` 和 bundle analyzer
- 缓解:保留 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 查询
// 解析响应判断明暗...
});
}
```
OSC 11 查询会向终端请求背景色响应可能与用户输入流、tmux/SSH 组合和非交互输出产生副作用。该能力只作为 opt-in 进阶功能,不作为默认启动路径的一部分;默认策略应优先基于 `NO_COLOR``FORCE_COLOR``COLORTERM``TERM` 和用户显式主题设置。
**影响范围**
- `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 等。不支持或禁用 OSC 8 的场景应保持当前纯文本 fallback。
**实现**
```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 包裹(如工具输出中的文件路径)
**安全与兼容**
- URL 必须过滤控制字符和 OSC 终止符,避免注入额外 escape sequence
- 仅允许明确协议白名单(如 `http:`, `https:`, `file:`),其他协议按纯文本渲染
- 不支持 OSC 8 或禁用超链接时,保持当前 `text (url)` 的可复制 fallback
- 在 screen reader 模式下默认使用纯文本 fallback
**影响范围**
- `packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx` — 链接渲染修改
- 新增:超链接工具函数模块
### 2.6 [P2] 消息历史虚拟滚动Phase 3
**现状**:所有历史消息通过 `<Static>` 追加到终端 scrollback长会话会产生大量渲染元素。
**调研结论先行**:这不是一个“列表 slice 一下”的小优化。Gemini CLI 的 `VirtualizedList` 和 Claude Code 的 `useVirtualScroll` 都表明,真正可用的消息虚拟滚动至少要处理:
- 动态高度消息
- 贴底行为sticky bottom
- resize 后高度缓存失效
- overscan
- 搜索/跳转/定位
- 复制模式 / 选择模式
- 渲染中间态不出现 blank spacer
**方案设计**
```
┌─────────────────────────────┐
│ Overscan (上方 2 条) │ ← 预渲染但不可见
├─────────────────────────────┤
│ │
│ 可见区域 (终端高度) │ ← 当前渲染
│ │
├─────────────────────────────┤
│ Overscan (下方 2 条) │ ← 预渲染但不可见
└─────────────────────────────┘
│ 未渲染消息 (跳过) │ ← 按需加载
```
**关键挑战**
- Ink 的 `<Static>` 是追加模式,无法移除已渲染内容
- 需要切换到 alternate screen 模式或自行管理终端输出
- 每条消息的高度需要预计算或缓存
**应补充的工程约束**
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 的优化效果,若长会话性能仍是痛点再实施。
### 2.7 [P3] LaTeX/数学公式渲染
**场景**:代码辅助场景中,模型输出可能包含数学公式(如算法分析、信号处理等)。
**方案层次**
**Level 1Unicode 数学符号替换(可行性高)**
```
$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 2Level 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. 竞品参考与路线校准
### 3.1 Gemini CLI滚动和渲染模式先行
Gemini CLI 在 parser 架构上并没有比 qwen-code 更先进,但它在长会话和渲染模式上已经形成了可借鉴的组合:
| 能力 | 实现方式 |
| --- | --- |
| 长会话容器 | `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 | 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
// 测试用例
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 内容 + 宽字符)
- 表格回归ANSI + CJK + emoji、极窄宽度、vertical fallback、代码 span 中 pipe
- 列表(有序 + 无序 + 嵌套 + 混合)
- 内联格式(加粗 + 斜体 + 代码 + 链接 + 删除线)
- 分割线
- 引用块
- streaming partial blocks未闭合代码块、未闭合表格、未闭合列表
- stable prefix / unstable suffix 切换场景
- HTML 输入(默认转义/忽略策略)
- resize 后高度缓存与虚拟滚动 range 稳定性
### 5.3 主题兼容性
| 终端 | ANSI 16 色 | 256 色 | Truecolor | 透明背景 |
| ---------------- | ------------ | ------ | --------- | ------------- |
| iTerm2 | 正确 | 正确 | 正确 | ANSI 模式正确 |
| Terminal.app | 正确 | 正确 | N/A | ANSI 模式正确 |
| kitty | 正确 | 正确 | 正确 | ANSI 模式正确 |
| WezTerm | 正确 | 正确 | 正确 | ANSI 模式正确 |
| Windows Terminal | 正确 | 正确 | 正确 | ANSI 模式正确 |
| NO_COLOR 环境 | NoColor 主题 | — | — | — |