diff --git a/packages/components/src/components/ai-gui/markdown-renderer.tsx b/packages/components/src/components/ai-gui/markdown-renderer.tsx index c8117632a..94e8357c2 100644 --- a/packages/components/src/components/ai-gui/markdown-renderer.tsx +++ b/packages/components/src/components/ai-gui/markdown-renderer.tsx @@ -41,7 +41,10 @@ import { parseMarkdownAgentFileHref, } from '@/lib/markdown-agent-file-link'; import { matchWholeFilePath, splitTextIntoFilePathSegments } from '@/lib/linkify-file-paths'; -import { remarkSingleDollarTextMath } from '@/lib/markdown-single-dollar-math'; +import { + normalizeTexMathDelimiters, + remarkSingleDollarTextMath, +} from '@/lib/markdown-single-dollar-math'; import { cn } from '@/lib/utils'; import { usePrLinkInterceptor } from './pr-link-context'; import { @@ -1113,6 +1116,7 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ const openDiagramLabel = t('sessions.diagramViewer.open', 'Open diagram'); const [diagramSelection, setDiagramSelection] = useState(null); const hasMermaidBlock = useMemo(() => MERMAID_FENCE_PATTERN.test(text), [text]); + const normalizedText = useMemo(() => normalizeTexMathDelimiters(text), [text]); const closeDiagram = useCallback(() => setDiagramSelection(null), []); @@ -1380,7 +1384,7 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ translations={streamdownTranslations} urlTransform={markdownUrlTransform} > - {text} + {normalizedText} {/* A sibling of the markdown, not a child: a portal's events bubble diff --git a/packages/components/src/lib/markdown-single-dollar-math.ts b/packages/components/src/lib/markdown-single-dollar-math.ts index 96cbfde9c..ade3457fb 100644 --- a/packages/components/src/lib/markdown-single-dollar-math.ts +++ b/packages/components/src/lib/markdown-single-dollar-math.ts @@ -15,6 +15,315 @@ type InlineMathNode = MdastNode & { }; }; +type TexMathDelimiter = { + kind: 'inline' | 'display'; + index: number; +}; + +type MarkdownContainer = + | { kind: 'blockquote' } + | { + kind: 'indent'; + size: number; + }; + +type MarkdownFence = { + containers: MarkdownContainer[]; + marker: '`' | '~'; + size: number; +}; + +const lineEndAfter = (value: string, start: number): number => { + const newline = value.indexOf('\n', start); + return newline === -1 ? value.length : newline + 1; +}; + +const lineContentEnd = (value: string, lineStart: number): number => { + const newline = value.indexOf('\n', lineStart); + const end = newline === -1 ? value.length : newline; + return end > lineStart && value[end - 1] === '\r' ? end - 1 : end; +}; + +const indentedCodeLineEnd = (value: string, lineStart: number): number | null => { + const contentEnd = lineContentEnd(value, lineStart); + let column = 0; + let cursor = lineStart; + + while (cursor < contentEnd) { + if (value[cursor] === ' ') { + column += 1; + } else if (value[cursor] === '\t') { + column += 4 - (column % 4); + } else { + return null; + } + + cursor += 1; + if (column >= 4) return lineEndAfter(value, lineStart); + } + + return null; +}; + +const spacesEnd = (value: string, start: number, end: number, maximum: number): number => { + let cursor = start; + while (cursor < end && cursor - start < maximum && value[cursor] === ' ') cursor += 1; + return cursor; +}; + +const listMarkerEnd = (value: string, start: number, end: number): number | null => { + let cursor = start; + const marker = value[cursor]; + + if (marker === '-' || marker === '+' || marker === '*') { + cursor += 1; + } else { + const digitStart = cursor; + while (cursor < end && cursor - digitStart < 9 && /\d/.test(value[cursor])) cursor += 1; + if (cursor === digitStart || (value[cursor] !== '.' && value[cursor] !== ')')) return null; + cursor += 1; + } + + if (value[cursor] !== ' ' && value[cursor] !== '\t') return null; + + const whitespaceStart = cursor; + while (cursor < end && (value[cursor] === ' ' || value[cursor] === '\t')) cursor += 1; + + // CommonMark treats one to four spaces as list-marker padding. With five or + // more, only the first belongs to the marker and the rest indent the content. + return cursor - whitespaceStart <= 4 ? cursor : whitespaceStart + 1; +}; + +const markdownFenceAt = (value: string, lineStart: number): MarkdownFence | null => { + const contentEnd = lineContentEnd(value, lineStart); + let cursor = lineStart; + const containers: MarkdownContainer[] = []; + + while (cursor < contentEnd) { + const containerStart = cursor; + cursor = spacesEnd(value, cursor, contentEnd, 3); + + if (value[cursor] === '>') { + containers.push({ kind: 'blockquote' }); + cursor += 1; + if (value[cursor] === ' ' || value[cursor] === '\t') cursor += 1; + continue; + } + + const markerEnd = listMarkerEnd(value, cursor, contentEnd); + if (markerEnd != null) { + containers.push({ kind: 'indent', size: markerEnd - containerStart }); + cursor = markerEnd; + continue; + } + + break; + } + + const marker = value[cursor]; + if (marker !== '`' && marker !== '~') return null; + + let runEnd = cursor; + while (runEnd < contentEnd && value[runEnd] === marker) runEnd += 1; + const size = runEnd - cursor; + if (size < 3) return null; + + // A backtick fence cannot contain another backtick in its info string. + if (marker === '`' && value.slice(runEnd, contentEnd).includes('`')) return null; + + return { containers, marker, size }; +}; + +const markdownContainerContentStart = ( + value: string, + lineStart: number, + contentEnd: number, + containers: readonly MarkdownContainer[] +): number | null => { + let cursor = lineStart; + + for (const container of containers) { + if (container.kind === 'blockquote') { + cursor = spacesEnd(value, cursor, contentEnd, 3); + if (value[cursor] !== '>') return null; + cursor += 1; + if (value[cursor] === ' ' || value[cursor] === '\t') cursor += 1; + continue; + } + + for (let index = 0; index < container.size; index += 1) { + if (value[cursor] !== ' ') return null; + cursor += 1; + } + } + + return cursor; +}; + +const isClosingMarkdownFence = ( + value: string, + lineStart: number, + fence: MarkdownFence +): boolean => { + const contentEnd = lineContentEnd(value, lineStart); + const contentStart = markdownContainerContentStart( + value, + lineStart, + contentEnd, + fence.containers + ); + if (contentStart == null) return false; + let cursor = spacesEnd(value, contentStart, contentEnd, 3); + + const runStart = cursor; + while (cursor < contentEnd && value[cursor] === fence.marker) cursor += 1; + if (cursor - runStart < fence.size) return false; + + while (cursor < contentEnd && (value[cursor] === ' ' || value[cursor] === '\t')) { + cursor += 1; + } + return cursor === contentEnd; +}; + +const fencedCodeEnd = (value: string, lineStart: number, fence: MarkdownFence): number => { + let cursor = lineEndAfter(value, lineStart); + + while (cursor < value.length) { + const nextLine = lineEndAfter(value, cursor); + if (isClosingMarkdownFence(value, cursor, fence)) return nextLine; + cursor = nextLine; + } + + return value.length; +}; + +const backtickRunLength = (value: string, start: number): number => { + let cursor = start; + while (cursor < value.length && value[cursor] === '`') cursor += 1; + return cursor - start; +}; + +const inlineCodeEnd = (value: string, start: number, size: number): number | null => { + let cursor = start + size; + + while (cursor < value.length) { + const next = value.indexOf('`', cursor); + if (next === -1) return null; + + const nextSize = backtickRunLength(value, next); + if (nextSize === size) return next + nextSize; + cursor = next + nextSize; + } + + return null; +}; + +const slashRunLength = (value: string, start: number): number => { + let cursor = start; + while (cursor < value.length && value[cursor] === '\\') cursor += 1; + return cursor - start; +}; + +/** + * Normalizes TeX's `\\(...\\)` and `\\[...\\]` delimiters to the double-dollar + * form understood by remark-math. This must run before Streamdown splits the + * Markdown into blocks: otherwise a display formula containing a line such as + * `=` can already have been classified as a Markdown heading. + * + * Only complete, matching pairs outside code spans/blocks are rewritten. Each + * delimiter remains two characters wide, so source offsets used by later + * Markdown transforms stay valid. + */ +export const normalizeTexMathDelimiters = (value: string): string => { + const replacements: number[] = []; + let opening: TexMathDelimiter | null = null; + let cursor = 0; + let lineStart = 0; + + while (cursor < value.length) { + if (cursor === lineStart) { + const codeLineEnd = opening == null ? indentedCodeLineEnd(value, lineStart) : null; + if (codeLineEnd != null) { + cursor = codeLineEnd; + lineStart = cursor; + continue; + } + + const fence = markdownFenceAt(value, lineStart); + if (fence) { + opening = null; + cursor = fencedCodeEnd(value, lineStart, fence); + lineStart = cursor; + continue; + } + } + + const current = value[cursor]; + if (current === '\n') { + if (opening?.kind === 'inline') opening = null; + cursor += 1; + lineStart = cursor; + continue; + } + + if (current === '`') { + const size = backtickRunLength(value, cursor); + const end = inlineCodeEnd(value, cursor, size); + if (end != null) { + opening = null; + cursor = end; + lineStart = value.lastIndexOf('\n', cursor - 1) + 1; + continue; + } + cursor += size; + continue; + } + + if (current !== '\\') { + cursor += 1; + continue; + } + + const slashSize = slashRunLength(value, cursor); + const delimiterIndex = cursor + slashSize - 1; + const delimiterMarker = value[delimiterIndex + 1]; + + // Pairs of slashes escape each other. With an odd run, only its final + // slash participates in the TeX delimiter and any preceding pairs remain. + if ( + slashSize % 2 === 0 || + (delimiterMarker !== '(' && + delimiterMarker !== ')' && + delimiterMarker !== '[' && + delimiterMarker !== ']') + ) { + cursor += slashSize; + continue; + } + + const kind = delimiterMarker === '(' || delimiterMarker === ')' ? 'inline' : 'display'; + const isOpening = delimiterMarker === '(' || delimiterMarker === '['; + + if (isOpening) { + opening = { kind, index: delimiterIndex }; + } else if (opening?.kind === kind) { + replacements.push(opening.index, delimiterIndex); + opening = null; + } + + cursor = delimiterIndex + 2; + } + + if (replacements.length === 0) return value; + + const normalized = value.split(''); + replacements.forEach((index) => { + normalized[index] = '$'; + normalized[index + 1] = '$'; + }); + return normalized.join(''); +}; + const SKIP_CHILDREN_NODE_TYPES = new Set([ 'code', 'definition', diff --git a/packages/components/tests/markdown-math-delimiters.test.ts b/packages/components/tests/markdown-math-delimiters.test.ts new file mode 100644 index 000000000..3e92e5cb8 --- /dev/null +++ b/packages/components/tests/markdown-math-delimiters.test.ts @@ -0,0 +1,97 @@ +import { describe, expect, it } from 'vitest'; + +import { normalizeTexMathDelimiters } from '../src/lib/markdown-single-dollar-math'; + +describe('normalizeTexMathDelimiters', () => { + it('normalizes complete inline and display pairs without shifting Unicode text', () => { + const markdown = ['😀 before \\(x_i\\).', '', '\\[', 'y = \\boxed{1}', '\\]'].join('\n'); + + expect(normalizeTexMathDelimiters(markdown)).toBe( + ['😀 before $$x_i$$.', '', '$$', 'y = \\boxed{1}', '$$'].join('\n') + ); + }); + + it('leaves escaped and incomplete delimiters unchanged', () => { + const markdown = String.raw`literal \\(x\\), unmatched z\), and incomplete \(y`; + + expect(normalizeTexMathDelimiters(markdown)).toBe(markdown); + }); + + it('does not let an incomplete inline delimiter suppress a later formula', () => { + const markdown = ['incomplete \\(y', 'next \\(z\\)'].join('\n'); + + expect(normalizeTexMathDelimiters(markdown)).toBe(['incomplete \\(y', 'next $$z$$'].join('\n')); + }); + + it('leaves delimiters inside inline and fenced code unchanged', () => { + const markdown = [ + '`\\(inline\\)`', + '', + '~~~tex', + '\\[', + 'display', + '\\]', + '~~~', + '', + '\\(outside\\)', + ].join('\n'); + + expect(normalizeTexMathDelimiters(markdown)).toBe( + ['`\\(inline\\)`', '', '~~~tex', '\\[', 'display', '\\]', '~~~', '', '$$outside$$'].join('\n') + ); + }); + + it('leaves delimiters inside four-column indented code unchanged', () => { + const markdown = [' \\(space_indented\\)', '\t\\[tab_indented\\]', '', '\\(outside\\)'].join( + '\n' + ); + + expect(normalizeTexMathDelimiters(markdown)).toBe( + [' \\(space_indented\\)', '\t\\[tab_indented\\]', '', '$$outside$$'].join('\n') + ); + }); + + it('leaves delimiters inside container-nested fenced code unchanged', () => { + const markdown = [ + '> ```tex', + '> \\(blockquote_literal\\)', + '> ```', + '', + '- ~~~tex', + ' \\[list_literal\\]', + ' ~~~', + '', + '> - ````tex', + '> \\(nested_literal\\)', + '> ````', + '', + '10. ```tex', + ' \\(ordered_list_literal\\)', + ' ```', + '', + '\\(outside\\)', + ].join('\n'); + + expect(normalizeTexMathDelimiters(markdown)).toBe( + [ + '> ```tex', + '> \\(blockquote_literal\\)', + '> ```', + '', + '- ~~~tex', + ' \\[list_literal\\]', + ' ~~~', + '', + '> - ````tex', + '> \\(nested_literal\\)', + '> ````', + '', + '10. ```tex', + ' \\(ordered_list_literal\\)', + ' ```', + '', + '$$outside$$', + ].join('\n') + ); + }); +}); diff --git a/packages/components/tests/markdown-streaming-reparse.test.ts b/packages/components/tests/markdown-streaming-reparse.test.ts index cc33be97d..5838ea6a3 100644 --- a/packages/components/tests/markdown-streaming-reparse.test.ts +++ b/packages/components/tests/markdown-streaming-reparse.test.ts @@ -380,6 +380,82 @@ describe('MarkdownRenderer streaming rendering', () => { expect(await waitForElement('[data-streamdown="mermaid-block"]')).not.toBeNull(); }); + it('renders Codex-style parenthesis and bracket LaTeX delimiters', async () => { + await renderMarkdown( + [ + 'Let \\(t_i\\) denote the token allocation.', + '', + '\\[', + '\\boxed{DV(t^*)[a]}', + '=', + '\\int \\xi_i a_i\\,di', + '\\]', + '', + 'where \\(\\xi_i = \\underbrace{V_z / \\Lambda}_{\\text{social value}} r_{i,t}\\).', + ].join('\n') + ); + + expect(container?.querySelectorAll('.katex')).toHaveLength(3); + expect(container?.querySelectorAll('.katex-display')).toHaveLength(1); + }); + + it('keeps Codex-style LaTeX delimiters literal inside Markdown code', async () => { + await renderMarkdown( + [ + '`\\(inline_code\\)`', + '', + '```tex', + '\\[', + 'fenced_code', + '\\]', + '```', + '', + 'Outside \\(x_i\\) renders.', + ].join('\n') + ); + + expect(container?.querySelectorAll('.katex')).toHaveLength(1); + expect(container?.querySelector('code')?.textContent).toBe('\\(inline_code\\)'); + expect(container?.textContent).toContain('\\[fenced_code\\]'); + }); + + it('keeps LaTeX delimiters literal in indented code blocks', async () => { + await renderMarkdown([' \\(literal\\)', '', 'Outside \\(x_i\\) renders.'].join('\n')); + + expect(container?.querySelectorAll('.katex')).toHaveLength(1); + expect(container?.querySelector('pre code')?.textContent).toContain('\\(literal\\)'); + }); + + it('keeps LaTeX delimiters literal in container-nested fenced code', async () => { + await renderMarkdown( + [ + '> ```tex', + '> \\(blockquote_literal\\)', + '> ```', + '', + '- ~~~tex', + ' \\[list_literal\\]', + ' ~~~', + '', + '> - ````tex', + '> \\(nested_literal\\)', + '> ````', + '', + '10. ```tex', + ' \\(ordered_list_literal\\)', + ' ```', + '', + 'Outside \\(x_i\\) renders.', + ].join('\n') + ); + + expect(container?.querySelectorAll('.katex')).toHaveLength(1); + expect(container?.textContent).toContain('\\(blockquote_literal\\)'); + expect(container?.textContent).toContain('\\[list_literal\\]'); + expect(container?.textContent).toContain('\\(nested_literal\\)'); + expect(container?.textContent).toContain('\\(ordered_list_literal\\)'); + }); + it('does not parse dollars inside code spans or link labels as LaTeX', async () => { await renderMarkdown( [