mirror of
http://10.0.2.1:3031/sauer/claude-code.git
synced 2026-06-30 16:56:58 +10:00
236 lines
28 KiB
TypeScript
236 lines
28 KiB
TypeScript
|
|
import { c as _c } from "react/compiler-runtime";
|
|||
|
|
import { marked, type Token, type Tokens } from 'marked';
|
|||
|
|
import React, { Suspense, use, useMemo, useRef } from 'react';
|
|||
|
|
import { useSettings } from '../hooks/useSettings.js';
|
|||
|
|
import { Ansi, Box, useTheme } from '../ink.js';
|
|||
|
|
import { type CliHighlight, getCliHighlightPromise } from '../utils/cliHighlight.js';
|
|||
|
|
import { hashContent } from '../utils/hash.js';
|
|||
|
|
import { configureMarked, formatToken } from '../utils/markdown.js';
|
|||
|
|
import { stripPromptXMLTags } from '../utils/messages.js';
|
|||
|
|
import { MarkdownTable } from './MarkdownTable.js';
|
|||
|
|
type Props = {
|
|||
|
|
children: string;
|
|||
|
|
/** When true, render all text content as dim */
|
|||
|
|
dimColor?: boolean;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
// Module-level token cache — marked.lexer is the hot cost on virtual-scroll
|
|||
|
|
// remounts (~3ms per message). useMemo doesn't survive unmount→remount, so
|
|||
|
|
// scrolling back to a previously-visible message re-parses. Messages are
|
|||
|
|
// immutable in history; same content → same tokens. Keyed by hash to avoid
|
|||
|
|
// retaining full content strings (turn50→turn99 RSS regression, #24180).
|
|||
|
|
const TOKEN_CACHE_MAX = 500;
|
|||
|
|
const tokenCache = new Map<string, Token[]>();
|
|||
|
|
|
|||
|
|
// Characters that indicate markdown syntax. If none are present, skip the
|
|||
|
|
// ~3ms marked.lexer call entirely — render as a single paragraph. Covers
|
|||
|
|
// the majority of short assistant responses and user prompts that are
|
|||
|
|
// plain sentences. Checked via indexOf (not regex) for speed.
|
|||
|
|
// Single regex: matches any MD marker or ordered-list start (N. at line start).
|
|||
|
|
// One pass instead of 10× includes scans.
|
|||
|
|
const MD_SYNTAX_RE = /[#*`|[>\-_~]|\n\n|^\d+\. |\n\d+\. /;
|
|||
|
|
function hasMarkdownSyntax(s: string): boolean {
|
|||
|
|
// Sample first 500 chars — if markdown exists it's usually early (headers,
|
|||
|
|
// code fence, list). Long tool outputs are mostly plain text tails.
|
|||
|
|
return MD_SYNTAX_RE.test(s.length > 500 ? s.slice(0, 500) : s);
|
|||
|
|
}
|
|||
|
|
function cachedLexer(content: string): Token[] {
|
|||
|
|
// Fast path: plain text with no markdown syntax → single paragraph token.
|
|||
|
|
// Skips marked.lexer's full GFM parse (~3ms on long content). Not cached —
|
|||
|
|
// reconstruction is a single object allocation, and caching would retain
|
|||
|
|
// 4× content in raw/text fields plus the hash key for zero benefit.
|
|||
|
|
if (!hasMarkdownSyntax(content)) {
|
|||
|
|
return [{
|
|||
|
|
type: 'paragraph',
|
|||
|
|
raw: content,
|
|||
|
|
text: content,
|
|||
|
|
tokens: [{
|
|||
|
|
type: 'text',
|
|||
|
|
raw: content,
|
|||
|
|
text: content
|
|||
|
|
}]
|
|||
|
|
} as Token];
|
|||
|
|
}
|
|||
|
|
const key = hashContent(content);
|
|||
|
|
const hit = tokenCache.get(key);
|
|||
|
|
if (hit) {
|
|||
|
|
// Promote to MRU — without this the eviction is FIFO (scrolling back to
|
|||
|
|
// an early message evicts the very item you're looking at).
|
|||
|
|
tokenCache.delete(key);
|
|||
|
|
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;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Renders markdown content using a hybrid approach:
|
|||
|
|
* - Tables are rendered as React components with proper flexbox layout
|
|||
|
|
* - Other content is rendered as ANSI strings via formatToken
|
|||
|
|
*/
|
|||
|
|
export function Markdown(props) {
|
|||
|
|
const $ = _c(4);
|
|||
|
|
const settings = useSettings();
|
|||
|
|
if (settings.syntaxHighlightingDisabled) {
|
|||
|
|
let t0;
|
|||
|
|
if ($[0] !== props) {
|
|||
|
|
t0 = <MarkdownBody {...props} highlight={null} />;
|
|||
|
|
$[0] = props;
|
|||
|
|
$[1] = t0;
|
|||
|
|
} else {
|
|||
|
|
t0 = $[1];
|
|||
|
|
}
|
|||
|
|
return t0;
|
|||
|
|
}
|
|||
|
|
let t0;
|
|||
|
|
if ($[2] !== props) {
|
|||
|
|
t0 = <Suspense fallback={<MarkdownBody {...props} highlight={null} />}><MarkdownWithHighlight {...props} /></Suspense>;
|
|||
|
|
$[2] = props;
|
|||
|
|
$[3] = t0;
|
|||
|
|
} else {
|
|||
|
|
t0 = $[3];
|
|||
|
|
}
|
|||
|
|
return t0;
|
|||
|
|
}
|
|||
|
|
function MarkdownWithHighlight(props) {
|
|||
|
|
const $ = _c(4);
|
|||
|
|
let t0;
|
|||
|
|
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
|
|||
|
|
t0 = getCliHighlightPromise();
|
|||
|
|
$[0] = t0;
|
|||
|
|
} else {
|
|||
|
|
t0 = $[0];
|
|||
|
|
}
|
|||
|
|
const highlight = use(t0);
|
|||
|
|
let t1;
|
|||
|
|
if ($[1] !== highlight || $[2] !== props) {
|
|||
|
|
t1 = <MarkdownBody {...props} highlight={highlight} />;
|
|||
|
|
$[1] = highlight;
|
|||
|
|
$[2] = props;
|
|||
|
|
$[3] = t1;
|
|||
|
|
} else {
|
|||
|
|
t1 = $[3];
|
|||
|
|
}
|
|||
|
|
return t1;
|
|||
|
|
}
|
|||
|
|
function MarkdownBody(t0) {
|
|||
|
|
const $ = _c(7);
|
|||
|
|
const {
|
|||
|
|
children,
|
|||
|
|
dimColor,
|
|||
|
|
highlight
|
|||
|
|
} = t0;
|
|||
|
|
const [theme] = useTheme();
|
|||
|
|
configureMarked();
|
|||
|
|
let elements;
|
|||
|
|
if ($[0] !== children || $[1] !== dimColor || $[2] !== highlight || $[3] !== theme) {
|
|||
|
|
const tokens = cachedLexer(stripPromptXMLTags(children));
|
|||
|
|
elements = [];
|
|||
|
|
let nonTableContent = "";
|
|||
|
|
const flushNonTableContent = function flushNonTableContent() {
|
|||
|
|
if (nonTableContent) {
|
|||
|
|
elements.push(<Ansi key={elements.length} dimColor={dimColor}>{nonTableContent.trim()}</Ansi>);
|
|||
|
|
nonTableContent = "";
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
for (const token of tokens) {
|
|||
|
|
if (token.type === "table") {
|
|||
|
|
flushNonTableContent();
|
|||
|
|
elements.push(<MarkdownTable key={elements.length} token={token as Tokens.Table} highlight={highlight} />);
|
|||
|
|
} else {
|
|||
|
|
nonTableContent = nonTableContent + formatToken(token, theme, 0, null, null, highlight);
|
|||
|
|
nonTableContent;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
flushNonTableContent();
|
|||
|
|
$[0] = children;
|
|||
|
|
$[1] = dimColor;
|
|||
|
|
$[2] = highlight;
|
|||
|
|
$[3] = theme;
|
|||
|
|
$[4] = elements;
|
|||
|
|
} else {
|
|||
|
|
elements = $[4];
|
|||
|
|
}
|
|||
|
|
const elements_0 = elements;
|
|||
|
|
let t1;
|
|||
|
|
if ($[5] !== elements_0) {
|
|||
|
|
t1 = <Box flexDirection="column" gap={1}>{elements_0}</Box>;
|
|||
|
|
$[5] = elements_0;
|
|||
|
|
$[6] = t1;
|
|||
|
|
} else {
|
|||
|
|
t1 = $[6];
|
|||
|
|
}
|
|||
|
|
return t1;
|
|||
|
|
}
|
|||
|
|
type StreamingProps = {
|
|||
|
|
children: string;
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Renders markdown during streaming by splitting at the last top-level block
|
|||
|
|
* boundary: everything before is stable (memoized, never re-parsed), only the
|
|||
|
|
* final block is re-parsed per delta. marked.lexer() correctly handles
|
|||
|
|
* unclosed code fences as a single token, so block boundaries are always safe.
|
|||
|
|
*
|
|||
|
|
* The stable boundary only advances (monotonic), so ref mutation during render
|
|||
|
|
* is idempotent and safe under StrictMode double-rendering. Component unmounts
|
|||
|
|
* between turns (streamingText → null), resetting the ref.
|
|||
|
|
*/
|
|||
|
|
export function StreamingMarkdown({
|
|||
|
|
children
|
|||
|
|
}: StreamingProps): React.ReactNode {
|
|||
|
|
// React Compiler: this component reads and writes stablePrefixRef.current
|
|||
|
|
// during render by design. The boundary only advances (monotonic), so
|
|||
|
|
// the ref mutation is idempotent under StrictMode double-render — but the
|
|||
|
|
// compiler can't prove that, and memoizing around the ref reads would
|
|||
|
|
// break the algorithm (stale boundary). Opt out.
|
|||
|
|
'use no memo';
|
|||
|
|
|
|||
|
|
configureMarked();
|
|||
|
|
|
|||
|
|
// Strip before boundary tracking so it matches <Markdown>'s stripping
|
|||
|
|
// (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
|
|||
|
|
// one-time re-lex on the smaller stripped string.
|
|||
|
|
const stripped = stripPromptXMLTags(children);
|
|||
|
|
const stablePrefixRef = useRef('');
|
|||
|
|
|
|||
|
|
// Reset if text was replaced (defensive; normally unmount handles this)
|
|||
|
|
if (!stripped.startsWith(stablePrefixRef.current)) {
|
|||
|
|
stablePrefixRef.current = '';
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Lex only from current boundary — O(unstable length), not O(full text)
|
|||
|
|
const boundary = stablePrefixRef.current.length;
|
|||
|
|
const tokens = marked.lexer(stripped.substring(boundary));
|
|||
|
|
|
|||
|
|
// Last non-space token is the growing block; everything before is final
|
|||
|
|
let lastContentIdx = tokens.length - 1;
|
|||
|
|
while (lastContentIdx >= 0 && tokens[lastContentIdx]!.type === 'space') {
|
|||
|
|
lastContentIdx--;
|
|||
|
|
}
|
|||
|
|
let advance = 0;
|
|||
|
|
for (let i = 0; i < lastContentIdx; i++) {
|
|||
|
|
advance += tokens[i]!.raw.length;
|
|||
|
|
}
|
|||
|
|
if (advance > 0) {
|
|||
|
|
stablePrefixRef.current = stripped.substring(0, boundary + advance);
|
|||
|
|
}
|
|||
|
|
const stablePrefix = stablePrefixRef.current;
|
|||
|
|
const unstableSuffix = stripped.substring(stablePrefix.length);
|
|||
|
|
|
|||
|
|
// stablePrefix is memoized inside <Markdown> via useMemo([children, ...])
|
|||
|
|
// so it never re-parses as the unstable suffix grows
|
|||
|
|
return <Box flexDirection="column" gap={1}>
|
|||
|
|
{stablePrefix && <Markdown>{stablePrefix}</Markdown>}
|
|||
|
|
{unstableSuffix && <Markdown>{unstableSuffix}</Markdown>}
|
|||
|
|
</Box>;
|
|||
|
|
}
|
|||
|
|
//# sourceMappingURL=data:application/json;charset=utf-8;base64,eyJ2ZXJzaW9uIjozLCJuYW1lcyI6WyJtYXJrZWQiLCJUb2tlbiIsIlRva2VucyIsIlJlYWN0IiwiU3VzcGVuc2UiLCJ1c2UiLCJ1c2VNZW1vIiwidXNlUmVmIiwidXNlU2V0dGluZ3MiLCJBbnNpIiwiQm94IiwidXNlVGhlbWUiLCJDbGlIaWdobGlnaHQiLCJnZXRDbGlIaWdobGlnaHRQcm9taXNlIiwiaGFzaENvbnRlbnQiLCJjb25maWd1cmVNYXJrZWQiLCJmb3JtYXRUb2tlbiIsInN0cmlwUHJvbXB0WE1MVGFncyIsIk1hcmtkb3duVGFibGUiLCJQcm9wcyIsImNoaWxkcmVuIiwiZGltQ29sb3IiLCJUT0tFTl9DQUNIRV9NQVgiLCJ0b2tlbkNhY2hlIiwiTWFwIiwiTURfU1lOVEFYX1JFIiwiaGFzTWFya2Rvd25TeW50YXgiLCJzIiwidGVzdCIsImxlbmd0aCIsInNsaWNlIiwiY2FjaGVkTGV4ZXIiLCJjb250ZW50IiwidHlwZSIsInJhdyIsInRleHQiLCJ0b2tlbnMiLCJrZXkiLCJoaXQiLCJnZXQiLCJkZWxldGUiLCJzZXQiLCJsZXhlciIsInNpemUiLCJmaXJzdCIsImtleXMiLCJuZXh0IiwidmFsdWUiLCJ1bmRlZmluZWQiLCJNYXJrZG93biIsInByb3BzIiwiJCIsIl9jIiwic2V0dGluZ3MiLCJzeW50YXhIaWdobGlnaHRpbmdEaXNhYmxlZCIsInQwIiwiTWFya2Rvd25XaXRoSGlnaGxpZ2h0IiwiU3ltYm9sIiwiZm9yIiwiaGlnaGxpZ2h0IiwidDEiLCJNYXJrZG93bkJvZHkiLCJ0aGVtZSIsImVsZW1lbnRzIiwibm9uVGFibGVDb250ZW50IiwiZmx1c2hOb25UYWJsZUNvbnRlbnQiLCJwdXNoIiwidHJpbSIsInRva2VuIiwiVGFibGUiLCJlbGVtZW50c18wIiwiU3RyZWFtaW5nUHJvcHMiLCJTdHJlYW1pbmdNYXJrZG93biIsIlJlYWN0Tm9kZSIsInN0cmlwcGVkIiwic3RhYmxlUHJlZml4UmVmIiwic3RhcnRzV2l0aCIsImN1cnJlbnQiLCJib3VuZGFyeSIsInN1YnN0cmluZyIsImxhc3RDb250ZW50SWR4IiwiYWR2YW5jZSIsImkiLCJzdGFibGVQcmVmaXgiLCJ1bnN0YWJsZVN1ZmZpeCJdLCJzb3VyY2VzIjpbIk1hcmtkb3duLnRzeCJdLCJzb3VyY2VzQ29udGVudCI6WyJpbXBvcnQgeyBtYXJrZWQsIHR5cGUgVG9rZW4sIHR5cGUgVG9rZW5zIH0gZnJvbSAnbWFya2VkJ1xuaW1wb3J0IFJlYWN0LCB7IFN1c3BlbnNlLCB1c2UsIHVzZU1lbW8sIHVzZVJlZiB9IGZyb20gJ3JlYWN0J1xuaW1wb3J0IHsgdXNlU2V0dGluZ3MgfSBmcm9tICcuLi9ob29rcy91c2VTZXR0aW5ncy5qcydcbmltcG9ydCB7IEFuc2ksIEJveCwgdXNlVGhlbWUgfSBmcm9tICcuLi9pbmsuanMnXG5pbXBvcnQge1xuICB0eXBlIENsaUhpZ2hsaWdodCxcbiAgZ2V0Q2xpSGlnaGxpZ2h0UHJvbWlzZSxcbn0gZnJvbSAnLi4vdXRpbHMvY2xpSGlnaGxpZ2h0LmpzJ1xuaW1wb3J0IHsgaGFzaENvbnRlbnQgfSBmcm9tICcuLi91dGlscy9oYXNoLmpzJ1xuaW1wb3J0IHsgY29uZmlndXJlTWFya2VkLCBmb3JtYXRUb2tlbiB9IGZyb20gJy4uL3V0aWxzL21hcmtkb3duLmpzJ1xuaW1wb3J0IHsgc3RyaXBQcm9tcHRYTUxUYWdzIH0gZnJvbSAnLi4vdXRpbHMvbWVzc2FnZXMuanMnXG5pbXBvcnQgeyBNYXJrZG93blRhYmxlIH0gZnJvbSAnLi9NYXJrZG93blRhYmxlLmpzJ1xuXG50eXBlIFByb3BzID0ge1xuICBjaGlsZHJlbjogc3RyaW5nXG4gIC8qKiBXaGVuIHRydWUsIHJlbmRlciBhbGwgdGV4dCBjb250ZW50IGFzIGRpbSAqL1xuICBkaW1Db2xvcj86IGJvb2xlYW5cbn1cblxuLy8gTW9kdWxlLWxldmVsIHRva2VuIGNhY2hlIOKAlCBtYXJrZWQubGV4ZXIgaXMgdGhlIGhvdCBjb3N0IG9uIHZpcnR1YWwtc2Nyb2xsXG4vLyByZW1vdW50cyAofjNtcyBwZXIgbWVzc2FnZSkuIHVzZU1lbW8gZG9lc24ndCBzdXJ2aXZlIHVubW91bnTihpJyZW1vdW50LCBzb1xuLy8gc2Nyb2xsaW5nIGJhY2sgdG8gYSBwcmV2aW91c2x5LXZpc2libGUgbWVzc2FnZSByZS1wYXJzZXMuIE1lc3NhZ2VzIGFyZVxuLy8gaW1tdXRhYmxlIGluIGhpc3Rvcnk7IHNhbWUgY29udGVudCDihpIgc2FtZSB0b2tlbnMuIEtleWVkIGJ5IGhhc2ggdG8gYXZvaWRcbi8vIHJldGFpbmluZyBmdWxsIGNvbnRlbnQgc3RyaW5ncyAodHVybjUw4oaSdHVybjk5IFJTUyByZWdyZXNzaW9uLCAjMjQxODApLlxuY29uc3QgVE9LRU5fQ0FDSEVfTUFYID0gNTAwXG5jb25zdCB0b2tlbkNhY2hlID0gbmV3IE1hcDxzdHJpbmcsIFRva2VuW10+KClcblxuLy8gQ2hhcmFjdGVycyB0aGF0IGluZGljYXRlIG1hcmtkb3duIHN5bnRheC4gSWYgbm9uZSBhcmUgcHJlc2VudCwgc2tpcCB0aGVcbi8vIH4zbXMgbWFya2VkLmxleGVyIGNhbGwgZW50aXJlbHkg4oCUIHJlbmRlciBhcyBhIHNpbmdsZSBwYXJhZ3JhcGguIENvdmVyc1xuLy8gdGhlIG1ham9yaXR5IG9mIHNob3J0IGFzc2lzdGFudCByZXNwb25zZXMgYW5kIHVzZXIgcHJvbXB0cyB0aGF0IGFyZVxuLy8gcGxhaW4gc2VudGVuY2VzLiBDaGVja2VkIHZpYSBpbmRleE9mIChub3QgcmVnZXgpIGZvciBzcGVlZC5cbi8vIFNpbmdsZSByZWdleDogbWF0Y2hlcyBhbnkgTUQgbWFya2VyIG9yIG9yZGVyZWQtbGlzdCBzdGFydCAoTi4gYXQgbGluZSBzdGFydCkuXG4vLyBPbmUgcGFzcyBpbnN0ZWFkIG9mIDEww5cgaW5jbHVkZXMgc2NhbnMuXG5jb25zdCBNRF9TWU5UQVhfUkUgPSAvWyMqYHxbPlxcLV9+XXxcXG5cXG58XlxcZCtcXC4gfFxcblxcZCtcXC4gL1xuZnVuY3Rpb24gaGFzTWFya2Rvd25TeW50YXgoczogc3RyaW5nKTogYm9vbGVhbiB7XG4gIC8vIFNhbXBsZSBmaXJzdCA1MDAgY2hhcnMg4oCUIGlmIG1hcmtkb3duIGV4aXN0cyBpdCdzIHVzdWFsbHkgZWFybHkgKGhlYWRlcnMsXG4gIC8vIGNvZGUgZmVuY2UsIGxpc3QpLiBMb25nIHRvb2wgb3V0cHV0cyBhcmUgbW9zdGx5IHBsYWluIHRleHQgdGFpbHMuXG4gIHJldHVybiBNRF9TWU5UQVhfUkUudGVzdChzLmxlbmd0aCA+IDUwMCA/IHMuc2xpY2UoMCwgNTAwKSA6IHMpXG59XG5cbmZ1bmN0aW9uIGNhY2hlZExleGVyKGNvbnRlbnQ6IHN0cmluZyk6IFRva2VuW10ge1xuICAvLyBGYXN0IHBhdGg6IHBsYWluIHRleHQgd2l0aCBubyBtYXJrZG93biBzeW50YXgg4oaSIHNpbmdsZSBwYXJhZ3JhcGggdG9rZW4
|