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 @@ -5,9 +5,10 @@ const markdownPrompt = `Output a compact Markdown rendering verification sample
1. A mermaid flowchart fenced code block with a branch and a loop.
2. A mermaid sequenceDiagram fenced code block.
3. A markdown table with two rows.
4. Inline math $x = \\\\frac{-b \\\\pm \\\\sqrt{b^2 - 4ac}}{2a}$.
4. Inline math $x$ and $y = \\\\frac{-b \\\\pm \\\\sqrt{b^2 - 4ac}}{2a}$.
5. One display math block using $$ fences.
Comment on lines +8 to 9

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Suggestion] This scenario adds valuable inline-math content (items 4–5, 7), but no CI workflow or npm test command collects the terminal-capture scenarios — the only entry point is the manual capture:markdown-rendering script. If the rendering or /copy latex integration for math regresses, this scenario silently produces stale screenshots without any automated gate catching it.

Concrete cost: the three bugs this PR fixes (single-char $x$, escaped \$xy$, code-span `$xy$`) now have new scenario assertions that will never run in CI.

Consider either adding a CI step that runs capture:markdown-rendering (or the whole terminal-capture suite), or adding an assertion-based integration test for the math rendering + /copy latex flow in a vitest-collected file.

— qwen3.7-max via Qwen Code /review

6. One checked and one unchecked task list item.
7. Literal inline code \`$zz$\` and escaped math source \\\\$xy$.

Do not explain the sample.`;

Expand Down
32 changes: 32 additions & 0 deletions packages/cli/src/ui/commands/copyCommand.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,38 @@ describe('copyCommand', () => {
});
});

it('should copy single-character inline math and skip escaped/code spans', async () => {
if (!copyCommand.action) throw new Error('Command has no action');

mockGetHistoryShallow.mockReturnValue([
{
role: 'model',
parts: [
{
text: 'Literal \\$xy$, code `$xy$`, longer ``a `$zz$` b``, then $x$ and $\\alpha$.',
},
],
},
]);
mockCopyToClipboard.mockResolvedValue(undefined);

const first = await copyCommand.action(mockContext, 'inline-latex 1');
expect(mockCopyToClipboard).toHaveBeenLastCalledWith('x');
expect(first).toEqual({
type: 'message',
messageType: 'info',
content: 'Inline LaTeX expression 1 copied to the clipboard',
});

const second = await copyCommand.action(mockContext, 'inline-latex 2');
expect(mockCopyToClipboard).toHaveBeenLastCalledWith('\\alpha');
expect(second).toEqual({
type: 'message',
messageType: 'info',
content: 'Inline LaTeX expression 2 copied to the clipboard',
});
});

it('should copy a numbered inline LaTeX expression with /copy latex inline 1', async () => {
if (!copyCommand.action) throw new Error('Command has no action');

Expand Down
20 changes: 6 additions & 14 deletions packages/cli/src/ui/commands/copyCommand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { copyToClipboard } from '../utils/commandUtils.js';
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
import { CommandKind } from './types.js';
import { t } from '../../i18n/index.js';
import { findInlineMathExpressions } from '../utils/inline-math.js';

interface FencedCodeBlock {
lang: string | null;
Expand Down Expand Up @@ -41,12 +42,6 @@ interface SelectedInlineLatexExpression {
label: string;
}

const INLINE_MATH_MAX_CHARS = 1024;
const INLINE_MATH_REGEX = new RegExp(
String.raw`(?<![\w$])\$(?![\s\d$])(?=[^$\n]{1,${INLINE_MATH_MAX_CHARS}}\S\$)([^$\n]{1,${INLINE_MATH_MAX_CHARS}})\$(?![\w$])`,
'g',
);

function parseFencedCodeBlocks(markdown: string): FencedCodeBlock[] {
const blocks: FencedCodeBlock[] = [];
const lines = markdown.split(/\r?\n/);
Expand Down Expand Up @@ -139,14 +134,11 @@ function parseInlineLatexExpressions(
continue;
}

for (const match of line.matchAll(INLINE_MATH_REGEX)) {
const content = match[1];
if (content) {
expressions.push({
content,
index: expressions.length + 1,
});
}
for (const content of findInlineMathExpressions(line)) {
expressions.push({
content,
index: expressions.length + 1,
});
}
}

Expand Down
48 changes: 47 additions & 1 deletion packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { renderWithProviders } from '../../test-utils/render.js';
import { RenderInline } from './InlineMarkdownRenderer.js';
import { getPlainTextLength, RenderInline } from './InlineMarkdownRenderer.js';
import { HYPERLINK_ENV_KEYS } from './osc8.js';

describe('<RenderInline />', () => {
Expand Down Expand Up @@ -57,6 +57,52 @@ describe('<RenderInline />', () => {
expect(lastFrame()).not.toContain('$\\alpha$');
});

it('renders single-character and CJK-adjacent inline math', () => {
const { lastFrame } = renderWithProviders(
<RenderInline text="Values $x$、$α$。" enableInlineMath />,
);

const output = (lastFrame() ?? '').replace(/\n/g, '');
expect(output).toContain('Valuesx、α。');
expect(output).not.toContain('$x$');
});

it('preserves escaped inline math and inline code', () => {
const { lastFrame } = renderWithProviders(
<RenderInline text={'Literal \\$xy$ and code `$xy$`'} enableInlineMath />,
);

expect((lastFrame() ?? '').replace(/\n/g, '')).toContain(
'Literal \\$xy$ and code$xy$',
);
});

it('keeps math literal inside multi-backtick code spans', () => {
const { lastFrame } = renderWithProviders(
<RenderInline text={'Use ``a `$xy$` b`` then $y$'} enableInlineMath />,
);

expect(lastFrame()).toContain('$xy$');
expect(lastFrame()).not.toContain('$y$');
});

it('keeps longer backtick runs inside single-backtick code spans', () => {
const { lastFrame } = renderWithProviders(
<RenderInline text={'Use `a `` $xy$ `` b` then $y$'} enableInlineMath />,
);

expect(lastFrame()).toContain('$xy$');
expect(lastFrame()).not.toContain('$y$');
});

it('measures recognized single-character math without delimiters', () => {
expect(getPlainTextLength('value $x$', true)).toBe('value x'.length);
expect(getPlainTextLength('code `$xy$`', true)).toBe('code $xy$'.length);
expect(getPlainTextLength('code ``a `$xy$` b``', true)).toBe(
'code a `$xy$` b'.length,
);
});

it('does not parse ordinary dollar amounts as inline math', () => {
const { lastFrame } = renderWithProviders(
<RenderInline text="cost is $5 and $10 later" enableInlineMath />,
Expand Down
53 changes: 37 additions & 16 deletions packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ import {
supportsHyperlinks,
trimTrailingUrlPunctuation,
} from './osc8.js';
import {
INLINE_CODE_SPAN_PATTERN_SOURCE,
INLINE_MATH_PATTERN_SOURCE,
} from './inline-math.js';

// Constants for Markdown parsing
const BOLD_MARKER_LENGTH = 2; // For "**"
Expand All @@ -31,19 +35,14 @@ const INLINE_CODE_MARKER_LENGTH = 1; // For "`"
const UNDERLINE_TAG_START_LENGTH = 3; // For "<u>"
const UNDERLINE_TAG_END_LENGTH = 4; // For "</u>"
const INLINE_MATH_MARKER_LENGTH = 1; // For "$"
const INLINE_MATH_MAX_CHARS = 1024;
const INLINE_MATH_PATTERN = new RegExp(
String.raw`(?<![\w$])\$(?![\s\d$])(?=[^$\n]{1,${INLINE_MATH_MAX_CHARS}}\S\$)[^$\n]{1,${INLINE_MATH_MAX_CHARS}}\$(?![\w$])`,
'g',
);
const INLINE_MARKDOWN_REGEX = new RegExp(
String.raw`(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|${MD_LINK_PATTERN}|` +
String.raw`\`+.+?\`+|<u>.*?<\/u>|https?:\/\/\S+)`,
String.raw`${INLINE_CODE_SPAN_PATTERN_SOURCE}|<u>.*?<\/u>|https?:\/\/\S+)`,
'g',
);
const INLINE_MARKDOWN_WITH_MATH_REGEX = new RegExp(
String.raw`(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|${MD_LINK_PATTERN}|` +
String.raw`\`+.+?\`+|(?<![\w$])\$(?![\s\d$])(?=[^$\n]{1,${INLINE_MATH_MAX_CHARS}}\S\$)[^$\n]{1,${INLINE_MATH_MAX_CHARS}}\$(?![\w$])|<u>.*?<\/u>|https?:\/\/\S+)`,
String.raw`${INLINE_CODE_SPAN_PATTERN_SOURCE}|${INLINE_MATH_PATTERN_SOURCE}|<u>.*?<\/u>|https?:\/\/\S+)`,
'g',
);

Expand Down Expand Up @@ -274,19 +273,41 @@ export const getPlainTextLength = (
text: string,
enableInlineMath = false,
): number => {
const cleanText = text
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);
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;
}
lastIndex = inlineRegex.lastIndex;
}
normalizedText += text.slice(lastIndex);

const cleanText = normalizedText
.replace(/\*\*(.*?)\*\*/g, '$1')
.replace(/\*(.*?)\*/g, '$1')
.replace(/_(.*?)_/g, '$1')
.replace(/~~(.*?)~~/g, '$1')
Comment on lines +306 to 310
.replace(/`(.*?)`/g, '$1')
.replace(INLINE_MATH_PATTERN, (match: string) =>
enableInlineMath
? renderInlineLatex(
match.slice(INLINE_MATH_MARKER_LENGTH, -INLINE_MATH_MARKER_LENGTH),
)
: match,
)
.replace(/<u>(.*?)<\/u>/g, '$1')
.replace(/.*\[(.*?)\]\(.*\)/g, '$1');
return stringWidth(cleanText);
Expand Down
17 changes: 17 additions & 0 deletions packages/cli/src/ui/utils/MarkdownDisplay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1134,6 +1134,23 @@ Another paragraph.
expect(output).not.toContain('$\\alpha$');
});

it('renders table math while preserving escaped and code-span math', () => {
const text = `
| Formula | Literal | Code |
|---------|---------|------|
| $x$ | \\$xy$ | \`\`a \`$zz$\` b\`\` |
`.replace(/\n/g, eol);
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
const output = stripAnsi(lastFrame() ?? '');

expect(output).toContain('│ x');
expect(output).toContain('\\$xy$');
expect(output).toContain('a `$zz$` b');
expect(output).not.toContain('$x$');
});

it('keeps pipes inside markdown table math spans in the same cell', () => {
const text = `
| Expression | Meaning |
Expand Down
11 changes: 6 additions & 5 deletions packages/cli/src/ui/utils/TableRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import { getCachedStringWidth } from './textUtils.js';
import { TABLE_MAX_ROW_LINES as MAX_ROW_LINES } from './pending-rendered-height.js';
import { theme } from '../semantic-colors.js';
import { renderInlineLatex } from './latexRenderer.js';
import {
INLINE_CODE_SPAN_PATTERN_SOURCE,
INLINE_MATH_PATTERN_SOURCE,
} from './inline-math.js';
import {
MD_LINK_CAPTURE,
MD_LINK_PATTERN,
Expand Down Expand Up @@ -42,17 +46,14 @@ const ABSOLUTE_MIN_HORIZONTAL_TABLE_WIDTH = 24;
/** Safety margin to account for terminal resize races */
const SAFETY_MARGIN = 4;

const INLINE_MATH_MAX_CHARS = 1024;

const INLINE_MATH_PATTERN = String.raw`(?<![\w$])\$(?![\s\d$])(?=[^$\n]{1,${INLINE_MATH_MAX_CHARS}}\S\$)[^$\n]{1,${INLINE_MATH_MAX_CHARS}}\$(?![\w$])`;
const INLINE_MARKDOWN_REGEX = new RegExp(
String.raw`(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|${MD_LINK_PATTERN}|` +
String.raw`\`+.+?\`+|<u>.*?<\/u>|https?:\/\/\S+)`,
String.raw`${INLINE_CODE_SPAN_PATTERN_SOURCE}|<u>.*?<\/u>|https?:\/\/\S+)`,
'g',
);
const INLINE_MARKDOWN_WITH_MATH_REGEX = new RegExp(
String.raw`(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|${MD_LINK_PATTERN}|` +
String.raw`\`+.+?\`+|${INLINE_MATH_PATTERN}|<u>.*?<\/u>|https?:\/\/\S+)`,
String.raw`${INLINE_CODE_SPAN_PATTERN_SOURCE}|${INLINE_MATH_PATTERN_SOURCE}|<u>.*?<\/u>|https?:\/\/\S+)`,
'g',
);

Expand Down
57 changes: 57 additions & 0 deletions packages/cli/src/ui/utils/inline-math.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, expect, it } from 'vitest';
import {
findInlineMathExpressions,
INLINE_MATH_MAX_CHARS,
readInlineMathSpanAt,
} from './inline-math.js';

describe('inline math recognition', () => {
it('recognizes single-character and CJK-adjacent formulas', () => {
expect(findInlineMathExpressions('Values $x$、$α$。')).toEqual(['x', 'α']);
});

it('preserves escaped dollars, prices, variables, and adjacent spans', () => {
expect(
findInlineMathExpressions(String.raw`Literal \$xy$ and \$\alpha$`),
).toEqual([]);
expect(findInlineMathExpressions('Price $20 and $30')).toEqual([]);
expect(findInlineMathExpressions('Use $HOME and ${PATH}')).toEqual([]);
expect(findInlineMathExpressions('$a$$b$')).toEqual([]);
});

it('rejects formulas whose closing dollar is escaped', () => {
expect(findInlineMathExpressions(String.raw`A $x\$ B`)).toEqual([]);
expect(findInlineMathExpressions(String.raw`Total $a b\$ end`)).toEqual([]);
});

it('ignores inline code spans and unclosed formulas', () => {
expect(findInlineMathExpressions('Use `$xy$` then $z$ and $open')).toEqual([
'z',
]);
expect(findInlineMathExpressions('Use ``a `$x$` b`` then $y$')).toEqual([
'y',
]);
expect(findInlineMathExpressions('Use `a `` $x$ `` b` then $y$')).toEqual([
'y',
]);
});

it('bounds formula length', () => {
const maximum = 'x'.repeat(INLINE_MATH_MAX_CHARS);
const tooLong = 'x'.repeat(INLINE_MATH_MAX_CHARS + 1);

expect(findInlineMathExpressions(`$${maximum}$`)).toHaveLength(1);
expect(findInlineMathExpressions(`$${tooLong}$`)).toEqual([]);
});

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();
});
});
44 changes: 44 additions & 0 deletions packages/cli/src/ui/utils/inline-math.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

export const INLINE_MATH_MAX_CHARS = 1024;

export const INLINE_MATH_PATTERN_SOURCE = String.raw`(?<![\\\w$])\$(?![\s\d$])[^$\n]{0,${INLINE_MATH_MAX_CHARS - 1}}[^$\s](?<!\\)\$(?![\w$])`;

export const INLINE_CODE_SPAN_PATTERN_SOURCE = String.raw`(?<!\`)(?<inlineCodeFence>\`+)(?!\`).+?(?<!\`)\k<inlineCodeFence>(?!\`)`;

const INLINE_CODE_SPAN_RE = new RegExp(INLINE_CODE_SPAN_PATTERN_SOURCE, 'g');

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;
}
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;
}
Loading
Loading