mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-05-02 13:40:46 +00:00
docs: refine tui optimization design
This commit is contained in:
parent
4eb19a94c0
commit
88efd775db
4 changed files with 342 additions and 153 deletions
|
|
@ -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<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 主题兼容性
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue