Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions docs/tui-rendering-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,23 @@

调用方必须复用同一个 `Markdown` 实例,否则实例级状态随组件一起消失;`AssistantMessageComponent` 因此按内容种类保留实例并只调 `setText`(`markdownSlots`)。每帧仍要做一次指针复制拼出扁平数组,以及主屏/全屏自己的差分;这两项是独立成本,不随前缀复用消失。Step 呈现的包装层(`step-message.ts` 的 `renderNativeRun`:`hideCompleteFences`、空行折叠、`trimOuterRows`)同样每帧扫一遍全部 native 行,那部分成本不受这里影响,需要单独处理。

`invalidate()` 同时清掉 `defaultStylePrefix`。它是从主题派生的 ANSI 前缀,会在每个"在样式内结束"的行内片段之后重新拼一次;主题切换若只清缓存而留着它,旧主题的转义就会拼进新输出——当新主题不再应用该样式时(例如关掉粗体)会直接显示错。第 8 节的 `setIncrementalRenderDisabled(true)` 对这里同样生效:置位后渲染整篇且不保留前缀(`renderContent` 开头),否则那个"强制全量路径做字节级等价校验"的开关在 Markdown 内部形同虚设。

**语法高亮按主题实例 + 语法版本 + `(lang, source)` 记忆化**(`cachedHighlight`,`packages/coding-agent/src/theme/theme.ts`)。渲染器把主题当纯函数用,自己无法去重,所以只要一帧里重渲了某个已完成代码块,高亮就整块重跑:认证前缀之外的路径(块级数学、reference definition)每帧重渲整篇,而认证路径的可变尾块里若含围栏也会每帧重渲。实测 40 KB、120 列、120 B/delta,用产品主题和真实 highlight.js:块级数学文档 1 154 ms → 211 ms(5.5 倍),reference definition 1 206 ms → 237 ms(5.1 倍),含一个围栏的散文 29 ms → 9 ms(3.2 倍);**纯散文只有 1.15 倍**——不含围栏的文档本来就不调用高亮,缓存对它无用,这不是本改动的收益来源。

三个键成分都不能省:

- **主题实例**:导出的 `theme` 是 Proxy、身份不变,直接用它做键会把上一个主题的颜色永久留给下一个;键取 `setGlobalTheme` 存进 `Symbol.for` 槽的真实实例。这个识别只覆盖本模块的 Proxy,跨模块实例传入的 Proxy 会被当作普通对象——仓库内没有这种调用。
- **语法版本**(`highlightGrammarRevision`):`highlight.js` 的子语言解析走同一个注册表,同一段 `javascript` 里的 HTML 模板只有 XML 语法加载完成后才会上色,所以 `(lang, source)` 不足以标识结果。启动时 `loadAllHighlightLanguages()` 完成后版本自增,旧键自动失效。
- **language + source**:同一段文本按 TypeScript 和按 JSON 高亮结果不同。

其它约束:

- **只在第二次请求时写入**。仍在流式增长的围栏每个 delta 都是一个新键,且这些前缀之后不会再被请求;把它们存下来会用一个代码块的几百个中间态占满预算(实测单个 56 KB 围栏 33 MiB)并把真正可复用的已完成块挤出去。已完成块会重复请求同一键,因此第二次命中才落盘。
- **按 payload 预算淘汰**(4 MiB/主题,FIFO)而不是条目数;单条源码超过 64 Ki(UTF-16 code unit,不是字节)不保留,避免 read/write 工具把整文件钉在缓存里。
- **永远返回副本**:`core/tools/write.ts` 会在流式写入时持有并修改返回数组。
- 两个入口(`highlightCode` 与 markdown 主题的 `highlightCode`)共用一份缓存,因此失败兜底也统一成 `fg("mdCodeBlock", …)`;否则同一键的结果取决于谁先跑。

## 5. 主屏差分渲染(regular)

`TuiMainScreen.doRender`(`tui-main-screen.ts:329-750`)每帧的完整顺序:
Expand Down
214 changes: 181 additions & 33 deletions packages/coding-agent/src/theme/theme.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { Compile } from "typebox/compile";
import { getCustomThemesDir, getThemesDir } from "../config.ts";
import type { SourceInfo } from "../core/source-info.ts";
import { closeWatcher, watchWithErrorHandler } from "../utils/fs-watch.ts";
import { highlight, supportsLanguage } from "../utils/syntax-highlight.ts";
import { highlight, highlightGrammarRevision, supportsLanguage } from "../utils/syntax-highlight.ts";
import { stripBom } from "../utils/text.ts";

// ============================================================================
Expand Down Expand Up @@ -1220,29 +1220,175 @@ function getCliHighlightTheme(t: Theme): CliHighlightTheme {
return cachedCliHighlightTheme;
}

/**
* The concrete `Theme` behind the exported proxy.
*
* `theme` is a proxy whose identity never changes, so keying a cache on it would
* survive a theme switch and serve colours from the previous theme. Every
* replacement path funnels through `setGlobalTheme`, which stores the backing
* instance under a `Symbol.for` key, so resolving it here gives the cache a key
* that changes when the theme does.
*
* This recognises *this* module's proxy. A proxy exported by a different module
* instance (tsx and jiti both loading this file) is a distinct object and is used
* as-is, so a caller that passes a foreign instance's `theme` into
* `getMarkdownTheme` would pin that instance's colours. No in-repo caller does
* that; themes reach the cache either through the default parameter or as a
* concrete `Theme`.
*/
function resolveThemeInstance(t: Theme): Theme {
if ((t as unknown) !== (theme as unknown)) return t;
const store = globalThis as Record<symbol, Theme>;
return store[THEME_KEY_NEW] ?? store[THEME_KEY] ?? store[THEME_KEY_OLD] ?? t;
}

/**
* Highlighted lines per theme, memoized on language and source.
*
* Both the streaming renderer and the tool-output path ask for the same fenced
* block repeatedly: a block is highlighted while it is being written, and any
* document that still re-renders whole (block math, reference definitions, a
* message that has not settled) re-highlights every finished block on every
* frame. The renderer cannot deduplicate that by itself because the work happens
* inside this theme object, which it treats as a set of pure functions.
*
* Only blocks that are asked for twice are retained. A fenced block that is still
* streaming produces a new key on every delta, and each of those prefixes is dead
* the moment the next one arrives: caching them would fill the whole budget with
* hundreds of intermediate versions of one block (measured: 33 MiB for a single
* 56 KB fence) and evict the finished blocks the cache exists to serve. A block
* that is merely re-rendered repeats its key, so it is stored on the second miss.
*
* The result is always a copy. `core/tools/write.ts` keeps the returned array and
* appends to or overwrites its elements while a file streams, so handing out the
* cached array would corrupt the cache.
*/
interface HighlightEntry {
lines: string[];
/** Estimated payload, used for the total budget rather than an entry count. */
bytes: number;
}

/** Total retained payload per theme; a long answer stays well inside this. */
const HIGHLIGHT_CACHE_BYTES = 4 * 1024 * 1024;
/** Whole-file callers (read/write tools) must not pin megabytes per entry. */
const HIGHLIGHT_CACHE_SOURCE_LIMIT = 64 * 1024;
/** Recently requested keys, by digest, so a repeat can be told from a one-off. */
const HIGHLIGHT_PENDING_LIMIT = 128;

/** Bumped by `clearHighlightCache`; a state with an older generation is dropped. */
let highlightGeneration = 0;

/** Per-theme bookkeeping for the "store on second miss" rule. */
interface HighlightState {
generation: number;
entries: Map<string, HighlightEntry>;
bytes: number;
/** Digests requested once and not stored yet, oldest first. */
recentMisses: Set<string>;
}

const highlightState = new WeakMap<Theme, HighlightState>();

/** FNV-1a over the key; collisions only cost one extra stored entry. */
function highlightDigest(text: string): string {
let hash = 0x811c9dc5;
for (let index = 0; index < text.length; index++) {
hash ^= text.charCodeAt(index);
hash = Math.imul(hash, 0x01000193);
}
return (hash >>> 0).toString(16);
}

function highlightStateFor(themeInstance: Theme): HighlightState {
const existing = highlightState.get(themeInstance);
if (existing && existing.generation === highlightGeneration) return existing;
// A stale generation is dropped rather than swept, so invalidation stays O(1)
// even though the weak map cannot be iterated.
const state: HighlightState = {
generation: highlightGeneration,
entries: new Map(),
bytes: 0,
recentMisses: new Set(),
};
highlightState.set(themeInstance, state);
return state;
}

function cachedHighlight(t: Theme, code: string, lang: string, compute: () => string[]): string[] {
const state = highlightStateFor(resolveThemeInstance(t));
const key = `${highlightGrammarRevision()}\u0001${lang}\u0000${code}`;
const hit = state.entries.get(key);
if (hit) return [...hit.lines];

// A streaming block yields a fresh key per delta and is never asked for again;
// a finished block repeats. Only the latter is worth the memory.
const digest = highlightDigest(key);
const repeat = state.recentMisses.delete(digest);
if (!repeat) {
if (state.recentMisses.size >= HIGHLIGHT_PENDING_LIMIT) {
const oldest = state.recentMisses.values().next().value;
if (oldest !== undefined) state.recentMisses.delete(oldest);
}
state.recentMisses.add(digest);
}

const lines = compute();
if (!repeat || code.length > HIGHLIGHT_CACHE_SOURCE_LIMIT) return [...lines];

const bytes = code.length + lines.reduce((total, line) => total + line.length, 0);
// Bound retained payload rather than entry count: one long block would
// otherwise outgrow any fixed number of entries.
while (state.bytes + bytes > HIGHLIGHT_CACHE_BYTES && state.entries.size > 0) {
const oldest = state.entries.keys().next().value;
if (oldest === undefined) break;
const evicted = state.entries.get(oldest);
if (evicted) state.bytes -= evicted.bytes;
state.entries.delete(oldest);
}
state.entries.set(key, { lines, bytes });
state.bytes += bytes;
return [...lines];
}

/**
* Drop every memoized block for every theme.
*
* Used when the grammar set changes, and by tests that need a cold memo. State is
* invalidated by generation rather than deleted, because the per-theme map is weak
* and cannot be swept.
*/
export function clearHighlightCache(): void {
highlightGeneration += 1;
}

/**
* Highlight code with syntax coloring based on file extension or language.
* Returns array of highlighted lines.
*/
export function highlightCode(code: string, lang?: string): string[] {
// Validate language before highlighting to avoid stderr spam from cli-highlight
const validLang = lang && supportsLanguage(lang) ? lang : undefined;
// Skip highlighting when no valid language is specified. cli-highlight's
// auto-detection is unreliable and can misidentify prose as AppleScript,
// LiveCodeServer, etc., coloring random English words as keywords.
if (!validLang) {
return code.split("\n").map((line) => theme.fg("mdCodeBlock", line));
}
const opts = {
language: validLang,
ignoreIllegals: true,
theme: getCliHighlightTheme(theme),
};
try {
return highlight(code, opts).split("\n");
} catch {
return code.split("\n");
}
// No language means no highlighting at all, so the memo covers plain fences too:
// an unlabelled block is the most common kind, and it is restyled line by line.
return cachedHighlight(theme, code, validLang ?? "", () => {
if (!validLang) {
return code.split("\n").map((line) => theme.fg("mdCodeBlock", line));
}
const opts = {
language: validLang,
ignoreIllegals: true,
theme: getCliHighlightTheme(theme),
};
try {
return highlight(code, opts).split("\n");
} catch {
// Same fallback as the markdown theme below, because both write into one
// memo: a differing fallback would make the result depend on which entry
// point happened to run first.
return code.split("\n").map((line) => theme.fg("mdCodeBlock", line));
}
});
}

/**
Expand Down Expand Up @@ -1350,22 +1496,24 @@ export function getMarkdownTheme(activeTheme: Theme = theme): MarkdownTheme {
highlightCode: (code: string, lang?: string): string[] => {
// Validate language before highlighting to avoid stderr spam from cli-highlight
const validLang = lang && supportsLanguage(lang) ? lang : undefined;
// Skip highlighting when no valid language is specified. cli-highlight's
// auto-detection is unreliable and can misidentify prose as AppleScript,
// LiveCodeServer, etc., coloring random English words as keywords.
if (!validLang) {
return code.split("\n").map((line) => activeTheme.fg("mdCodeBlock", line));
}
const opts = {
language: validLang,
ignoreIllegals: true,
theme: getCliHighlightTheme(activeTheme),
};
try {
return highlight(code, opts).split("\n");
} catch {
return code.split("\n").map((line) => activeTheme.fg("mdCodeBlock", line));
}
// No language means no highlighting at all, so the memo covers plain fences
// too. Both paths share one cache because the cache is keyed on the resolved
// theme instance, not on the closure that produced the lines.
return cachedHighlight(activeTheme, code, validLang ?? "", () => {
if (!validLang) {
return code.split("\n").map((line) => activeTheme.fg("mdCodeBlock", line));
}
const opts = {
language: validLang,
ignoreIllegals: true,
theme: getCliHighlightTheme(activeTheme),
};
try {
return highlight(code, opts).split("\n");
} catch {
return code.split("\n").map((line) => activeTheme.fg("mdCodeBlock", line));
}
});
},
};
}
Expand Down
20 changes: 19 additions & 1 deletion packages/coding-agent/src/utils/syntax-highlight.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,30 @@ for (const [name, language] of Object.entries(eagerLanguages)) {

let allLanguagesPromise: Promise<void> | undefined;

/**
* Increments when the grammar set changes.
*
* `highlight.js` resolves embedded sub-languages through the same registry, so the
* same source and language can produce different spans before and after the
* remaining grammars load — a JavaScript template literal only gets HTML colouring
* once the XML grammar exists. Anything memoizing highlighted output has to include
* this in its key, because the language name alone does not identify the result.
*/
let grammarRevision = 0;

export function highlightGrammarRevision(): number {
return grammarRevision;
}

export function loadAllHighlightLanguages(): Promise<void> {
if (!allLanguagesPromise) {
allLanguagesPromise = new Promise((resolve) => {
setImmediate(() => {
void import("highlight.js/lib/index.js").then(
() => resolve(),
() => {
grammarRevision += 1;
resolve();
},
() => {
// Eager languages and plaintext fallback remain available.
resolve();
Expand Down
Loading
Loading