/** * Markdown rendering component for ink-terminal. * * Adapted from Claude Code's Markdown.tsx. Key patterns extracted: * 1. LRU token cache (500 entries, hash-keyed, MRU promotion) * 2. hasMarkdownSyntax fast-path optimization (skip lexer for plain text) * 3. Stable/unstable prefix splitting for streaming mode * * Simplified vs Claude Code: * - No useSettings/useTheme — standalone component * - No syntax highlighting (code blocks render as dim text) * - No MarkdownTable React component — tables rendered inline as ANSI strings * - No stripPromptXMLTags — no Claude-specific preprocessing * - No React compiler runtime (_c) — uses standard React.memo/useMemo */ import { marked, type Token } from 'marked' import React, { useMemo, useRef } from 'react' import { Ansi } from '../react/Ansi.js' import Box from '../react/components/Box.js' import { formatToken } from './format-token.js' import { LRUTokenCache } from './token-cache.js' // --------------------------------------------------------------------------- // Marked configuration // --------------------------------------------------------------------------- let markedConfigured = false function configureMarked(): void { if (markedConfigured) return markedConfigured = true // Disable strikethrough parsing — the model often uses ~ for "approximate" // (e.g., ~100) and rarely intends actual strikethrough formatting. marked.use({ tokenizer: { del() { return undefined }, }, }) } // --------------------------------------------------------------------------- // Token cache + fast-path // --------------------------------------------------------------------------- const tokenCache = new LRUTokenCache(500) // Single regex: matches any MD marker or ordered-list start (N. at line start). // One pass instead of many indexOf scans. const MD_SYNTAX_RE = /[#*`|[>\-_~]|\n\n|^\d+\. |\n\d+\. / /** * Fast check for markdown syntax presence. If none is found, skip the * ~3ms marked.lexer call entirely — render as a single paragraph. Covers * the majority of short responses that are plain sentences. */ function hasMarkdownSyntax(s: string): boolean { // Sample first 500 chars — if markdown exists it's usually early (headers, // code fence, list). Long outputs are mostly plain text tails. return MD_SYNTAX_RE.test(s.length > 500 ? s.slice(0, 500) : s) } /** * Parse markdown content into tokens, with LRU caching and fast-path * for plain text. */ function cachedLexer(content: string): Token[] { // Fast path: plain text with no markdown syntax -> single paragraph token. if (!hasMarkdownSyntax(content)) { return [ { type: 'paragraph', raw: content, text: content, tokens: [{ type: 'text', raw: content, text: content }], } as Token, ] } const hit = tokenCache.get(content) if (hit) return hit const tokens = marked.lexer(content) tokenCache.set(content, tokens) return tokens } // --------------------------------------------------------------------------- // Component props // --------------------------------------------------------------------------- type MarkdownProps = { children: string /** When true, render all text content as dim */ dimColor?: boolean /** When true, use streaming mode with stable/unstable prefix splitting */ streaming?: boolean } // --------------------------------------------------------------------------- // Public components // --------------------------------------------------------------------------- /** * Renders markdown content as styled ANSI text inside an ink-terminal layout. * * Basic usage: * ```tsx * {markdownString} * ``` * * Streaming usage (re-parses only the unstable tail): * ```tsx * {partialMarkdownString} * ``` */ export function Markdown({ children, dimColor, streaming, }: MarkdownProps): React.ReactNode { if (streaming) { return {children} } return {children} } // --------------------------------------------------------------------------- // Internal: static markdown rendering // --------------------------------------------------------------------------- const MarkdownBody = React.memo(function MarkdownBody({ children, dimColor, }: { children: string dimColor?: boolean }): React.ReactNode { configureMarked() const rendered = useMemo(() => { const tokens = cachedLexer(children) return tokens.map(token => formatToken(token)).join('').trim() }, [children]) if (!rendered) return null return ( {rendered} ) }) // --------------------------------------------------------------------------- // Internal: streaming markdown rendering // --------------------------------------------------------------------------- /** * 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. */ function StreamingMarkdown({ children, dimColor, }: { children: string dimColor?: boolean }): React.ReactNode { configureMarked() const stablePrefixRef = useRef('') // Reset if text was replaced (defensive; normally unmount handles this) if (!children.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(children.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 = children.substring(0, boundary + advance) } const stablePrefix = stablePrefixRef.current const unstableSuffix = children.substring(stablePrefix.length) // stablePrefix is memoized inside via React.memo + useMemo // so it never re-parses as the unstable suffix grows return ( {stablePrefix && ( {stablePrefix} )} {unstableSuffix && ( {unstableSuffix} )} ) }