fix: highlight 缓存改用 LRUCache 降低内存开销
- Fallback.tsx: 手动 Map LRU 替换为 lru-cache 的 LRUCache - Markdown.tsx: tokenCache 同样替换为 LRUCache - color-diff-napi: 新增行级 hljs AST 缓存,避免终端 resize 时重复高亮 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
b4629aa5bb
commit
d35f0db17d
|
|
@ -18,26 +18,20 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { diffArrays } from 'diff'
|
import { diffArrays } from 'diff'
|
||||||
import type * as hljsNamespace from 'highlight.js'
|
import hljs from 'highlight.js'
|
||||||
import { basename, extname } from 'path'
|
import { basename, extname } from 'path'
|
||||||
|
|
||||||
// Lazy: defers loading highlight.js until first render. The full bundle
|
// Static import — createRequire(import.meta.url) fails in Bun --compile mode
|
||||||
// registers 190+ language grammars at require time (~50MB, 100-200ms on
|
// because the resolved path points to the internal bunfs binary path where
|
||||||
// macOS, several× that on Windows). With a top-level import, any caller
|
// node_modules cannot be found. A top-level import ensures the module is
|
||||||
// chunk that reaches this module — including test/preload.ts via
|
// bundled and accessible at runtime.
|
||||||
// StructuredDiff.tsx → colorDiff.ts — pays that cost at module-eval time
|
type HLJSApi = typeof hljs
|
||||||
// and carries the heap for the rest of the process. On Windows CI this
|
|
||||||
// pushed later tests in the same shard into GC-pause territory and a
|
|
||||||
// beforeEach/afterEach hook timeout (officialRegistry.test.ts, PR #24150).
|
|
||||||
// Same lazy pattern the NAPI wrapper used for dlopen.
|
|
||||||
type HLJSApi = typeof hljsNamespace.default
|
|
||||||
let cachedHljs: HLJSApi | null = null
|
let cachedHljs: HLJSApi | null = null
|
||||||
function hljs(): HLJSApi {
|
function hljsApi(): HLJSApi {
|
||||||
if (cachedHljs) return cachedHljs
|
if (cachedHljs) return cachedHljs
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
||||||
const mod = require('highlight.js')
|
|
||||||
// highlight.js uses `export =` (CJS). Under bun/ESM the interop wraps it
|
// highlight.js uses `export =` (CJS). Under bun/ESM the interop wraps it
|
||||||
// in .default; under node CJS the module IS the API. Check at runtime.
|
// in .default; under node CJS the module IS the API. Check at runtime.
|
||||||
|
const mod = hljs as HLJSApi & { default?: HLJSApi }
|
||||||
cachedHljs = 'default' in mod && mod.default ? mod.default : mod
|
cachedHljs = 'default' in mod && mod.default ? mod.default : mod
|
||||||
return cachedHljs!
|
return cachedHljs!
|
||||||
}
|
}
|
||||||
|
|
@ -436,9 +430,9 @@ function detectLanguage(
|
||||||
// Filename-based lookup (handles Dockerfile, Makefile, CMakeLists.txt, etc.)
|
// Filename-based lookup (handles Dockerfile, Makefile, CMakeLists.txt, etc.)
|
||||||
const stem = base.split('.')[0] ?? ''
|
const stem = base.split('.')[0] ?? ''
|
||||||
const byName = FILENAME_LANGS[base] ?? FILENAME_LANGS[stem]
|
const byName = FILENAME_LANGS[base] ?? FILENAME_LANGS[stem]
|
||||||
if (byName && hljs().getLanguage(byName)) return byName
|
if (byName && hljsApi().getLanguage(byName)) return byName
|
||||||
if (ext) {
|
if (ext) {
|
||||||
const lang = hljs().getLanguage(ext)
|
const lang = hljsApi().getLanguage(ext)
|
||||||
if (lang) return ext
|
if (lang) return ext
|
||||||
}
|
}
|
||||||
// Shebang / first-line detection (strip UTF-8 BOM)
|
// Shebang / first-line detection (strip UTF-8 BOM)
|
||||||
|
|
@ -508,6 +502,50 @@ function hasRootNode(emitter: unknown): emitter is { rootNode: HljsNode } {
|
||||||
|
|
||||||
let loggedEmitterShapeError = false
|
let loggedEmitterShapeError = false
|
||||||
|
|
||||||
|
// Per-line hljs AST cache — ColorFile.render re-highlights every line on
|
||||||
|
// width change (terminal resize). The AST is theme-independent; flattenHljs
|
||||||
|
// applies theme colors separately. Capped at 2048 entries (~1 MB typical).
|
||||||
|
const HL_LINE_CACHE_MAX = 2048
|
||||||
|
const hlLineCache = new Map<string, HljsNode | null>()
|
||||||
|
function cachedHljsAst(
|
||||||
|
lang: string,
|
||||||
|
code: string,
|
||||||
|
): HljsNode | null {
|
||||||
|
const key = lang + '\0' + code
|
||||||
|
const hit = hlLineCache.get(key)
|
||||||
|
if (hit !== undefined) return hit
|
||||||
|
let result
|
||||||
|
try {
|
||||||
|
result = hljsApi().highlight(code, {
|
||||||
|
language: lang,
|
||||||
|
ignoreIllegals: true,
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
hlLineCache.set(key, null)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const emitter = result._emitter || {}
|
||||||
|
if (!hasRootNode(emitter)) {
|
||||||
|
if (!loggedEmitterShapeError) {
|
||||||
|
loggedEmitterShapeError = true
|
||||||
|
logError(
|
||||||
|
new Error(
|
||||||
|
`color-diff: hljs emitter shape mismatch (keys: ${Object.keys(emitter).join(',')}). Syntax highlighting disabled.`,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
hlLineCache.set(key, null)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const node = emitter.rootNode
|
||||||
|
if (hlLineCache.size >= HL_LINE_CACHE_MAX) {
|
||||||
|
const first = hlLineCache.keys().next().value
|
||||||
|
if (first !== undefined) hlLineCache.delete(first)
|
||||||
|
}
|
||||||
|
hlLineCache.set(key, node)
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
function highlightLine(
|
function highlightLine(
|
||||||
state: { lang: string | null; stack: unknown },
|
state: { lang: string | null; stack: unknown },
|
||||||
line: string,
|
line: string,
|
||||||
|
|
@ -518,30 +556,12 @@ function highlightLine(
|
||||||
if (!state.lang) {
|
if (!state.lang) {
|
||||||
return [[defaultStyle(theme), code]]
|
return [[defaultStyle(theme), code]]
|
||||||
}
|
}
|
||||||
let result
|
const rootNode = cachedHljsAst(state.lang, code)
|
||||||
try {
|
if (!rootNode) {
|
||||||
result = hljs().highlight(code, {
|
|
||||||
language: state.lang,
|
|
||||||
ignoreIllegals: true,
|
|
||||||
})
|
|
||||||
} catch {
|
|
||||||
// hljs throws on unknown language despite ignoreIllegals
|
|
||||||
return [[defaultStyle(theme), code]]
|
|
||||||
}
|
|
||||||
const emitter = result._emitter || {};
|
|
||||||
if (!hasRootNode(emitter)) {
|
|
||||||
if (!loggedEmitterShapeError) {
|
|
||||||
loggedEmitterShapeError = true
|
|
||||||
logError(
|
|
||||||
new Error(
|
|
||||||
`color-diff: hljs emitter shape mismatch (keys: ${Object.keys(emitter).join(',')}). Syntax highlighting disabled.`,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return [[defaultStyle(theme), code]]
|
return [[defaultStyle(theme), code]]
|
||||||
}
|
}
|
||||||
const blocks: Block[] = []
|
const blocks: Block[] = []
|
||||||
flattenHljs(emitter.rootNode, theme, undefined, blocks)
|
flattenHljs(rootNode, theme, undefined, blocks)
|
||||||
return blocks
|
return blocks
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,42 +1,34 @@
|
||||||
import { extname } from 'path'
|
import { extname } from 'path';
|
||||||
import React, { Suspense, use, useMemo } from 'react'
|
import React, { Suspense, use, useMemo } from 'react';
|
||||||
import { Ansi, Text } from '@anthropic/ink'
|
import { Ansi, Text } from '@anthropic/ink';
|
||||||
import { getCliHighlightPromise } from '../../utils/cliHighlight.js'
|
import { LRUCache } from 'lru-cache';
|
||||||
import { logForDebugging } from '../../utils/debug.js'
|
import { getCliHighlightPromise } from '../../utils/cliHighlight.js';
|
||||||
import { convertLeadingTabsToSpaces } from '../../utils/file.js'
|
import { logForDebugging } from '../../utils/debug.js';
|
||||||
import { hashPair } from '../../utils/hash.js'
|
import { convertLeadingTabsToSpaces } from '../../utils/file.js';
|
||||||
|
import { hashPair } from '../../utils/hash.js';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
code: string
|
code: string;
|
||||||
filePath: string
|
filePath: string;
|
||||||
dim?: boolean
|
dim?: boolean;
|
||||||
skipColoring?: boolean
|
skipColoring?: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
// Module-level highlight cache — hl.highlight() is the hot cost on virtual-
|
// Module-level highlight cache — hl.highlight() is the hot cost on virtual-
|
||||||
// scroll remounts. useMemo doesn't survive unmount→remount. Keyed by hash
|
// scroll remounts. useMemo doesn't survive unmount→remount. Keyed by hash
|
||||||
// of code+language to avoid retaining full source strings (#24180 RSS fix).
|
// of code+language to avoid retaining full source strings (#24180 RSS fix).
|
||||||
const HL_CACHE_MAX = 500
|
const hlCache = new LRUCache<string, string>({ max: 500 });
|
||||||
const hlCache = new Map<string, string>()
|
|
||||||
function cachedHighlight(
|
function cachedHighlight(
|
||||||
hl: NonNullable<Awaited<ReturnType<typeof getCliHighlightPromise>>>,
|
hl: NonNullable<Awaited<ReturnType<typeof getCliHighlightPromise>>>,
|
||||||
code: string,
|
code: string,
|
||||||
language: string,
|
language: string,
|
||||||
): string {
|
): string {
|
||||||
const key = hashPair(language, code)
|
const key = hashPair(language, code);
|
||||||
const hit = hlCache.get(key)
|
const hit = hlCache.get(key);
|
||||||
if (hit !== undefined) {
|
if (hit !== undefined) return hit;
|
||||||
hlCache.delete(key)
|
const out = hl.highlight(code, { language });
|
||||||
hlCache.set(key, hit)
|
hlCache.set(key, out);
|
||||||
return hit
|
return out;
|
||||||
}
|
|
||||||
const out = hl.highlight(code, { language })
|
|
||||||
if (hlCache.size >= HL_CACHE_MAX) {
|
|
||||||
const first = hlCache.keys().next().value
|
|
||||||
if (first !== undefined) hlCache.delete(first)
|
|
||||||
}
|
|
||||||
hlCache.set(key, out)
|
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HighlightedCodeFallback({
|
export function HighlightedCodeFallback({
|
||||||
|
|
@ -45,55 +37,45 @@ export function HighlightedCodeFallback({
|
||||||
dim = false,
|
dim = false,
|
||||||
skipColoring = false,
|
skipColoring = false,
|
||||||
}: Props): React.ReactElement {
|
}: Props): React.ReactElement {
|
||||||
const codeWithSpaces = convertLeadingTabsToSpaces(code)
|
const codeWithSpaces = convertLeadingTabsToSpaces(code);
|
||||||
if (skipColoring) {
|
if (skipColoring) {
|
||||||
return (
|
return (
|
||||||
<Text dimColor={dim}>
|
<Text dimColor={dim}>
|
||||||
<Ansi>{codeWithSpaces}</Ansi>
|
<Ansi>{codeWithSpaces}</Ansi>
|
||||||
</Text>
|
</Text>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
const language = extname(filePath).slice(1)
|
const language = extname(filePath).slice(1);
|
||||||
return (
|
return (
|
||||||
<Text dimColor={dim}>
|
<Text dimColor={dim}>
|
||||||
<Suspense fallback={<Ansi>{codeWithSpaces}</Ansi>}>
|
<Suspense fallback={<Ansi>{codeWithSpaces}</Ansi>}>
|
||||||
<Highlighted codeWithSpaces={codeWithSpaces} language={language} />
|
<Highlighted codeWithSpaces={codeWithSpaces} language={language} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</Text>
|
</Text>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Highlighted({
|
function Highlighted({ codeWithSpaces, language }: { codeWithSpaces: string; language: string }): React.ReactElement {
|
||||||
codeWithSpaces,
|
const hl = use(getCliHighlightPromise());
|
||||||
language,
|
|
||||||
}: {
|
|
||||||
codeWithSpaces: string
|
|
||||||
language: string
|
|
||||||
}): React.ReactElement {
|
|
||||||
const hl = use(getCliHighlightPromise())
|
|
||||||
const out = useMemo(() => {
|
const out = useMemo(() => {
|
||||||
if (!hl) return codeWithSpaces
|
if (!hl) return codeWithSpaces;
|
||||||
let highlightLang = 'markdown'
|
let highlightLang = 'markdown';
|
||||||
if (language) {
|
if (language) {
|
||||||
if (hl.supportsLanguage(language)) {
|
if (hl.supportsLanguage(language)) {
|
||||||
highlightLang = language
|
highlightLang = language;
|
||||||
} else {
|
} else {
|
||||||
logForDebugging(
|
logForDebugging(`Language not supported while highlighting code, falling back to markdown: ${language}`);
|
||||||
`Language not supported while highlighting code, falling back to markdown: ${language}`,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
return cachedHighlight(hl, codeWithSpaces, highlightLang)
|
return cachedHighlight(hl, codeWithSpaces, highlightLang);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof Error && e.message.includes('Unknown language')) {
|
if (e instanceof Error && e.message.includes('Unknown language')) {
|
||||||
logForDebugging(
|
logForDebugging(`Language not supported while highlighting code, falling back to markdown: ${e}`);
|
||||||
`Language not supported while highlighting code, falling back to markdown: ${e}`,
|
return cachedHighlight(hl, codeWithSpaces, 'markdown');
|
||||||
)
|
|
||||||
return cachedHighlight(hl, codeWithSpaces, 'markdown')
|
|
||||||
}
|
}
|
||||||
return codeWithSpaces
|
return codeWithSpaces;
|
||||||
}
|
}
|
||||||
}, [codeWithSpaces, language, hl])
|
}, [codeWithSpaces, language, hl]);
|
||||||
return <Ansi>{out}</Ansi>
|
return <Ansi>{out}</Ansi>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,26 @@
|
||||||
import { marked, type Token, type Tokens } from 'marked'
|
import { marked, type Token, type Tokens } from 'marked';
|
||||||
import React, { Suspense, use, useMemo, useRef } from 'react'
|
import React, { Suspense, use, useMemo, useRef } from 'react';
|
||||||
import { useSettings } from '../hooks/useSettings.js'
|
import { LRUCache } from 'lru-cache';
|
||||||
import { Ansi, Box, useTheme } from '@anthropic/ink'
|
import { useSettings } from '../hooks/useSettings.js';
|
||||||
import {
|
import { Ansi, Box, useTheme } from '@anthropic/ink';
|
||||||
type CliHighlight,
|
import { type CliHighlight, getCliHighlightPromise } from '../utils/cliHighlight.js';
|
||||||
getCliHighlightPromise,
|
import { hashContent } from '../utils/hash.js';
|
||||||
} from '../utils/cliHighlight.js'
|
import { configureMarked, formatToken } from '../utils/markdown.js';
|
||||||
import { hashContent } from '../utils/hash.js'
|
import { stripPromptXMLTags } from '../utils/messages.js';
|
||||||
import { configureMarked, formatToken } from '../utils/markdown.js'
|
import { MarkdownTable } from './MarkdownTable.js';
|
||||||
import { stripPromptXMLTags } from '../utils/messages.js'
|
|
||||||
import { MarkdownTable } from './MarkdownTable.js'
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
children: string
|
children: string;
|
||||||
/** When true, render all text content as dim */
|
/** When true, render all text content as dim */
|
||||||
dimColor?: boolean
|
dimColor?: boolean;
|
||||||
}
|
};
|
||||||
|
|
||||||
// Module-level token cache — marked.lexer is the hot cost on virtual-scroll
|
// Module-level token cache — marked.lexer is the hot cost on virtual-scroll
|
||||||
// remounts (~3ms per message). useMemo doesn't survive unmount→remount, so
|
// remounts (~3ms per message). useMemo doesn't survive unmount→remount, so
|
||||||
// scrolling back to a previously-visible message re-parses. Messages are
|
// scrolling back to a previously-visible message re-parses. Messages are
|
||||||
// immutable in history; same content → same tokens. Keyed by hash to avoid
|
// immutable in history; same content → same tokens. Keyed by hash to avoid
|
||||||
// retaining full content strings (turn50→turn99 RSS regression, #24180).
|
// retaining full content strings (turn50→turn99 RSS regression, #24180).
|
||||||
const TOKEN_CACHE_MAX = 500
|
const tokenCache = new LRUCache<string, Token[]>({ max: 500 });
|
||||||
const tokenCache = new Map<string, Token[]>()
|
|
||||||
|
|
||||||
// Characters that indicate markdown syntax. If none are present, skip the
|
// Characters that indicate markdown syntax. If none are present, skip the
|
||||||
// ~3ms marked.lexer call entirely — render as a single paragraph. Covers
|
// ~3ms marked.lexer call entirely — render as a single paragraph. Covers
|
||||||
|
|
@ -31,11 +28,11 @@ const tokenCache = new Map<string, Token[]>()
|
||||||
// plain sentences. Checked via indexOf (not regex) for speed.
|
// plain sentences. Checked via indexOf (not regex) for speed.
|
||||||
// Single regex: matches any MD marker or ordered-list start (N. at line start).
|
// Single regex: matches any MD marker or ordered-list start (N. at line start).
|
||||||
// One pass instead of 10× includes scans.
|
// One pass instead of 10× includes scans.
|
||||||
const MD_SYNTAX_RE = /[#*`|[>\-_~]|\n\n|^\d+\. |\n\d+\. /
|
const MD_SYNTAX_RE = /[#*`|[>\-_~]|\n\n|^\d+\. |\n\d+\. /;
|
||||||
function hasMarkdownSyntax(s: string): boolean {
|
function hasMarkdownSyntax(s: string): boolean {
|
||||||
// Sample first 500 chars — if markdown exists it's usually early (headers,
|
// Sample first 500 chars — if markdown exists it's usually early (headers,
|
||||||
// code fence, list). Long tool outputs are mostly plain text tails.
|
// code fence, list). Long tool outputs are mostly plain text tails.
|
||||||
return MD_SYNTAX_RE.test(s.length > 500 ? s.slice(0, 500) : s)
|
return MD_SYNTAX_RE.test(s.length > 500 ? s.slice(0, 500) : s);
|
||||||
}
|
}
|
||||||
|
|
||||||
function cachedLexer(content: string): Token[] {
|
function cachedLexer(content: string): Token[] {
|
||||||
|
|
@ -51,25 +48,14 @@ function cachedLexer(content: string): Token[] {
|
||||||
text: content,
|
text: content,
|
||||||
tokens: [{ type: 'text', raw: content, text: content }],
|
tokens: [{ type: 'text', raw: content, text: content }],
|
||||||
} as Token,
|
} as Token,
|
||||||
]
|
];
|
||||||
}
|
}
|
||||||
const key = hashContent(content)
|
const key = hashContent(content);
|
||||||
const hit = tokenCache.get(key)
|
const hit = tokenCache.get(key);
|
||||||
if (hit) {
|
if (hit) return hit;
|
||||||
// Promote to MRU — without this the eviction is FIFO (scrolling back to
|
const tokens = marked.lexer(content);
|
||||||
// an early message evicts the very item you're looking at).
|
tokenCache.set(key, tokens);
|
||||||
tokenCache.delete(key)
|
return tokens;
|
||||||
tokenCache.set(key, hit)
|
|
||||||
return hit
|
|
||||||
}
|
|
||||||
const tokens = marked.lexer(content)
|
|
||||||
if (tokenCache.size >= TOKEN_CACHE_MAX) {
|
|
||||||
// LRU-ish: drop oldest. Map preserves insertion order.
|
|
||||||
const first = tokenCache.keys().next().value
|
|
||||||
if (first !== undefined) tokenCache.delete(first)
|
|
||||||
}
|
|
||||||
tokenCache.set(key, tokens)
|
|
||||||
return tokens
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -78,9 +64,9 @@ function cachedLexer(content: string): Token[] {
|
||||||
* - Other content is rendered as ANSI strings via formatToken
|
* - Other content is rendered as ANSI strings via formatToken
|
||||||
*/
|
*/
|
||||||
export function Markdown(props: Props): React.ReactNode {
|
export function Markdown(props: Props): React.ReactNode {
|
||||||
const settings = useSettings()
|
const settings = useSettings();
|
||||||
if (settings.syntaxHighlightingDisabled) {
|
if (settings.syntaxHighlightingDisabled) {
|
||||||
return <MarkdownBody {...props} highlight={null} />
|
return <MarkdownBody {...props} highlight={null} />;
|
||||||
}
|
}
|
||||||
// Suspense fallback renders with highlight=null — plain markdown shows
|
// Suspense fallback renders with highlight=null — plain markdown shows
|
||||||
// for ~50ms on first ever render while cli-highlight loads.
|
// for ~50ms on first ever render while cli-highlight loads.
|
||||||
|
|
@ -88,26 +74,22 @@ export function Markdown(props: Props): React.ReactNode {
|
||||||
<Suspense fallback={<MarkdownBody {...props} highlight={null} />}>
|
<Suspense fallback={<MarkdownBody {...props} highlight={null} />}>
|
||||||
<MarkdownWithHighlight {...props} />
|
<MarkdownWithHighlight {...props} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MarkdownWithHighlight(props: Props): React.ReactNode {
|
function MarkdownWithHighlight(props: Props): React.ReactNode {
|
||||||
const highlight = use(getCliHighlightPromise())
|
const highlight = use(getCliHighlightPromise());
|
||||||
return <MarkdownBody {...props} highlight={highlight} />
|
return <MarkdownBody {...props} highlight={highlight} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MarkdownBody({
|
function MarkdownBody({ children, dimColor, highlight }: Props & { highlight: CliHighlight | null }): React.ReactNode {
|
||||||
children,
|
const [theme] = useTheme();
|
||||||
dimColor,
|
configureMarked();
|
||||||
highlight,
|
|
||||||
}: Props & { highlight: CliHighlight | null }): React.ReactNode {
|
|
||||||
const [theme] = useTheme()
|
|
||||||
configureMarked()
|
|
||||||
|
|
||||||
const elements = useMemo(() => {
|
const elements = useMemo(() => {
|
||||||
const tokens = cachedLexer(stripPromptXMLTags(children))
|
const tokens = cachedLexer(stripPromptXMLTags(children));
|
||||||
const elements: React.ReactNode[] = []
|
const elements: React.ReactNode[] = [];
|
||||||
let nonTableContent = ''
|
let nonTableContent = '';
|
||||||
|
|
||||||
function flushNonTableContent(): void {
|
function flushNonTableContent(): void {
|
||||||
if (nonTableContent) {
|
if (nonTableContent) {
|
||||||
|
|
@ -115,40 +97,34 @@ function MarkdownBody({
|
||||||
<Ansi key={elements.length} dimColor={dimColor}>
|
<Ansi key={elements.length} dimColor={dimColor}>
|
||||||
{nonTableContent.trim()}
|
{nonTableContent.trim()}
|
||||||
</Ansi>,
|
</Ansi>,
|
||||||
)
|
);
|
||||||
nonTableContent = ''
|
nonTableContent = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const token of tokens) {
|
for (const token of tokens) {
|
||||||
if (token.type === 'table') {
|
if (token.type === 'table') {
|
||||||
flushNonTableContent()
|
flushNonTableContent();
|
||||||
elements.push(
|
elements.push(<MarkdownTable key={elements.length} token={token as Tokens.Table} highlight={highlight} />);
|
||||||
<MarkdownTable
|
|
||||||
key={elements.length}
|
|
||||||
token={token as Tokens.Table}
|
|
||||||
highlight={highlight}
|
|
||||||
/>,
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
nonTableContent += formatToken(token, theme, 0, null, null, highlight)
|
nonTableContent += formatToken(token, theme, 0, null, null, highlight);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
flushNonTableContent()
|
flushNonTableContent();
|
||||||
return elements
|
return elements;
|
||||||
}, [children, dimColor, highlight, theme])
|
}, [children, dimColor, highlight, theme]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" gap={1}>
|
<Box flexDirection="column" gap={1}>
|
||||||
{elements}
|
{elements}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
type StreamingProps = {
|
type StreamingProps = {
|
||||||
children: string
|
children: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Renders markdown during streaming by splitting at the last top-level block
|
* Renders markdown during streaming by splitting at the last top-level block
|
||||||
|
|
@ -160,49 +136,47 @@ type StreamingProps = {
|
||||||
* is idempotent and safe under StrictMode double-rendering. Component unmounts
|
* is idempotent and safe under StrictMode double-rendering. Component unmounts
|
||||||
* between turns (streamingText → null), resetting the ref.
|
* between turns (streamingText → null), resetting the ref.
|
||||||
*/
|
*/
|
||||||
export function StreamingMarkdown({
|
export function StreamingMarkdown({ children }: StreamingProps): React.ReactNode {
|
||||||
children,
|
|
||||||
}: StreamingProps): React.ReactNode {
|
|
||||||
// React Compiler: this component reads and writes stablePrefixRef.current
|
// React Compiler: this component reads and writes stablePrefixRef.current
|
||||||
// during render by design. The boundary only advances (monotonic), so
|
// during render by design. The boundary only advances (monotonic), so
|
||||||
// the ref mutation is idempotent under StrictMode double-render — but the
|
// the ref mutation is idempotent under StrictMode double-render — but the
|
||||||
// compiler can't prove that, and memoizing around the ref reads would
|
// compiler can't prove that, and memoizing around the ref reads would
|
||||||
// break the algorithm (stale boundary). Opt out.
|
// break the algorithm (stale boundary). Opt out.
|
||||||
'use no memo'
|
'use no memo';
|
||||||
configureMarked()
|
configureMarked();
|
||||||
|
|
||||||
// Strip before boundary tracking so it matches <Markdown>'s stripping
|
// Strip before boundary tracking so it matches <Markdown>'s stripping
|
||||||
// (line 29). When a closing tag arrives, stripped(N+1) is not a prefix
|
// (line 29). When a closing tag arrives, stripped(N+1) is not a prefix
|
||||||
// of stripped(N), but the startsWith reset below handles that with a
|
// of stripped(N), but the startsWith reset below handles that with a
|
||||||
// one-time re-lex on the smaller stripped string.
|
// one-time re-lex on the smaller stripped string.
|
||||||
const stripped = stripPromptXMLTags(children)
|
const stripped = stripPromptXMLTags(children);
|
||||||
|
|
||||||
const stablePrefixRef = useRef('')
|
const stablePrefixRef = useRef('');
|
||||||
|
|
||||||
// Reset if text was replaced (defensive; normally unmount handles this)
|
// Reset if text was replaced (defensive; normally unmount handles this)
|
||||||
if (!stripped.startsWith(stablePrefixRef.current)) {
|
if (!stripped.startsWith(stablePrefixRef.current)) {
|
||||||
stablePrefixRef.current = ''
|
stablePrefixRef.current = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lex only from current boundary — O(unstable length), not O(full text)
|
// Lex only from current boundary — O(unstable length), not O(full text)
|
||||||
const boundary = stablePrefixRef.current.length
|
const boundary = stablePrefixRef.current.length;
|
||||||
const tokens = marked.lexer(stripped.substring(boundary))
|
const tokens = marked.lexer(stripped.substring(boundary));
|
||||||
|
|
||||||
// Last non-space token is the growing block; everything before is final
|
// Last non-space token is the growing block; everything before is final
|
||||||
let lastContentIdx = tokens.length - 1
|
let lastContentIdx = tokens.length - 1;
|
||||||
while (lastContentIdx >= 0 && tokens[lastContentIdx]!.type === 'space') {
|
while (lastContentIdx >= 0 && tokens[lastContentIdx]!.type === 'space') {
|
||||||
lastContentIdx--
|
lastContentIdx--;
|
||||||
}
|
}
|
||||||
let advance = 0
|
let advance = 0;
|
||||||
for (let i = 0; i < lastContentIdx; i++) {
|
for (let i = 0; i < lastContentIdx; i++) {
|
||||||
advance += tokens[i]!.raw.length
|
advance += tokens[i]!.raw.length;
|
||||||
}
|
}
|
||||||
if (advance > 0) {
|
if (advance > 0) {
|
||||||
stablePrefixRef.current = stripped.substring(0, boundary + advance)
|
stablePrefixRef.current = stripped.substring(0, boundary + advance);
|
||||||
}
|
}
|
||||||
|
|
||||||
const stablePrefix = stablePrefixRef.current
|
const stablePrefix = stablePrefixRef.current;
|
||||||
const unstableSuffix = stripped.substring(stablePrefix.length)
|
const unstableSuffix = stripped.substring(stablePrefix.length);
|
||||||
|
|
||||||
// stablePrefix is memoized inside <Markdown> via useMemo([children, ...])
|
// stablePrefix is memoized inside <Markdown> via useMemo([children, ...])
|
||||||
// so it never re-parses as the unstable suffix grows
|
// so it never re-parses as the unstable suffix grows
|
||||||
|
|
@ -211,5 +185,5 @@ export function StreamingMarkdown({
|
||||||
{stablePrefix && <Markdown>{stablePrefix}</Markdown>}
|
{stablePrefix && <Markdown>{stablePrefix}</Markdown>}
|
||||||
{unstableSuffix && <Markdown>{unstableSuffix}</Markdown>}
|
{unstableSuffix && <Markdown>{unstableSuffix}</Markdown>}
|
||||||
</Box>
|
</Box>
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user