qwen-code/docs/design/tui-optimization/03-rendering-extensibility.md
2026-04-20 21:18:29 +08:00

23 KiB
Raw Blame History

TUI 优化:渲染性能与可扩展性

详细设计文档 3/3 — 提升渲染性能,支持更多格式,增强主题可配置性,探索远期方向。

1. 问题分析

1.1 Markdown 解析器现状

当前使用自定义正则逐行解析器(packages/cli/src/ui/utils/MarkdownDisplay.tsx461 行):

// 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.tsx224 行):

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.tsx540 行):

源码校准

  • 当前实现已经使用 wrap-ansistrip-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

// 大多数主题使用 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 token/block 缓存

目标:消除流式输出时的重复解析开销。

关键约束:不能缓存 React.ReactNode[]MarkdownDisplay 的最终渲染受 isPendingavailableTerminalHeightcontentWidthtextColor、主题、代码行号设置等 props/settings 影响;按文本 hash 缓存 ReactNode 会导致 resize、主题切换、pending 高度裁剪和行号开关后复用错误结果。

方案:实现 block 级别的 LRU 缓存,但缓存对象是 token/block 元数据,而不是 ReactNode。

设计

// 新增缓存层
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 cachehash(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同步基线 + 异步预热

// 当前(急切加载)
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高亮结果缓存

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 类型

流式优化

// 仅对最后一个不完整块调用 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

影响范围

  • 新增: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 色主题,确保兼容所有终端(包括透明背景、自定义配色方案)。

终端能力检测逻辑

// 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 色主题,尊重终端调色板
  }
}

明暗主题自动检测(进阶):

// 通过 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_COLORFORCE_COLORCOLORTERMTERM 和用户显式主题设置。

影响范围

  • 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。

实现

// 新增工具函数
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长会话会产生大量渲染元素。

方案设计

┌─────────────────────────────┐
│     Overscan (上方 2 条)     │  ← 预渲染但不可见
├─────────────────────────────┤
│                             │
│     可见区域 (终端高度)       │  ← 当前渲染
│                             │
├─────────────────────────────┤
│     Overscan (下方 2 条)     │  ← 预渲染但不可见
└─────────────────────────────┘
│     未渲染消息 (跳过)        │  ← 按需加载

关键挑战

  • Ink 的 <Static> 是追加模式,无法移除已渲染内容
  • 需要切换到 alternate screen 模式或自行管理终端输出
  • 每条消息的高度需要预计算或缓存

参考Claude Code 的 <ScrollBox> 组件31KB实现了完整的虚拟滚动 + DECSTBM 硬件滚动。

建议:先评估 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. 竞品参考

Claude Code 渲染架构

能力 实现方式
Markdown 解析 marked 库 + LRU token 缓存500 条)
快速路径 正则检测无 MD 语法 → 跳过 marked.lexer()(大多数短回复)
流式优化 在块边界分割,仅重解析最后一个块
代码高亮 <Suspense> 包裹的可选 CLI 语法高亮
表格 React 组件 <MarkdownTable> + flexbox 布局
超链接 OSC 8 终端超链接
样式池化 StylePool: ANSI 码集内化为整数 ID + 转换缓存
字符池化 CharPool: ASCII 快速路径 + Map 缓存

关键差异Claude Code 使用 marked(成熟的 GFM 解析器)而非自定义正则,并通过 LRU 缓存 + 快速路径跳过 + 流式块分割实现了高效的流式渲染。

4. 实施优先级与里程碑

优先级 方案 周次 风险 预期收益
P0 Markdown token/block 缓存 3 解析耗时显著下降
P0 代码高亮缓存 + 同步基线/异步预热 3 重复渲染消除,降低大块代码成本
P1 切换到 marked 解析器 7-8 GFM 基础能力增强
P1 ANSI 16 色默认 + 能力检测 4 修复透明终端兼容性
P2 OSC 8 终端超链接 9-10 URL 可点击
P2 虚拟滚动 13-15 长会话性能
P3 LaTeX 数学公式 15-16 数学内容渲染
远期 Web 渲染探索 TBD 探索性 富文本能力

5. 验证方案

5.1 渲染性能基准

// 测试用例
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未闭合代码块、未闭合表格、未闭合列表
  • HTML 输入(默认转义/忽略策略)

5.3 主题兼容性

终端 ANSI 16 色 256 色 Truecolor 透明背景
iTerm2 正确 正确 正确 ANSI 模式正确
Terminal.app 正确 正确 N/A ANSI 模式正确
kitty 正确 正确 正确 ANSI 模式正确
WezTerm 正确 正确 正确 ANSI 模式正确
Windows Terminal 正确 正确 正确 ANSI 模式正确
NO_COLOR 环境 NoColor 主题