From 09c6c1371d2bdfd99fb28a315c91afb54d5eb529 Mon Sep 17 00:00:00 2001 From: Fletcher Tian Date: Sun, 26 Jul 2026 14:24:10 +0800 Subject: [PATCH 1/2] fix(cli): handle escaped dollars around inline math --- .../scenarios/markdown-rendering.ts | 2 +- .../cli/src/ui/commands/copyCommand.test.ts | 25 +++ .../ui/utils/InlineMarkdownRenderer.test.tsx | 43 ++++- .../src/ui/utils/InlineMarkdownRenderer.tsx | 160 ++++++++++------- .../cli/src/ui/utils/MarkdownDisplay.test.tsx | 12 +- packages/cli/src/ui/utils/TableRenderer.tsx | 80 +++++---- packages/cli/src/ui/utils/inline-math.test.ts | 31 ++++ packages/cli/src/ui/utils/inline-math.ts | 163 ++++++++++++++++-- .../ui/utils/pending-rendered-height.test.ts | 7 + 9 files changed, 394 insertions(+), 129 deletions(-) diff --git a/integration-tests/terminal-capture/scenarios/markdown-rendering.ts b/integration-tests/terminal-capture/scenarios/markdown-rendering.ts index eea2f94be6a..a54589d8fd0 100644 --- a/integration-tests/terminal-capture/scenarios/markdown-rendering.ts +++ b/integration-tests/terminal-capture/scenarios/markdown-rendering.ts @@ -8,7 +8,7 @@ const markdownPrompt = `Output a compact Markdown rendering verification sample 4. Inline math $x$ and $y = \\\\frac{-b \\\\pm \\\\sqrt{b^2 - 4ac}}{2a}$. 5. One display math block using $$ fences. 6. One checked and one unchecked task list item. -7. Literal inline code \`$zz$\` and escaped math source \\\\$xy$. +7. Literal inline code \`$zz$\`, escaped prose \\\\$xy$, formula $x + \\\\$5$, and the boundaries \\\\$$x^2$ and $x^2\\\\$$. Do not explain the sample.`; diff --git a/packages/cli/src/ui/commands/copyCommand.test.ts b/packages/cli/src/ui/commands/copyCommand.test.ts index 6681c1c4f7b..dd3c89f132d 100644 --- a/packages/cli/src/ui/commands/copyCommand.test.ts +++ b/packages/cli/src/ui/commands/copyCommand.test.ts @@ -573,6 +573,31 @@ describe('copyCommand', () => { }); }); + it('should copy formulas containing or adjacent to escaped dollars', async () => { + if (!copyCommand.action) throw new Error('Command has no action'); + + mockGetHistoryShallow.mockReturnValue([ + { + role: 'model', + parts: [ + { + text: String.raw`literal \$x$ +formula $x + \$5$ +literal then math: \$$x^2$ +math then literal: $x^2\$$`, + }, + ], + }, + ]); + mockCopyToClipboard.mockResolvedValue(undefined); + + const expected = [String.raw`x + \$5`, 'x^2', String.raw`x^2\$`]; + for (const [offset, expression] of expected.entries()) { + await copyCommand.action(mockContext, `inline-latex ${offset + 1}`); + expect(mockCopyToClipboard).toHaveBeenLastCalledWith(expression); + } + }); + it('should copy a numbered inline LaTeX expression with /copy latex inline 1', async () => { if (!copyCommand.action) throw new Error('Command has no action'); diff --git a/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx b/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx index ad769f76c40..02a88765488 100644 --- a/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx +++ b/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx @@ -67,16 +67,44 @@ describe('', () => { expect(output).not.toContain('$x$'); }); - it('preserves escaped inline math and inline code', () => { + it('renders escaped dollars as prose while keeping inline code verbatim', () => { const { lastFrame } = renderWithProviders( - , + , ); expect((lastFrame() ?? '').replace(/\n/g, '')).toContain( - 'Literal \\$xy$ and code$xy$', + 'Literal $xy$ and code$xy$ and \\$xy$', ); }); + it('renders escaped dollars inside and next to inline math', () => { + const text = String.raw`$x^2$ is valid math +it costs $5 and $10 +$5-$10 +literal \$x$ +formula $x + \$5$ +literal then math: \$$x^2$ +math then literal: $x^2\$$`; + const { lastFrame } = renderWithProviders( + , + ); + + const output = (lastFrame() ?? '').replace(/\n/g, ''); + expect(output).toContain('x² is valid math'); + expect(output).toContain('it costs $5 and $10'); + expect(output).toContain('$5-$10'); + expect(output).toContain('literal $x$'); + expect(output).toContain('x + $5'); + expect(output).toContain('literal then math:'); + expect(output).toContain('$x²'); + expect(output).toContain('math then literal:'); + expect(output).toContain('x²$'); + expect(output).not.toContain('\\$'); + }); + it('keeps math literal inside multi-backtick code spans', () => { const { lastFrame } = renderWithProviders( , @@ -101,6 +129,15 @@ describe('', () => { expect(getPlainTextLength('code ``a `$xy$` b``', true)).toBe( 'code a `$xy$` b'.length, ); + expect(getPlainTextLength(String.raw`literal \$x$`, true)).toBe( + 'literal $x$'.length, + ); + expect(getPlainTextLength(String.raw`formula $x + \$5$`, true)).toBe( + 'formula x + $5'.length, + ); + expect(getPlainTextLength(String.raw`even \\$x$`, true)).toBe( + 'even \\x'.length, + ); }); it('does not parse ordinary dollar amounts as inline math', () => { diff --git a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx index 490430348f6..d0984805cbc 100644 --- a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx +++ b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx @@ -24,7 +24,9 @@ import { } from './osc8.js'; import { INLINE_CODE_SPAN_PATTERN_SOURCE, - INLINE_MATH_PATTERN_SOURCE, + mergeInlineMathMatches, + unescapeMarkdownBeforeMath, + unescapeMarkdownDollars, } from './inline-math.js'; // Constants for Markdown parsing @@ -34,17 +36,11 @@ const STRIKETHROUGH_MARKER_LENGTH = 2; // For "~~") const INLINE_CODE_MARKER_LENGTH = 1; // For "`" const UNDERLINE_TAG_START_LENGTH = 3; // For "" const UNDERLINE_TAG_END_LENGTH = 4; // For "" -const INLINE_MATH_MARKER_LENGTH = 1; // For "$" const INLINE_MARKDOWN_REGEX = new RegExp( String.raw`(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|${MD_LINK_PATTERN}|` + String.raw`${INLINE_CODE_SPAN_PATTERN_SOURCE}|.*?<\/u>|https?:\/\/\S+)`, 'g', ); -const INLINE_MARKDOWN_WITH_MATH_REGEX = new RegExp( - String.raw`(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|${MD_LINK_PATTERN}|` + - String.raw`${INLINE_CODE_SPAN_PATTERN_SOURCE}|${INLINE_MATH_PATTERN_SOURCE}|.*?<\/u>|https?:\/\/\S+)`, - 'g', -); const debugLogger = createDebugLogger('INLINE_MARKDOWN'); @@ -62,7 +58,8 @@ const RenderInlineInternal: React.FC = ({ // Early return for plain text without markdown or URLs if ( !/[*_~`<[]|https?:/.test(text) && - !(enableInlineMath && text.includes('$')) + !(enableInlineMath && text.includes('$')) && + !text.includes('\\$') ) { return {text}; } @@ -72,24 +69,39 @@ const RenderInlineInternal: React.FC = ({ // Capability is stable for the duration of a single render — read it once // here so each matched link/URL doesn't re-walk the env-var table. const canHyperlink = supportsHyperlinks(); - const inlineRegex = enableInlineMath - ? INLINE_MARKDOWN_WITH_MATH_REGEX - : INLINE_MARKDOWN_REGEX; - inlineRegex.lastIndex = 0; - let match; + for (const token of mergeInlineMathMatches( + text, + INLINE_MARKDOWN_REGEX, + enableInlineMath, + )) { + const index = + token.kind === 'math' ? token.span.index : (token.match.index ?? 0); - while ((match = inlineRegex.exec(text)) !== null) { - if (match.index > lastIndex) { + if (index > lastIndex) { + const prose = text.slice(lastIndex, index); nodes.push( - {text.slice(lastIndex, match.index)} + {token.kind === 'math' + ? unescapeMarkdownBeforeMath(prose) + : unescapeMarkdownDollars(prose)} , ); } + if (token.kind === 'math') { + nodes.push( + + {renderInlineLatex(unescapeMarkdownDollars(token.span.content))} + , + ); + lastIndex = index + token.span.raw.length; + continue; + } + + const match = token.match; const fullMatch = match[0]; let renderedNode: React.ReactNode = null; - const key = `m-${match.index}`; + const key = `m-${index}`; try { if ( @@ -99,25 +111,35 @@ const RenderInlineInternal: React.FC = ({ ) { renderedNode = ( - {fullMatch.slice(BOLD_MARKER_LENGTH, -BOLD_MARKER_LENGTH)} + {unescapeMarkdownDollars( + fullMatch.slice(BOLD_MARKER_LENGTH, -BOLD_MARKER_LENGTH), + )} ); } else if ( fullMatch.length > ITALIC_MARKER_LENGTH * 2 && ((fullMatch.startsWith('*') && fullMatch.endsWith('*')) || (fullMatch.startsWith('_') && fullMatch.endsWith('_'))) && - !/\w/.test(text.substring(match.index - 1, match.index)) && + !/\w/.test(text.substring(index - 1, index)) && !/\w/.test( - text.substring(inlineRegex.lastIndex, inlineRegex.lastIndex + 1), + text.substring( + index + fullMatch.length, + index + fullMatch.length + 1, + ), ) && - !/\S[./\\]/.test(text.substring(match.index - 2, match.index)) && + !/\S[./\\]/.test(text.substring(index - 2, index)) && !/[./\\]\S/.test( - text.substring(inlineRegex.lastIndex, inlineRegex.lastIndex + 2), + text.substring( + index + fullMatch.length, + index + fullMatch.length + 2, + ), ) ) { renderedNode = ( - {fullMatch.slice(ITALIC_MARKER_LENGTH, -ITALIC_MARKER_LENGTH)} + {unescapeMarkdownDollars( + fullMatch.slice(ITALIC_MARKER_LENGTH, -ITALIC_MARKER_LENGTH), + )} ); } else if ( @@ -127,9 +149,11 @@ const RenderInlineInternal: React.FC = ({ ) { renderedNode = ( - {fullMatch.slice( - STRIKETHROUGH_MARKER_LENGTH, - -STRIKETHROUGH_MARKER_LENGTH, + {unescapeMarkdownDollars( + fullMatch.slice( + STRIKETHROUGH_MARKER_LENGTH, + -STRIKETHROUGH_MARKER_LENGTH, + ), )} ); @@ -173,7 +197,10 @@ const RenderInlineInternal: React.FC = ({ // `(url)` suffix) when OSC 8 is active. The legacy `label (url)` // branch leaves both intact so today's unsupported-terminal // output stays byte-identical. - const safeLabel = wrapOsc8 ? sanitizeForOsc(linkText) : linkText; + const renderedLinkText = unescapeMarkdownDollars(linkText); + const safeLabel = wrapOsc8 + ? sanitizeForOsc(renderedLinkText) + : renderedLinkText; const safeUrl = wrapOsc8 ? sanitizeForOsc(url) : url; // Keep the `(url)` suffix visible when the label itself looks // like a (mismatched) URL — pre-OSC-8 rendering always showed the @@ -193,7 +220,7 @@ const RenderInlineInternal: React.FC = ({ ) : ( - {linkText} + {renderedLinkText} ({url}) ); @@ -206,24 +233,10 @@ const RenderInlineInternal: React.FC = ({ ) { renderedNode = ( - {fullMatch.slice( - UNDERLINE_TAG_START_LENGTH, - -UNDERLINE_TAG_END_LENGTH, - )} - - ); - } else if ( - enableInlineMath && - fullMatch.startsWith('$') && - fullMatch.endsWith('$') && - fullMatch.length > INLINE_MATH_MARKER_LENGTH * 2 - ) { - renderedNode = ( - - {renderInlineLatex( + {unescapeMarkdownDollars( fullMatch.slice( - INLINE_MATH_MARKER_LENGTH, - -INLINE_MATH_MARKER_LENGTH, + UNDERLINE_TAG_START_LENGTH, + -UNDERLINE_TAG_END_LENGTH, ), )} @@ -252,12 +265,20 @@ const RenderInlineInternal: React.FC = ({ renderedNode = null; } - nodes.push(renderedNode ?? {fullMatch}); - lastIndex = inlineRegex.lastIndex; + nodes.push( + renderedNode ?? ( + {unescapeMarkdownDollars(fullMatch)} + ), + ); + lastIndex = index + fullMatch.length; } if (lastIndex < text.length) { - nodes.push({text.slice(lastIndex)}); + nodes.push( + + {unescapeMarkdownDollars(text.slice(lastIndex))} + , + ); } return <>{nodes.filter((node) => node !== null)}; @@ -273,35 +294,42 @@ export const getPlainTextLength = ( text: string, enableInlineMath = false, ): number => { - const inlineRegex = enableInlineMath - ? INLINE_MARKDOWN_WITH_MATH_REGEX - : INLINE_MARKDOWN_REGEX; - inlineRegex.lastIndex = 0; let normalizedText = ''; let lastIndex = 0; - let match; - while ((match = inlineRegex.exec(text)) !== null) { - normalizedText += text.slice(lastIndex, match.index); + for (const token of mergeInlineMathMatches( + text, + INLINE_MARKDOWN_REGEX, + enableInlineMath, + )) { + const index = + token.kind === 'math' ? token.span.index : (token.match.index ?? 0); + const prose = text.slice(lastIndex, index); + normalizedText += + token.kind === 'math' + ? unescapeMarkdownBeforeMath(prose) + : unescapeMarkdownDollars(prose); + + if (token.kind === 'math') { + normalizedText += renderInlineLatex( + unescapeMarkdownDollars(token.span.content), + ); + lastIndex = index + token.span.raw.length; + continue; + } + + const match = token.match; const fullMatch = match[0]; const codeMatch = fullMatch.match(/^(`+)(.+?)\1$/s); if (codeMatch?.[2]) { normalizedText += codeMatch[2]; - } else if ( - enableInlineMath && - fullMatch.startsWith('$') && - fullMatch.endsWith('$') - ) { - normalizedText += renderInlineLatex( - fullMatch.slice(INLINE_MATH_MARKER_LENGTH, -INLINE_MATH_MARKER_LENGTH), - ); } else { - normalizedText += fullMatch; + normalizedText += unescapeMarkdownDollars(fullMatch); } - lastIndex = inlineRegex.lastIndex; + lastIndex = index + fullMatch.length; } - normalizedText += text.slice(lastIndex); + normalizedText += unescapeMarkdownDollars(text.slice(lastIndex)); const cleanText = normalizedText .replace(/\*\*(.*?)\*\*/g, '$1') diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx index 45c32675c91..c18c9a90b7d 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx @@ -1134,11 +1134,11 @@ Another paragraph. expect(output).not.toContain('$\\alpha$'); }); - it('renders table math while preserving escaped and code-span math', () => { + it('renders escaped dollars in table prose and keeps code spans verbatim', () => { const text = ` -| Formula | Literal | Code | -|---------|---------|------| -| $x$ | \\$xy$ | \`\`a \`$zz$\` b\`\` | +| Formula | Literal | Code | Mixed | +|---------|---------|------|-------| +| $x$ | \\$xy$ | \`\`a \`$zz$\` b\`\` | $x + \\$5$ | `.replace(/\n/g, eol); const { lastFrame } = renderWithProviders( , @@ -1146,8 +1146,10 @@ Another paragraph. const output = stripAnsi(lastFrame() ?? ''); expect(output).toContain('│ x'); - expect(output).toContain('\\$xy$'); + expect(output).toContain('$xy$'); + expect(output).not.toContain('\\$xy$'); expect(output).toContain('a `$zz$` b'); + expect(output).toContain('x + $5'); expect(output).not.toContain('$x$'); }); diff --git a/packages/cli/src/ui/utils/TableRenderer.tsx b/packages/cli/src/ui/utils/TableRenderer.tsx index 1234f5b3e67..6ab6c1f0818 100644 --- a/packages/cli/src/ui/utils/TableRenderer.tsx +++ b/packages/cli/src/ui/utils/TableRenderer.tsx @@ -14,7 +14,9 @@ import { theme } from '../semantic-colors.js'; import { renderInlineLatex } from './latexRenderer.js'; import { INLINE_CODE_SPAN_PATTERN_SOURCE, - INLINE_MATH_PATTERN_SOURCE, + mergeInlineMathMatches, + unescapeMarkdownBeforeMath, + unescapeMarkdownDollars, } from './inline-math.js'; import { MD_LINK_CAPTURE, @@ -51,11 +53,6 @@ const INLINE_MARKDOWN_REGEX = new RegExp( String.raw`${INLINE_CODE_SPAN_PATTERN_SOURCE}|.*?<\/u>|https?:\/\/\S+)`, 'g', ); -const INLINE_MARKDOWN_WITH_MATH_REGEX = new RegExp( - String.raw`(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|${MD_LINK_PATTERN}|` + - String.raw`${INLINE_CODE_SPAN_PATTERN_SOURCE}|${INLINE_MATH_PATTERN_SOURCE}|.*?<\/u>|https?:\/\/\S+)`, - 'g', -); export type ColumnAlign = 'left' | 'center' | 'right'; @@ -253,21 +250,36 @@ const ansiFmt = { * Mirrors RenderInline's behavior but outputs strings instead of React nodes. */ function renderMarkdownToAnsi(text: string, enableInlineMath = false): string { - const inlineRegex = enableInlineMath - ? INLINE_MARKDOWN_WITH_MATH_REGEX - : INLINE_MARKDOWN_REGEX; - inlineRegex.lastIndex = 0; - // Capability is stable for the duration of one cell render — read it once // here instead of per matched token. const canHyperlink = supportsHyperlinks(); let result = ''; let lastIndex = 0; - let match; - while ((match = inlineRegex.exec(text)) !== null) { - result += text.slice(lastIndex, match.index); + for (const token of mergeInlineMathMatches( + text, + INLINE_MARKDOWN_REGEX, + enableInlineMath, + )) { + const index = + token.kind === 'math' ? token.span.index : (token.match.index ?? 0); + const prose = text.slice(lastIndex, index); + result += + token.kind === 'math' + ? unescapeMarkdownBeforeMath(prose) + : unescapeMarkdownDollars(prose); + + if (token.kind === 'math') { + result += applyColor( + renderInlineLatex(unescapeMarkdownDollars(token.span.content)), + theme.text.accent, + ); + lastIndex = index + token.span.raw.length; + continue; + } + + const match = token.match; const fullMatch = match[0]!; let rendered: string | null = null; @@ -276,27 +288,31 @@ function renderMarkdownToAnsi(text: string, enableInlineMath = false): string { fullMatch.endsWith('**') && fullMatch.length > 4 ) { - rendered = ansiFmt.bold(fullMatch.slice(2, -2)); + rendered = ansiFmt.bold(unescapeMarkdownDollars(fullMatch.slice(2, -2))); } else if ( fullMatch.length > 2 && ((fullMatch.startsWith('*') && fullMatch.endsWith('*')) || (fullMatch.startsWith('_') && fullMatch.endsWith('_'))) && - !/\w/.test(text.substring(match.index - 1, match.index)) && + !/\w/.test(text.substring(index - 1, index)) && !/\w/.test( - text.substring(inlineRegex.lastIndex, inlineRegex.lastIndex + 1), + text.substring(index + fullMatch.length, index + fullMatch.length + 1), ) && - !/\S[./\\]/.test(text.substring(match.index - 2, match.index)) && + !/\S[./\\]/.test(text.substring(index - 2, index)) && !/[./\\]\S/.test( - text.substring(inlineRegex.lastIndex, inlineRegex.lastIndex + 2), + text.substring(index + fullMatch.length, index + fullMatch.length + 2), ) ) { - rendered = ansiFmt.italic(fullMatch.slice(1, -1)); + rendered = ansiFmt.italic( + unescapeMarkdownDollars(fullMatch.slice(1, -1)), + ); } else if ( fullMatch.startsWith('~~') && fullMatch.endsWith('~~') && fullMatch.length > 4 ) { - rendered = ansiFmt.strikethrough(fullMatch.slice(2, -2)); + rendered = ansiFmt.strikethrough( + unescapeMarkdownDollars(fullMatch.slice(2, -2)), + ); } else if ( fullMatch.startsWith('`') && fullMatch.endsWith('`') && @@ -313,7 +329,7 @@ function renderMarkdownToAnsi(text: string, enableInlineMath = false): string { ) { const linkMatch = fullMatch.match(MD_LINK_CAPTURE); if (linkMatch) { - const labelText = linkMatch[1] ?? ''; + const labelText = unescapeMarkdownDollars(linkMatch[1] ?? ''); const url = linkMatch[2] ?? ''; // When OSC 8 wraps, show only the label — long URLs in narrow // table cells were the worst offender for layout cluttering, so @@ -344,22 +360,14 @@ function renderMarkdownToAnsi(text: string, enableInlineMath = false): string { rendered = `${labelText} ${applyColor(`(${url})`, theme.text.link)}`; } } - } else if ( - enableInlineMath && - fullMatch.startsWith('$') && - fullMatch.endsWith('$') && - fullMatch.length > 2 - ) { - rendered = applyColor( - renderInlineLatex(fullMatch.slice(1, -1)), - theme.text.accent, - ); } else if ( fullMatch.startsWith('') && fullMatch.endsWith('') && fullMatch.length > 7 ) { - rendered = ansiFmt.underline(fullMatch.slice(3, -4)); + rendered = ansiFmt.underline( + unescapeMarkdownDollars(fullMatch.slice(3, -4)), + ); } else if (/^https?:\/\//.test(fullMatch)) { const visible = applyColor(fullMatch, theme.text.link); if (canHyperlink) { @@ -372,11 +380,11 @@ function renderMarkdownToAnsi(text: string, enableInlineMath = false): string { } } - result += rendered ?? fullMatch; - lastIndex = inlineRegex.lastIndex; + result += rendered ?? unescapeMarkdownDollars(fullMatch); + lastIndex = index + fullMatch.length; } - result += text.slice(lastIndex); + result += unescapeMarkdownDollars(text.slice(lastIndex)); return result; } diff --git a/packages/cli/src/ui/utils/inline-math.test.ts b/packages/cli/src/ui/utils/inline-math.test.ts index 82e8334e6ae..26035aaa40c 100644 --- a/packages/cli/src/ui/utils/inline-math.test.ts +++ b/packages/cli/src/ui/utils/inline-math.test.ts @@ -9,6 +9,8 @@ import { findInlineMathExpressions, INLINE_MATH_MAX_CHARS, readInlineMathSpanAt, + unescapeMarkdownBeforeMath, + unescapeMarkdownDollars, } from './inline-math.js'; describe('inline math recognition', () => { @@ -30,6 +32,27 @@ describe('inline math recognition', () => { expect(findInlineMathExpressions(String.raw`Total $a b\$ end`)).toEqual([]); }); + it('recognizes literal dollars inside and next to formulas', () => { + expect(findInlineMathExpressions(String.raw`Formula $x + \$5$`)).toEqual([ + String.raw`x + \$5`, + ]); + expect( + findInlineMathExpressions(String.raw`Literal then math: \$$x^2$`), + ).toEqual(['x^2']); + expect( + findInlineMathExpressions(String.raw`Math then literal: $x^2\$$`), + ).toEqual([String.raw`x^2\$`]); + }); + + it('uses the full backslash-run parity around delimiters', () => { + expect(findInlineMathExpressions(String.raw`Odd \$x$; even \\$y$`)).toEqual( + ['y'], + ); + expect(findInlineMathExpressions(String.raw`Odd $x\$; even $y\\$`)).toEqual( + [String.raw`y\\`], + ); + }); + it('ignores inline code spans and unclosed formulas', () => { expect(findInlineMathExpressions('Use `$xy$` then $z$ and $open')).toEqual([ 'z', @@ -53,5 +76,13 @@ describe('inline math recognition', () => { it('reads a span only at the requested offset', () => { expect(readInlineMathSpanAt('A $x$ B', 2)).toBe('$x$'); expect(readInlineMathSpanAt(String.raw`A \$x$ B`, 3)).toBeNull(); + expect(readInlineMathSpanAt(String.raw`\$$x$`, 2)).toBe('$x$'); + }); + + it('removes Markdown escaping from literal dollars by parity', () => { + expect(unescapeMarkdownDollars(String.raw`\$ \\$ \\\$`)).toBe( + String.raw`$ \$ \$`, + ); + expect(unescapeMarkdownBeforeMath(String.raw`prefix \\`)).toBe('prefix \\'); }); }); diff --git a/packages/cli/src/ui/utils/inline-math.ts b/packages/cli/src/ui/utils/inline-math.ts index 678c5deef7a..e599cd2ce65 100644 --- a/packages/cli/src/ui/utils/inline-math.ts +++ b/packages/cli/src/ui/utils/inline-math.ts @@ -6,39 +6,166 @@ export const INLINE_MATH_MAX_CHARS = 1024; -export const INLINE_MATH_PATTERN_SOURCE = String.raw`(?\`+)(?!\`).+?(?(?!\`)`; const INLINE_CODE_SPAN_RE = new RegExp(INLINE_CODE_SPAN_PATTERN_SOURCE, 'g'); +export interface InlineMathSpan { + content: string; + index: number; + raw: string; +} + +export type InlineToken = + | { kind: 'markup'; match: RegExpMatchArray } + | { kind: 'math'; span: InlineMathSpan }; + +function isEscapedAt(text: string, index: number): boolean { + let slashCount = 0; + for ( + let cursor = index - 1; + cursor >= 0 && text[cursor] === '\\'; + cursor -= 1 + ) { + slashCount += 1; + } + return slashCount % 2 === 1; +} + +function isUnescapedDollarAt(text: string, index: number): boolean { + return text[index] === '$' && !isEscapedAt(text, index); +} + +export function unescapeMarkdownDollars(text: string): string { + return text.replace( + /(\\+)\$/g, + (_match, slashes: string) => + `${'\\'.repeat(Math.floor(slashes.length / 2))}$`, + ); +} + +export function unescapeMarkdownBeforeMath(text: string): string { + return unescapeMarkdownDollars(`${text}$`).slice(0, -1); +} + +// Escape state depends on the parity of the complete backslash run, and an +// escaped dollar inside math must be skipped while looking for the real closer. +function readInlineMathSpan( + text: string, + index: number, +): InlineMathSpan | null { + if (!isUnescapedDollarAt(text, index)) { + return null; + } + + const previous = text[index - 1] ?? ''; + const next = text[index + 1] ?? ''; + if ( + /\w/.test(previous) || + isUnescapedDollarAt(text, index - 1) || + !next || + /[\s\d$]/.test(next) + ) { + return null; + } + + let closingIndex = index + 1; + while ( + closingIndex < text.length && + text[closingIndex] !== '\n' && + closingIndex - index - 1 <= INLINE_MATH_MAX_CHARS && + !isUnescapedDollarAt(text, closingIndex) + ) { + closingIndex += 1; + } + + if (!isUnescapedDollarAt(text, closingIndex)) { + return null; + } + + const content = text.slice(index + 1, closingIndex); + const following = text[closingIndex + 1] ?? ''; + if ( + content.length > INLINE_MATH_MAX_CHARS || + /\s$/.test(content) || + /[\w$]/.test(following) + ) { + return null; + } + + return { + content, + index, + raw: text.slice(index, closingIndex + 1), + }; +} + +export function findNextInlineMath( + text: string, + fromIndex = 0, +): InlineMathSpan | null { + for (let index = fromIndex; index < text.length; index += 1) { + const span = readInlineMathSpan(text, index); + if (span) { + return span; + } + } + return null; +} + +export function* mergeInlineMathMatches( + text: string, + markupRegex: RegExp, + enableInlineMath = true, +): Generator { + markupRegex.lastIndex = 0; + const markupMatches = text.matchAll(markupRegex); + let nextMarkup = markupMatches.next(); + let nextMath = enableInlineMath ? findNextInlineMath(text) : null; + let cursor = 0; + + while (!nextMarkup.done || nextMath) { + while (!nextMarkup.done && (nextMarkup.value.index ?? 0) < cursor) { + nextMarkup = markupMatches.next(); + } + + if (nextMath && nextMath.index < cursor) { + nextMath = findNextInlineMath(text, cursor); + } + + const markupIndex = nextMarkup.done + ? Number.POSITIVE_INFINITY + : (nextMarkup.value.index ?? 0); + const mathIndex = nextMath?.index ?? Number.POSITIVE_INFINITY; + + if (!nextMarkup.done && markupIndex <= mathIndex) { + const match = nextMarkup.value; + yield { kind: 'markup', match }; + cursor = markupIndex + match[0].length; + nextMarkup = markupMatches.next(); + } else if (nextMath) { + yield { kind: 'math', span: nextMath }; + cursor = mathIndex + nextMath.raw.length; + nextMath = findNextInlineMath(text, cursor); + } + } +} + export function findInlineMathExpressions(text: string): string[] { - const codeRanges = [...text.matchAll(INLINE_CODE_SPAN_RE)].map((match) => ({ - start: match.index, - end: match.index + match[0].length, - })); - const mathRegex = new RegExp(INLINE_MATH_PATTERN_SOURCE, 'g'); const expressions: string[] = []; - for (const match of text.matchAll(mathRegex)) { - const start = match.index; - const raw = match[0]; - const end = start + raw.length; - if (codeRanges.some((range) => start >= range.start && end <= range.end)) { - continue; + for (const token of mergeInlineMathMatches(text, INLINE_CODE_SPAN_RE)) { + if (token.kind === 'math') { + expressions.push(token.span.content); } - expressions.push(raw.slice(1, -1)); } return expressions; } -const INLINE_MATH_AT_RE = new RegExp(INLINE_MATH_PATTERN_SOURCE, 'y'); - export function readInlineMathSpanAt( text: string, index: number, ): string | null { - INLINE_MATH_AT_RE.lastIndex = index; - return INLINE_MATH_AT_RE.exec(text)?.[0] ?? null; + return readInlineMathSpan(text, index)?.raw ?? null; } diff --git a/packages/cli/src/ui/utils/pending-rendered-height.test.ts b/packages/cli/src/ui/utils/pending-rendered-height.test.ts index 063c76373bd..79a06968635 100644 --- a/packages/cli/src/ui/utils/pending-rendered-height.test.ts +++ b/packages/cli/src/ui/utils/pending-rendered-height.test.ts @@ -42,6 +42,13 @@ describe('splitMarkdownTableRow', () => { 'c', ]); }); + + it('keeps pipes inside math that contains an escaped dollar', () => { + expect(splitMarkdownTableRow(String.raw`$x + \$5|y$ | c`)).toEqual([ + String.raw`$x + \$5|y$`, + 'c', + ]); + }); }); describe('estimateWrappedRows', () => { From cdb897efc565fbf8aa30dccbd4ac0f3f9f2ec4ea Mon Sep 17 00:00:00 2001 From: Fletcher Tian Date: Sun, 26 Jul 2026 15:03:58 +0800 Subject: [PATCH 2/2] test(cli): add escaped-dollar TUI capture scenario --- .../scenarios/inline-math-escaped-dollar.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 integration-tests/terminal-capture/scenarios/inline-math-escaped-dollar.ts diff --git a/integration-tests/terminal-capture/scenarios/inline-math-escaped-dollar.ts b/integration-tests/terminal-capture/scenarios/inline-math-escaped-dollar.ts new file mode 100644 index 00000000000..7ff783113f9 --- /dev/null +++ b/integration-tests/terminal-capture/scenarios/inline-math-escaped-dollar.ts @@ -0,0 +1,32 @@ +import type { ScenarioConfig } from '../scenario-runner.js'; + +const prompt = String.raw`请只输出以下 7 行测试文本,不要解释,不要编号,不要使用代码块,也不要改变任何字符。尤其要原样保留所有美元符号和反斜杠: +$x^2$ is valid math +it costs $5 and $10 +$5-$10 +literal \$x$ +formula $x + \$5$ +literal then math: \$$x^2$ +math then literal: $x^2\$$`; + +export default { + name: 'inline-math-escaped-dollar', + spawn: ['node', 'dist/cli.js', '--yolo', '--model', 'qwen3.7-max-2026-06-08'], + terminal: { + title: 'qwen-code inline math', + cwd: '../../..', + cols: 120, + rows: 32, + }, + flow: [ + { + type: prompt, + capture: 'inline-math-escaped-dollar.png', + }, + { + sleep: 20000, + capture: 'inline-math-escaped-dollar-settled.png', + }, + ], + gif: false, +} satisfies ScenarioConfig;