From 6e8b89cc8fc814db69c0bf0620090b67db34ff5d Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Fri, 4 Sep 2026 13:14:49 +0000 Subject: [PATCH 1/3] fix(components): render TeX bracket delimiters Model: GPT-5 --- .../components/ai-gui/markdown-renderer.tsx | 8 +- .../src/lib/markdown-single-dollar-math.ts | 197 ++++++++++++++++++ .../tests/markdown-math-delimiters.test.ts | 43 ++++ .../tests/markdown-streaming-reparse.test.ts | 39 ++++ 4 files changed, 285 insertions(+), 2 deletions(-) create mode 100644 packages/components/tests/markdown-math-delimiters.test.ts 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..d301171dd 100644 --- a/packages/components/src/lib/markdown-single-dollar-math.ts +++ b/packages/components/src/lib/markdown-single-dollar-math.ts @@ -15,6 +15,203 @@ type InlineMathNode = MdastNode & { }; }; +type TexMathDelimiter = { + kind: 'inline' | 'display'; + index: number; +}; + +type MarkdownFence = { + 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 markdownFenceAt = (value: string, lineStart: number): MarkdownFence | null => { + const contentEnd = lineContentEnd(value, lineStart); + let cursor = lineStart; + + while (cursor < contentEnd && cursor - lineStart < 3 && value[cursor] === ' ') { + cursor += 1; + } + + 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 { marker, size }; +}; + +const isClosingMarkdownFence = ( + value: string, + lineStart: number, + fence: MarkdownFence +): boolean => { + const contentEnd = lineContentEnd(value, lineStart); + let cursor = lineStart; + + while (cursor < contentEnd && cursor - lineStart < 3 && value[cursor] === ' ') { + cursor += 1; + } + + 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/fences 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 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..8617f01e3 --- /dev/null +++ b/packages/components/tests/markdown-math-delimiters.test.ts @@ -0,0 +1,43 @@ +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') + ); + }); +}); diff --git a/packages/components/tests/markdown-streaming-reparse.test.ts b/packages/components/tests/markdown-streaming-reparse.test.ts index cc33be97d..9eb92a0cb 100644 --- a/packages/components/tests/markdown-streaming-reparse.test.ts +++ b/packages/components/tests/markdown-streaming-reparse.test.ts @@ -380,6 +380,45 @@ 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('does not parse dollars inside code spans or link labels as LaTeX', async () => { await renderMarkdown( [ From 0bfd558033be41d2d17a488a6d1da21422c6db41 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Fri, 4 Sep 2026 15:03:32 +0000 Subject: [PATCH 2/3] fix(components): preserve nested fenced math Model: GPT-5 --- .../src/lib/markdown-single-dollar-math.ts | 100 ++++++++++++++++-- .../tests/markdown-math-delimiters.test.ts | 44 ++++++++ .../tests/markdown-streaming-reparse.test.ts | 30 ++++++ 3 files changed, 166 insertions(+), 8 deletions(-) diff --git a/packages/components/src/lib/markdown-single-dollar-math.ts b/packages/components/src/lib/markdown-single-dollar-math.ts index d301171dd..c86ef0d61 100644 --- a/packages/components/src/lib/markdown-single-dollar-math.ts +++ b/packages/components/src/lib/markdown-single-dollar-math.ts @@ -20,7 +20,15 @@ type TexMathDelimiter = { index: number; }; +type MarkdownContainer = + | { kind: 'blockquote' } + | { + kind: 'indent'; + size: number; + }; + type MarkdownFence = { + containers: MarkdownContainer[]; marker: '`' | '~'; size: number; }; @@ -36,12 +44,59 @@ const lineContentEnd = (value: string, lineStart: number): number => { return end > lineStart && value[end - 1] === '\r' ? end - 1 : end; }; +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 && cursor - lineStart < 3 && value[cursor] === ' ') { - cursor += 1; + 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]; @@ -55,7 +110,33 @@ const markdownFenceAt = (value: string, lineStart: number): MarkdownFence | null // A backtick fence cannot contain another backtick in its info string. if (marker === '`' && value.slice(runEnd, contentEnd).includes('`')) return null; - return { marker, size }; + 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 = ( @@ -64,11 +145,14 @@ const isClosingMarkdownFence = ( fence: MarkdownFence ): boolean => { const contentEnd = lineContentEnd(value, lineStart); - let cursor = lineStart; - - while (cursor < contentEnd && cursor - lineStart < 3 && value[cursor] === ' ') { - cursor += 1; - } + 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; diff --git a/packages/components/tests/markdown-math-delimiters.test.ts b/packages/components/tests/markdown-math-delimiters.test.ts index 8617f01e3..195a207ca 100644 --- a/packages/components/tests/markdown-math-delimiters.test.ts +++ b/packages/components/tests/markdown-math-delimiters.test.ts @@ -40,4 +40,48 @@ describe('normalizeTexMathDelimiters', () => { ['`\\(inline\\)`', '', '~~~tex', '\\[', 'display', '\\]', '~~~', '', '$$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 9eb92a0cb..3d4f55480 100644 --- a/packages/components/tests/markdown-streaming-reparse.test.ts +++ b/packages/components/tests/markdown-streaming-reparse.test.ts @@ -419,6 +419,36 @@ describe('MarkdownRenderer streaming rendering', () => { expect(container?.textContent).toContain('\\[fenced_code\\]'); }); + 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( [ From 8c1c801487cf63588e10bd76dbf0b35fcb7ed010 Mon Sep 17 00:00:00 2001 From: Zixuan Chen Date: Fri, 4 Sep 2026 15:18:35 +0000 Subject: [PATCH 3/3] fix(components): preserve indented TeX code Model: GPT-5 --- .../src/lib/markdown-single-dollar-math.ts | 30 ++++++++++++++++++- .../tests/markdown-math-delimiters.test.ts | 10 +++++++ .../tests/markdown-streaming-reparse.test.ts | 7 +++++ 3 files changed, 46 insertions(+), 1 deletion(-) diff --git a/packages/components/src/lib/markdown-single-dollar-math.ts b/packages/components/src/lib/markdown-single-dollar-math.ts index c86ef0d61..ade3457fb 100644 --- a/packages/components/src/lib/markdown-single-dollar-math.ts +++ b/packages/components/src/lib/markdown-single-dollar-math.ts @@ -44,6 +44,27 @@ const lineContentEnd = (value: string, lineStart: number): number => { 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; @@ -209,7 +230,7 @@ const slashRunLength = (value: string, start: number): number => { * 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/fences are rewritten. Each + * 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. */ @@ -221,6 +242,13 @@ export const normalizeTexMathDelimiters = (value: string): string => { 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; diff --git a/packages/components/tests/markdown-math-delimiters.test.ts b/packages/components/tests/markdown-math-delimiters.test.ts index 195a207ca..3e92e5cb8 100644 --- a/packages/components/tests/markdown-math-delimiters.test.ts +++ b/packages/components/tests/markdown-math-delimiters.test.ts @@ -41,6 +41,16 @@ describe('normalizeTexMathDelimiters', () => { ); }); + 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', diff --git a/packages/components/tests/markdown-streaming-reparse.test.ts b/packages/components/tests/markdown-streaming-reparse.test.ts index 3d4f55480..5838ea6a3 100644 --- a/packages/components/tests/markdown-streaming-reparse.test.ts +++ b/packages/components/tests/markdown-streaming-reparse.test.ts @@ -419,6 +419,13 @@ describe('MarkdownRenderer streaming rendering', () => { 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( [