Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -1113,6 +1116,7 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({
const openDiagramLabel = t('sessions.diagramViewer.open', 'Open diagram');
const [diagramSelection, setDiagramSelection] = useState<MermaidDiagramSelection | null>(null);
const hasMermaidBlock = useMemo(() => MERMAID_FENCE_PATTERN.test(text), [text]);
const normalizedText = useMemo(() => normalizeTexMathDelimiters(text), [text]);

const closeDiagram = useCallback(() => setDiagramSelection(null), []);

Expand Down Expand Up @@ -1380,7 +1384,7 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({
translations={streamdownTranslations}
urlTransform={markdownUrlTransform}
>
{text}
{normalizedText}
</Streamdown>
</div>
{/* A sibling of the markdown, not a child: a portal's events bubble
Expand Down
309 changes: 309 additions & 0 deletions packages/components/src/lib/markdown-single-dollar-math.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Normalize math in list continuation paragraphs

When a normal list continuation uses four root-relative spaces, such as - explanation\n \(x\), CommonMark removes the list item's two-space content indent and treats the remainder as paragraph indentation, not an indented code block. This absolute-column check nevertheless skips the entire line, so TeX delimiters in commonly formatted list content remain literal; account for the active container indentation before applying the four-column code rule.

Useful? React with 👍 / 👎.

}

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);
Comment on lines +244 to +255

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve delimiters in nested fenced code

Handle fenced blocks inside Markdown containers before rewriting delimiters. For example, in > ```tex\n> \(literal\)\n> ````, each line starts with >, so markdownFenceAtnever recognizes the fence and the code payload is changed to$$literal$$; list-nested fences fail similarly. Because MarkdownRenderer` also renders file previews, task bodies, and skill content, this visibly corrupts displayed and copied code rather than merely changing math rendering.

Useful? React with 👍 / 👎.

Comment on lines +244 to +255

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve delimiters in indented code blocks

When Markdown uses a standard four-space-indented code block, such as \(literal\), markdownFenceAt returns null because there is no fence, and the subsequent scan rewrites the displayed and copied source to $$literal$$. Fresh evidence beyond the earlier nested-fence report is this non-fenced CommonMark code form; the normalizer needs to skip indented code blocks as well as fenced blocks and code spans.

Useful? React with 👍 / 👎.

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;
Comment on lines +307 to +311

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve escaped parentheses in Markdown destinations

When a link or image destination contains Markdown-escaped balanced parentheses, for example [open](src/report\(final\).md), this scan rewrites the destination to src/report$$final$$.md before Streamdown parses it. The agent-file link remains syntactically file-like, so clicking it passes the altered href to the open-file path and targets a nonexistent filename; exclude Markdown destinations from delimiter normalization.

AGENTS.md reference: packages/components/src/lib/AGENTS.md:L31-L40

Useful? React with 👍 / 👎.

}

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',
Expand Down
Loading
Loading