From 5b220a520a872fe4a2a260d440ff0adc7016da82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Mon, 27 Apr 2026 20:53:15 +0800 Subject: [PATCH 01/36] feat(cli): expand markdown rendering in tui --- docs/design/markdown-syntax-extension.md | 189 ++++ .../src/ui/utils/InlineMarkdownRenderer.tsx | 22 +- .../cli/src/ui/utils/MarkdownDisplay.test.tsx | 160 ++- packages/cli/src/ui/utils/MarkdownDisplay.tsx | 120 ++- packages/cli/src/ui/utils/MermaidDiagram.tsx | 206 ++++ packages/cli/src/ui/utils/latexRenderer.ts | 144 +++ .../src/ui/utils/mermaidImageRenderer.test.ts | 149 +++ .../cli/src/ui/utils/mermaidImageRenderer.ts | 699 +++++++++++++ .../cli/src/ui/utils/mermaidVisualRenderer.ts | 933 ++++++++++++++++++ 9 files changed, 2610 insertions(+), 12 deletions(-) create mode 100644 docs/design/markdown-syntax-extension.md create mode 100644 packages/cli/src/ui/utils/MermaidDiagram.tsx create mode 100644 packages/cli/src/ui/utils/latexRenderer.ts create mode 100644 packages/cli/src/ui/utils/mermaidImageRenderer.test.ts create mode 100644 packages/cli/src/ui/utils/mermaidImageRenderer.ts create mode 100644 packages/cli/src/ui/utils/mermaidVisualRenderer.ts diff --git a/docs/design/markdown-syntax-extension.md b/docs/design/markdown-syntax-extension.md new file mode 100644 index 00000000000..c4197321ce5 --- /dev/null +++ b/docs/design/markdown-syntax-extension.md @@ -0,0 +1,189 @@ +# Markdown Syntax Extension Design + +## Context + +This document keeps the implementation references for the integrated Markdown +syntax extension PR. It is based on the TUI optimization research from +`origin/docs/tui-optimization-design`, especially: + +- `docs/design/tui-optimization/00-overview.md` +- `docs/design/tui-optimization/03-rendering-extensibility.md` +- `docs/design/tui-optimization/04-gemini-cli-research.md` +- `docs/design/tui-optimization/05-claude-code-research.md` +- `docs/design/tui-optimization/06-implementation-rollout-checklist.md` +- `docs/design/tui-optimization/08-execution-plan-and-test-matrix.md` + +The referenced research recommends a long-term Markdown architecture built +around an AST parser, block/token caching, stable-prefix streaming, bounded +detail panels, and terminal capability detection. This first implementation +keeps the runtime footprint small and makes the new behavior visible +immediately. + +## Integrated PR Scope + +This PR treats Markdown syntax expansion as one coherent renderer improvement, +not separate feature PRs. + +Included in the first implementation: + +- Mermaid code blocks render visually in the TUI. +- Mermaid diagrams render through PNG terminal images when `mmdc` and a + supported terminal image path are available. +- `flowchart` / `graph` Mermaid diagrams fall back to box-and-arrow previews. +- `sequenceDiagram` Mermaid diagrams fall back to participant-arrow previews. +- Unsupported Mermaid types render a compact visual placeholder instead of a + raw source block. +- Task list items render checked/unchecked markers. +- Blockquotes render with a visible quote bar. +- Inline `$...$` math and block `$$...$$` math render with common Unicode + substitutions. +- Existing Markdown tables continue to use `TableRenderer`. +- Existing non-Mermaid fenced code blocks continue to use `CodeColorizer`. + +## Mermaid Rendering Strategy + +### First version: capability-gated image rendering plus text fallback + +The implementation now treats Mermaid's own layout as the preferred path. When +the local environment supports it, the TUI renders Mermaid blocks through this +pipeline: + +```text +Mermaid source + -> mmdc / Mermaid CLI + -> PNG + -> Kitty or iTerm2 terminal image protocol +``` + +If the terminal does not support inline images but `chafa` is installed, the +same PNG is rendered as ANSI block graphics. If neither image protocol nor +`chafa` is available, the renderer falls back to the synchronous terminal text +preview described below. + +The image render is scheduled after the first React paint. Users see the +wireframe preview immediately with an explicit rendering status, then the +terminal image replaces it when Mermaid CLI finishes. This avoids a confusing +blank pause during slow `mmdc` startup, especially when the opt-in `npx` path +is cold. + +PNG generation is cached independently from terminal placement. Repeated +renders of the same Mermaid source, including terminal resize updates, reuse +the generated PNG and only recompute the Kitty/iTerm2 placement dimensions. + +The image path is intentionally capability-gated instead of always bundling +Puppeteer/Chromium into the hot CLI path. A user can enable the image path by +installing `@mermaid-js/mermaid-cli` so `mmdc` is on `PATH`, or by setting +`QWEN_CODE_MERMAID_MMD_CLI` to the binary path. For ad-hoc local verification, +`QWEN_CODE_MERMAID_ALLOW_NPX=1` allows the renderer to invoke +`npx -y @mermaid-js/mermaid-cli@11.12.0`; this is intentionally opt-in because +the first run may install Puppeteer/Chromium and block rendering. Terminal +protocol selection can be forced with +`QWEN_CODE_MERMAID_IMAGE_PROTOCOL=kitty|iterm2|off`. + +For Kitty-compatible terminals such as Ghostty, the renderer uses Kitty +Unicode placeholders instead of writing the image payload as Ink text. The PNG +is transmitted through raw stdout in quiet mode (`q=2`) with a virtual +placement (`U=1`), and the React tree renders the normal placeholder character +grid (`U+10EEEE`) with explicit row and column diacritics for each cell. This +keeps Ink responsible for layout and resize while preventing APC payload bytes +from being wrapped into visible base64 text. + +### Fallback: resizable wireframe preview + +The fallback avoids async work because Ink's `` path is append-only: a +finalized message cannot reliably wait for a background render job and then +update in place without forcing a full static refresh. The fallback must +therefore produce terminal output during the normal React render pass. + +For `flowchart` / `graph` diagrams, the fallback builds a lightweight graph +model instead of printing one edge at a time: + +- Nodes are normalized by Mermaid id, label, and basic shape. +- Node labels support Mermaid-style `\n` / `
` line breaks. +- Top-down diagrams are ranked into horizontal layers. +- Left-to-right diagrams are ranked into vertical columns when they fit. +- Multiple outgoing edges from the same node are drawn as one fork with + bracketed edge labels such as `[Yes]`, `[No]`, `[是]`, and `[否]`. +- Back edges and cycles are summarized in a `Cycles:` section with explicit + `↩ to ` markers. This avoids unstable long cross-diagram routes in + terminal fonts while keeping the loop semantics visible. +- The graph is recomputed from `contentWidth`, so resize changes node width, + spacing, and connector paths. + +Example: + +```mermaid +flowchart LR + A[Client] --> B[API] +``` + +renders as a terminal visual preview rather than Mermaid source. + +The fallback is still not a complete Mermaid engine. It is a fast, +dependency-light wireframe preview for common LLM-generated diagrams when +high-fidelity rendering is not available. + +### Future providers + +The provider boundary is intentionally open for additional native image +providers: + +- `mmdc` / `@mermaid-js/mermaid-cli` for SVG/PNG output. +- `terminal-image` for Kitty/iTerm2 plus ANSI fallback. +- `chafa` when present for Sixel/Kitty/iTerm2/Unicode mosaics. + +This path should remain optional, cached, and capability-gated, with cache keys +based on source hash, terminal width, renderer provider, and terminal protocol. +It should not block startup or add bundled Mermaid/Puppeteer work to the hot TUI +path by default. + +## AST Renderer Compatibility + +The first version extends the existing parser to minimize blast radius. The +feature boundaries are still compatible with a future `marked` token pipeline: + +- `code(lang=mermaid)` -> `MermaidDiagram` +- `code(lang=*)` -> existing `CodeColorizer` +- `table` -> existing `TableRenderer` +- `blockquote` -> quote block renderer +- `list(task=true)` -> task list renderer +- `paragraph/text` -> inline renderer with math/link/style support + +The implementation does not cache React nodes. A future AST renderer should +cache tokens/blocks, then render from current width/theme/settings props. + +## Safety And Performance + +- Mermaid source is treated as untrusted input. +- The first renderer does not execute Mermaid JavaScript. +- Native image rendering must be opt-in or capability-gated. +- Future browser-based rendering must use timeouts and size limits. +- Rendering should degrade to terminal text instead of throwing. +- Large blocks should respect available height and width. + +## Validation + +Targeted unit verification: + +```bash +cd packages/cli +npx vitest run src/ui/utils/MarkdownDisplay.test.tsx +npx vitest run src/ui/utils/mermaidImageRenderer.test.ts +``` + +Broader verification before PR submission: + +```bash +npm run typecheck --workspace=packages/cli +npm run lint --workspace=packages/cli +git diff --check +``` + +Manual scenarios: + +- Assistant response with a Mermaid `flowchart LR` block. +- Assistant response with a Mermaid `sequenceDiagram` block. +- Markdown table plus Mermaid in the same answer. +- Fenced JavaScript code block still showing code formatting. +- Narrow terminal width. +- Constrained tool/detail surface. diff --git a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx index 2403db96f5b..076d6e5c4e8 100644 --- a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx +++ b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx @@ -9,6 +9,7 @@ import { Text } from 'ink'; import { theme } from '../semantic-colors.js'; import stringWidth from 'string-width'; import { createDebugLogger } from '@qwen-code/qwen-code-core'; +import { renderInlineLatex } from './latexRenderer.js'; // Constants for Markdown parsing const BOLD_MARKER_LENGTH = 2; // For "**" @@ -17,6 +18,7 @@ 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 debugLogger = createDebugLogger('INLINE_MARKDOWN'); @@ -30,14 +32,14 @@ const RenderInlineInternal: React.FC = ({ textColor = theme.text.primary, }) => { // Early return for plain text without markdown or URLs - if (!/[*_~`<[https?:]/.test(text)) { + if (!/[*_~`<[$]|https?:/.test(text)) { return {text}; } const nodes: React.ReactNode[] = []; let lastIndex = 0; const inlineRegex = - /(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|.*?<\/u>|https?:\/\/\S+)/g; + /(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|\$(?:\\.|[^$\n])+\$|.*?<\/u>|https?:\/\/\S+)/g; let match; while ((match = inlineRegex.exec(text)) !== null) { @@ -138,6 +140,21 @@ const RenderInlineInternal: React.FC = ({ )} ); + } else if ( + fullMatch.startsWith('$') && + fullMatch.endsWith('$') && + fullMatch.length > INLINE_MATH_MARKER_LENGTH * 2 + ) { + renderedNode = ( + + {renderInlineLatex( + fullMatch.slice( + INLINE_MATH_MARKER_LENGTH, + -INLINE_MATH_MARKER_LENGTH, + ), + )} + + ); } else if (fullMatch.match(/^https?:\/\//)) { renderedNode = ( @@ -174,6 +191,7 @@ export const getPlainTextLength = (text: string): number => { .replace(/_(.*?)_/g, '$1') .replace(/~~(.*?)~~/g, '$1') .replace(/`(.*?)`/g, '$1') + .replace(/\$(.*?)\$/g, (_match, expr: string) => renderInlineLatex(expr)) .replace(/(.*?)<\/u>/g, '$1') .replace(/.*\[(.*?)\]\(.*\)/g, '$1'); return stringWidth(cleanText); diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx index fc788e18c71..95b9e1edfe8 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx @@ -4,10 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; import { MarkdownDisplay } from './MarkdownDisplay.js'; import { LoadedSettings } from '../../config/settings.js'; import { renderWithProviders } from '../../test-utils/render.js'; +import { renderMermaidVisual } from './mermaidVisualRenderer.js'; describe('', () => { const baseProps = { @@ -165,7 +166,7 @@ Some text before. const { lastFrame } = renderWithProviders( , ); - const output = lastFrame(); + const output = lastFrame() ?? ''; expect(output).toContain('Name'); expect(output).toContain('Alice'); expect(output).toContain('Bob'); @@ -196,7 +197,7 @@ Some text before. const { lastFrame } = renderWithProviders( , ); - const output = lastFrame(); + const output = lastFrame() ?? ''; expect(output).toContain('A | B'); expect(output).toContain('C'); }); @@ -209,7 +210,7 @@ next line const { lastFrame } = renderWithProviders( , ); - const output = lastFrame(); + const output = lastFrame() ?? ''; expect(output).toContain('| just text |'); expect(output).not.toContain('┌'); }); @@ -223,7 +224,7 @@ next line const { lastFrame } = renderWithProviders( , ); - const output = lastFrame(); + const output = lastFrame() ?? ''; expect(output).toContain('| A | B |'); expect(output).not.toContain('┌'); }); @@ -237,7 +238,7 @@ next line const { lastFrame } = renderWithProviders( , ); - const output = lastFrame(); + const output = lastFrame() ?? ''; expect(output).toContain('| A | B |'); expect(output).not.toContain('┌'); }); @@ -251,7 +252,7 @@ data const { lastFrame } = renderWithProviders( , ); - const output = lastFrame(); + const output = lastFrame() ?? ''; // `---` without any `|` is a horizontal rule, not a table separator expect(output).toContain('| Header |'); expect(output).not.toContain('┌'); @@ -360,6 +361,151 @@ Another paragraph. expect(lastFrame()).toMatchSnapshot(); expect(lastFrame()).toContain(' 1 '); }); + + it('renders task list items with checkbox markers', () => { + const text = ` +- [x] Done +- [ ] Todo +`.replace(/\n/g, eol); + const { lastFrame } = renderWithProviders( + , + ); + const output = lastFrame(); + expect(output).toContain('✓ Done'); + expect(output).toContain('○ Todo'); + }); + + it('renders blockquotes as quoted text', () => { + const text = '> Important **note**'.replace(/\n/g, eol); + const { lastFrame } = renderWithProviders( + , + ); + const output = lastFrame(); + expect(output).toContain('│'); + expect(output).toContain('Important note'); + }); + + it('renders inline and block math with unicode substitutions', () => { + const text = ` +Inline math: $x^2 + \\alpha$ + +$$ +\\sum_{i=1}^{n} x_i +$$ +`.replace(/\n/g, eol); + const { lastFrame } = renderWithProviders( + , + ); + const output = lastFrame(); + expect(output).toContain('x² + α'); + expect(output).toContain('Σᵢ₌₁ⁿ xᵢ'); + }); + + it('renders mermaid flowcharts as a visual preview', () => { + const text = ` +\`\`\`mermaid +flowchart LR + A[Client] --> B[API] +\`\`\` +`.replace(/\n/g, eol); + const { lastFrame } = renderWithProviders( + , + ); + const output = lastFrame(); + expect(output).toContain('Mermaid flowchart (LR)'); + expect(output).toContain('Client'); + expect(output).toContain('API'); + expect(output).toContain('▶'); + expect(output).not.toContain('flowchart LR'); + }); + + it('reuses mermaid node labels when later edges reference node ids', () => { + const text = ` +\`\`\`mermaid +flowchart TD + A[Developer writes code] --> B{Tests pass?} + B -->|Yes| C[Create Pull Request] + B -->|No| D[Fix failing tests] +\`\`\` +`.replace(/\n/g, eol); + const { lastFrame } = renderWithProviders( + , + ); + const output = lastFrame() ?? ''; + expect(output).toContain('Developer writes code'); + expect(output).toContain('Tests pass?'); + expect(output).toContain('Create Pull Request'); + expect(output).toContain('Fix failing tests'); + expect(output).toContain('Yes'); + expect(output).toContain('No'); + expect(output).toContain('▼'); + expect(output.match(/Tests pass\?/g)?.length).toBe(1); + expect(output.match(/Create Pull Request/g)?.length).toBe(1); + expect(output.match(/Fix failing tests/g)?.length).toBe(1); + expect(output).not.toContain('│ B '); + }); + + it('does not duplicate branch nodes when a mermaid flowchart loops back', () => { + const text = ` +\`\`\`mermaid +flowchart TD + A[Start] --> B{Is it working?} + B -->|Yes| C[Great!] + B -->|No| D[Debug] + D --> B +\`\`\` +`.replace(/\n/g, eol); + const { lastFrame } = renderWithProviders( + , + ); + const output = lastFrame() ?? ''; + expect(output).toContain('No'); + expect(output).toContain('Debug'); + expect(output).toContain('↩'); + expect(output).toContain('Cycles:'); + expect(output).toContain('Debug ↩ to Is it working?'); + expect(output.match(/│ Debug │/g)?.length).toBe(1); + }); + + it('resizes mermaid flowchart wireframes to the available width', () => { + const source = ` +flowchart TD + A[Start] --> B{Is it working?} + B -->|Yes| C[Great!] + B -->|No| D[Debug] + D --> B +`; + const narrow = renderMermaidVisual(source, 44).lines; + const wide = renderMermaidVisual(source, 72).lines; + const narrowOutput = narrow.join('\n'); + const wideOutput = wide.join('\n'); + + expect(narrowOutput).toContain('◇ Is it working? ◇'); + expect(narrowOutput).toContain('↩'); + expect(narrowOutput).toContain('Cycles:'); + expect(narrow.every((line) => line.length <= 44)).toBe(true); + expect(wide.every((line) => line.length <= 72)).toBe(true); + expect(wideOutput).not.toBe(narrowOutput); + }); + + it('renders mermaid sequence diagrams as a visual preview', () => { + const text = ` +\`\`\`mermaid +sequenceDiagram + participant U as User + participant A as API + U->>A: request +\`\`\` +`.replace(/\n/g, eol); + const { lastFrame } = renderWithProviders( + , + ); + const output = lastFrame(); + expect(output).toContain('Mermaid sequence diagram'); + expect(output).toContain('Participants: User | API'); + expect(output).toContain('User → API: request'); + expect(output).not.toContain('sequenceDiagram'); + }); }); it('correctly splits lines using \\n regardless of platform EOL', () => { diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.tsx index 3c0edcc0e7f..38426871c0f 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.tsx @@ -11,6 +11,8 @@ import { colorizeCode } from './CodeColorizer.js'; import { TableRenderer, type ColumnAlign } from './TableRenderer.js'; import { RenderInline } from './InlineMarkdownRenderer.js'; import { useSettings } from '../contexts/SettingsContext.js'; +import { MermaidDiagram } from './MermaidDiagram.js'; +import { renderInlineLatex } from './latexRenderer.js'; interface MarkdownDisplayProps { text: string; @@ -26,6 +28,8 @@ const EMPTY_LINE_HEIGHT = 1; const CODE_BLOCK_PREFIX_PADDING = 1; const LIST_ITEM_PREFIX_PADDING = 1; const LIST_ITEM_TEXT_FLEX_GROW = 1; +const BLOCKQUOTE_PREFIX_PADDING = 1; +const MATH_BLOCK_PREFIX_PADDING = 1; const MarkdownDisplayInternal: React.FC = ({ text, @@ -42,6 +46,8 @@ const MarkdownDisplayInternal: React.FC = ({ const ulItemRegex = /^([ \t]*)([-*+]) +(.*)/; const olItemRegex = /^([ \t]*)(\d+)\. +(.*)/; const hrRegex = /^ *([-*_] *){3,} *$/; + const blockquoteRegex = /^ *> ?(.*)$/; + const mathFenceRegex = /^ *\$\$ *$/; const tableRowRegex = /^\s*\|(.+)\|\s*$/; const tableSeparatorRegex = /^(?=.*\|)\s*\|?\s*(:?-+:?)\s*(\|\s*(:?-+:?)\s*)*\|?\s*$/; @@ -66,6 +72,8 @@ const MarkdownDisplayInternal: React.FC = ({ let codeBlockContent: string[] = []; let codeBlockLang: string | null = null; let codeBlockFence = ''; + let inMathBlock = false; + let mathBlockContent: string[] = []; let inTable = false; let tableRows: string[][] = []; let tableHeaders: string[] = []; @@ -108,11 +116,30 @@ const MarkdownDisplayInternal: React.FC = ({ return; } + if (inMathBlock) { + if (mathFenceRegex.test(line)) { + addContentBlock( + , + ); + inMathBlock = false; + mathBlockContent = []; + } else { + mathBlockContent.push(line); + } + return; + } + const codeFenceMatch = line.match(codeFenceRegex); + const mathFenceMatch = line.match(mathFenceRegex); const headerMatch = line.match(headerRegex); const ulMatch = line.match(ulItemRegex); const olMatch = line.match(olItemRegex); const hrMatch = line.match(hrRegex); + const blockquoteMatch = line.match(blockquoteRegex); const tableRowMatch = line.match(tableRowRegex); const tableSeparatorMatch = line.match(tableSeparatorRegex); @@ -120,6 +147,9 @@ const MarkdownDisplayInternal: React.FC = ({ inCodeBlock = true; codeBlockFence = codeFenceMatch[1]; codeBlockLang = codeFenceMatch[2] || null; + } else if (mathFenceMatch) { + inMathBlock = true; + mathBlockContent = []; } else if (tableRowMatch && !inTable) { // Potential table start - check if next line is separator with matching column count const potentialHeaders = tableRowMatch[1] @@ -198,6 +228,14 @@ const MarkdownDisplayInternal: React.FC = ({ --- , ); + } else if (blockquoteMatch) { + addContentBlock( + , + ); } else if (headerMatch) { const level = headerMatch[1].length; const headerText = headerMatch[2]; @@ -301,6 +339,16 @@ const MarkdownDisplayInternal: React.FC = ({ ); } + if (inMathBlock) { + addContentBlock( + , + ); + } + // Handle table at end of content if (inTable && tableHeaders.length > 0 && tableRows.length > 0) { addContentBlock( @@ -337,6 +385,18 @@ const RenderCodeBlockInternal: React.FC = ({ const settings = useSettings(); const MIN_LINES_FOR_MESSAGE = 1; // Minimum lines to show before the "generating more" message const RESERVED_LINES = 2; // Lines reserved for the message itself and potential padding + const fullContent = content.join('\n'); + + if (lang?.toLowerCase() === 'mermaid') { + return ( + + ); + } if (isPending && availableTerminalHeight !== undefined) { const MAX_CODE_LINES_WHEN_PENDING = Math.max( @@ -373,7 +433,6 @@ const RenderCodeBlockInternal: React.FC = ({ } } - const fullContent = content.join('\n'); const colorizedCode = colorizeCode( fullContent, lang, @@ -397,6 +456,53 @@ const RenderCodeBlockInternal: React.FC = ({ const RenderCodeBlock = React.memo(RenderCodeBlockInternal); +interface RenderMathBlockProps { + content: string[]; + contentWidth: number; +} + +const RenderMathBlockInternal: React.FC = ({ + content, + contentWidth, +}) => { + const rendered = renderInlineLatex(content.join(' ')); + return ( + + + {rendered} + + + ); +}; + +const RenderMathBlock = React.memo(RenderMathBlockInternal); + +interface RenderBlockquoteProps { + quoteText: string; + textColor?: string; +} + +const RenderBlockquoteInternal: React.FC = ({ + quoteText, + textColor = theme.text.primary, +}) => ( + + │ + + + + + + +); + +const RenderBlockquote = React.memo(RenderBlockquoteInternal); + interface RenderListItemProps { itemText: string; type: 'ul' | 'ol'; @@ -412,7 +518,15 @@ const RenderListItemInternal: React.FC = ({ leadingWhitespace = '', textColor = theme.text.primary, }) => { - const prefix = type === 'ol' ? `${marker}. ` : `${marker} `; + const taskMatch = itemText.match(/^\[([ xX])\]\s+(.*)$/); + const isTaskItem = taskMatch !== null; + const isTaskChecked = taskMatch?.[1]?.toLowerCase() === 'x'; + const effectiveItemText = taskMatch?.[2] ?? itemText; + const prefix = isTaskItem + ? `${isTaskChecked ? '✓' : '○'} ` + : type === 'ol' + ? `${marker}. ` + : `${marker} `; const prefixWidth = prefix.length; const indentation = leadingWhitespace.length; @@ -426,7 +540,7 @@ const RenderListItemInternal: React.FC = ({ - + diff --git a/packages/cli/src/ui/utils/MermaidDiagram.tsx b/packages/cli/src/ui/utils/MermaidDiagram.tsx new file mode 100644 index 00000000000..77097b41817 --- /dev/null +++ b/packages/cli/src/ui/utils/MermaidDiagram.tsx @@ -0,0 +1,206 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { Box, Text, useStdout } from 'ink'; +import { theme } from '../semantic-colors.js'; +import { MaxSizedBox } from '../components/shared/MaxSizedBox.js'; +import { renderMermaidVisual } from './mermaidVisualRenderer.js'; +import { renderMermaidImageSync } from './mermaidImageRenderer.js'; + +interface MermaidDiagramProps { + source: string; + contentWidth: number; + isPending: boolean; + availableTerminalHeight?: number; +} + +const MERMAID_PADDING = 1; + +const MermaidDiagramInternal: React.FC = ({ + source, + contentWidth, + isPending, + availableTerminalHeight, +}) => { + const { write } = useStdout(); + const preparedKittySequence = React.useRef(null); + const innerWidth = Math.max(8, contentWidth - MERMAID_PADDING); + const renderKey = React.useMemo( + () => `${source}\0${innerWidth}\0${availableTerminalHeight ?? 'auto'}`, + [availableTerminalHeight, innerWidth, source], + ); + const [imageState, setImageState] = React.useState<{ + key: string; + image: ReturnType | null; + }>({ key: '', image: null }); + const visual = React.useMemo( + () => renderMermaidVisual(source, innerWidth), + [source, innerWidth], + ); + const image = imageState.key === renderKey ? imageState.image : null; + const imageAttemptPending = !isPending && image === null; + + React.useEffect(() => { + if (isPending) { + setImageState({ key: renderKey, image: null }); + return; + } + + let cancelled = false; + setImageState((current) => + current.key === renderKey ? current : { key: renderKey, image: null }, + ); + + const timer = setTimeout(() => { + const nextImage = renderMermaidImageSync({ + source, + contentWidth: innerWidth, + availableTerminalHeight, + }); + if (!cancelled) { + setImageState({ key: renderKey, image: nextImage }); + } + }, 20); + + return () => { + cancelled = true; + clearTimeout(timer); + }; + }, [availableTerminalHeight, innerWidth, isPending, renderKey, source]); + + const kittySequence = + image?.kind === 'terminal-image' && + image.protocol === 'kitty' && + image.placeholder + ? image.sequence + : null; + + React.useEffect(() => { + if (!kittySequence || preparedKittySequence.current === kittySequence) { + return; + } + preparedKittySequence.current = kittySequence; + write(kittySequence); + }, [kittySequence, write]); + + if ( + image?.kind === 'terminal-image' && + image.protocol === 'kitty' && + image.placeholder + ) { + return ( + + + {image.title} + + + {image.placeholder.lines.map((line, index) => ( + + + {line} + + + ))} + + + ); + } + + if (image?.kind === 'terminal-image') { + return ( + + + {image.title} + + + {image.sequence} + + + ); + } + + if (image?.kind === 'ansi') { + return ( + + + {image.title} + + + {image.lines.map((line, index) => ( + + {line || ' '} + + ))} + + + ); + } + + return ( + + + {visual.title} + + + {visual.lines.map((line, index) => ( + + {line || ' '} + + ))} + + {visual.warning && ( + + {visual.warning} + + )} + {imageAttemptPending && ( + + Rendering Mermaid image... showing wireframe preview meanwhile. + + )} + {!isPending && image?.kind === 'unavailable' && ( + + Image rendering unavailable: {image.reason} + + )} + + ); +}; + +export const MermaidDiagram = React.memo(MermaidDiagramInternal); diff --git a/packages/cli/src/ui/utils/latexRenderer.ts b/packages/cli/src/ui/utils/latexRenderer.ts new file mode 100644 index 00000000000..39c7143d133 --- /dev/null +++ b/packages/cli/src/ui/utils/latexRenderer.ts @@ -0,0 +1,144 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +const COMMAND_REPLACEMENTS: Record = { + '\\alpha': 'α', + '\\beta': 'β', + '\\gamma': 'γ', + '\\delta': 'δ', + '\\epsilon': 'ε', + '\\theta': 'θ', + '\\lambda': 'λ', + '\\mu': 'μ', + '\\pi': 'π', + '\\rho': 'ρ', + '\\sigma': 'σ', + '\\tau': 'τ', + '\\phi': 'φ', + '\\omega': 'ω', + '\\Gamma': 'Γ', + '\\Delta': 'Δ', + '\\Theta': 'Θ', + '\\Lambda': 'Λ', + '\\Pi': 'Π', + '\\Sigma': 'Σ', + '\\Phi': 'Φ', + '\\Omega': 'Ω', + '\\sum': 'Σ', + '\\prod': '∏', + '\\int': '∫', + '\\infty': '∞', + '\\partial': '∂', + '\\sqrt': '√', + '\\times': '×', + '\\cdot': '·', + '\\pm': '±', + '\\leq': '≤', + '\\geq': '≥', + '\\neq': '≠', + '\\approx': '≈', + '\\rightarrow': '→', + '\\leftarrow': '←', + '\\Rightarrow': '⇒', + '\\Leftarrow': '⇐', +}; + +const SUPERSCRIPT: Record = { + '0': '⁰', + '1': '¹', + '2': '²', + '3': '³', + '4': '⁴', + '5': '⁵', + '6': '⁶', + '7': '⁷', + '8': '⁸', + '9': '⁹', + '+': '⁺', + '-': '⁻', + '=': '⁼', + '(': '⁽', + ')': '⁾', + n: 'ⁿ', + i: 'ⁱ', +}; + +const SUBSCRIPT: Record = { + '0': '₀', + '1': '₁', + '2': '₂', + '3': '₃', + '4': '₄', + '5': '₅', + '6': '₆', + '7': '₇', + '8': '₈', + '9': '₉', + '+': '₊', + '-': '₋', + '=': '₌', + '(': '₍', + ')': '₎', + a: 'ₐ', + e: 'ₑ', + h: 'ₕ', + i: 'ᵢ', + j: 'ⱼ', + k: 'ₖ', + l: 'ₗ', + m: 'ₘ', + n: 'ₙ', + o: 'ₒ', + p: 'ₚ', + r: 'ᵣ', + s: 'ₛ', + t: 'ₜ', + u: 'ᵤ', + v: 'ᵥ', + x: 'ₓ', +}; + +function convertScript(value: string, map: Record): string { + return [...value].map((char) => map[char] ?? char).join(''); +} + +export function renderInlineLatex(input: string): string { + let output = input.trim(); + + output = output.replace( + /\\frac\{([^{}]+)\}\{([^{}]+)\}/g, + (_match, numerator: string, denominator: string) => + `${renderInlineLatex(numerator)}/${renderInlineLatex(denominator)}`, + ); + + output = output.replace( + /\\sqrt\{([^{}]+)\}/g, + (_match, radicand: string) => `√(${renderInlineLatex(radicand)})`, + ); + + output = output.replace( + /\^\{([^{}]+)\}|\^([A-Za-z0-9+\-=()])/g, + (_match, braced: string | undefined, single: string | undefined) => + convertScript(braced ?? single ?? '', SUPERSCRIPT), + ); + + output = output.replace( + /_\{([^{}]+)\}|_([A-Za-z0-9+\-=()])/g, + (_match, braced: string | undefined, single: string | undefined) => + convertScript(braced ?? single ?? '', SUBSCRIPT), + ); + + for (const [command, replacement] of Object.entries(COMMAND_REPLACEMENTS)) { + output = output.split(command).join(replacement); + } + + return output + .replace(/\\left|\\right/g, '') + .replace(/\\,/g, ' ') + .replace(/\\([A-Za-z]+)/g, '$1') + .replace(/\s+/g, ' ') + .trim(); +} diff --git a/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts b/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts new file mode 100644 index 00000000000..1e533d4e5a0 --- /dev/null +++ b/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts @@ -0,0 +1,149 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { + buildKittyPlaceholder, + detectTerminalImageProtocol, + encodeITerm2InlineImage, + encodeKittyImage, + encodeKittyVirtualImage, + readPngSize, + renderMermaidImageSync, +} from './mermaidImageRenderer.js'; + +const PNG_1X1 = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=', + 'base64', +); + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe('mermaid image renderer', () => { + it('detects forced terminal image protocols', () => { + expect( + detectTerminalImageProtocol({ + QWEN_CODE_MERMAID_IMAGE_PROTOCOL: 'kitty', + }), + ).toBe('kitty'); + expect( + detectTerminalImageProtocol({ + QWEN_CODE_MERMAID_IMAGE_PROTOCOL: 'iterm2', + }), + ).toBe('iterm2'); + expect( + detectTerminalImageProtocol({ + QWEN_CODE_MERMAID_IMAGE_PROTOCOL: 'off', + }), + ).toBeNull(); + }); + + it('encodes PNG data for terminal image protocols', () => { + expect(readPngSize(PNG_1X1)).toEqual({ width: 1, height: 1 }); + expect(encodeITerm2InlineImage(PNG_1X1, 40, 10)).toContain( + '\u001b]1337;File=inline=1;width=40;height=10;', + ); + expect(encodeKittyImage(PNG_1X1, 40, 10)).toContain( + '\u001b_Ga=T,f=100,c=40,r=10,', + ); + expect(encodeKittyVirtualImage(PNG_1X1, 42, 40, 10)).toContain( + '\u001b_Ga=T,f=100,i=42,q=2,U=1,c=40,r=10,', + ); + }); + + it('builds Kitty unicode placeholders for virtual placements', () => { + const placeholder = buildKittyPlaceholder(42, 3, 2); + + expect(placeholder.color).toBe('#00002a'); + expect(placeholder.lines).toEqual([ + '\u{10EEEE}\u{305}\u{305}\u{10EEEE}\u{305}\u{30D}\u{10EEEE}\u{305}\u{30E}', + '\u{10EEEE}\u{30D}\u{305}\u{10EEEE}\u{30D}\u{30D}\u{10EEEE}\u{30D}\u{30E}', + ]); + }); + + it('renders Mermaid through mmdc when terminal images are available', () => { + const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-mmdc-')); + tempDirs.push(binDir); + const fakeMmdc = path.join(binDir, 'mmdc'); + fs.writeFileSync( + fakeMmdc, + [ + '#!/usr/bin/env node', + 'const fs = require("node:fs");', + 'const out = process.argv[process.argv.indexOf("-o") + 1];', + `fs.writeFileSync(out, Buffer.from("${PNG_1X1.toString( + 'base64', + )}", "base64"));`, + ].join('\n'), + 'utf8', + ); + fs.chmodSync(fakeMmdc, 0o755); + + const result = renderMermaidImageSync({ + source: 'flowchart TD\n A[Start] --> B[End]', + contentWidth: 80, + availableTerminalHeight: 20, + env: { + PATH: `${binDir}${path.delimiter}${process.env['PATH'] ?? ''}`, + QWEN_CODE_MERMAID_IMAGE_PROTOCOL: 'iterm2', + }, + }); + + expect(result.kind).toBe('terminal-image'); + expect(result.kind === 'terminal-image' && result.protocol).toBe('iterm2'); + expect(result.kind === 'terminal-image' && result.sequence).toContain( + '\u001b]1337;File=inline=1;', + ); + }); + + it('renders Kitty terminal images as virtual placements', () => { + const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-mmdc-')); + tempDirs.push(binDir); + const fakeMmdc = path.join(binDir, 'mmdc'); + fs.writeFileSync( + fakeMmdc, + [ + '#!/usr/bin/env node', + 'const fs = require("node:fs");', + 'const out = process.argv[process.argv.indexOf("-o") + 1];', + `fs.writeFileSync(out, Buffer.from("${PNG_1X1.toString( + 'base64', + )}", "base64"));`, + ].join('\n'), + 'utf8', + ); + fs.chmodSync(fakeMmdc, 0o755); + + const result = renderMermaidImageSync({ + source: 'flowchart TD\n A[Start] --> B[End]', + contentWidth: 80, + availableTerminalHeight: 20, + env: { + PATH: `${binDir}${path.delimiter}${process.env['PATH'] ?? ''}`, + QWEN_CODE_MERMAID_IMAGE_PROTOCOL: 'kitty', + }, + }); + + expect(result.kind).toBe('terminal-image'); + expect(result.kind === 'terminal-image' && result.protocol).toBe('kitty'); + expect(result.kind === 'terminal-image' && result.sequence).toContain( + 'q=2,U=1', + ); + expect(result.kind === 'terminal-image' && result.placeholder).toBeTruthy(); + expect( + result.kind === 'terminal-image' && result.placeholder?.lines[0], + ).toContain('\u{10EEEE}'); + }); +}); diff --git a/packages/cli/src/ui/utils/mermaidImageRenderer.ts b/packages/cli/src/ui/utils/mermaidImageRenderer.ts new file mode 100644 index 00000000000..e668c0c171f --- /dev/null +++ b/packages/cli/src/ui/utils/mermaidImageRenderer.ts @@ -0,0 +1,699 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +export type TerminalImageProtocol = 'kitty' | 'iterm2'; + +export interface MermaidImageRenderOptions { + source: string; + contentWidth: number; + availableTerminalHeight?: number; + env?: NodeJS.ProcessEnv; +} + +export interface MermaidTerminalImageResult { + kind: 'terminal-image'; + title: string; + sequence: string; + rows: number; + protocol: TerminalImageProtocol; + placeholder?: KittyImagePlaceholder; +} + +export interface MermaidAnsiImageResult { + kind: 'ansi'; + title: string; + lines: string[]; +} + +export interface MermaidImageUnavailableResult { + kind: 'unavailable'; + reason: string; +} + +export type MermaidImageRenderResult = + | MermaidTerminalImageResult + | MermaidAnsiImageResult + | MermaidImageUnavailableResult; + +interface PngSize { + width: number; + height: number; +} + +export interface KittyImagePlaceholder { + color: string; + imageId: number; + lines: string[]; +} + +const CACHE_LIMIT = 40; +const PNG_CACHE_LIMIT = 20; +const DEFAULT_RENDER_TIMEOUT_MS = 8000; +const DEFAULT_MERMAID_RENDER_WIDTH = 1280; +const NPX_MERMAID_CLI = 'npx:@mermaid-js/mermaid-cli@11.12.0'; +const PNG_SIGNATURE = '89504e470d0a1a0a'; +const KITTY_PLACEHOLDER = '\u{10EEEE}'; +const KITTY_PLACEHOLDER_DIACRITICS = [ + '\u{305}', + '\u{30D}', + '\u{30E}', + '\u{310}', + '\u{312}', + '\u{33D}', + '\u{33E}', + '\u{33F}', + '\u{346}', + '\u{34A}', + '\u{34B}', + '\u{34C}', + '\u{350}', + '\u{351}', + '\u{352}', + '\u{357}', + '\u{35B}', + '\u{363}', + '\u{364}', + '\u{365}', + '\u{366}', + '\u{367}', + '\u{368}', + '\u{369}', + '\u{36A}', + '\u{36B}', + '\u{36C}', + '\u{36D}', + '\u{36E}', + '\u{36F}', + '\u{483}', + '\u{484}', + '\u{485}', + '\u{486}', + '\u{487}', + '\u{592}', + '\u{593}', + '\u{594}', + '\u{595}', + '\u{597}', + '\u{598}', + '\u{599}', + '\u{59C}', + '\u{59D}', + '\u{59E}', + '\u{59F}', + '\u{5A0}', + '\u{5A1}', + '\u{5A8}', + '\u{5A9}', + '\u{5AB}', + '\u{5AC}', + '\u{5AF}', + '\u{5C4}', + '\u{610}', + '\u{611}', + '\u{612}', + '\u{613}', + '\u{614}', + '\u{615}', + '\u{616}', + '\u{617}', + '\u{657}', + '\u{658}', + '\u{659}', + '\u{65A}', + '\u{65B}', + '\u{65D}', + '\u{65E}', + '\u{6D6}', + '\u{6D7}', + '\u{6D8}', + '\u{6D9}', + '\u{6DA}', + '\u{6DB}', + '\u{6DC}', + '\u{6DF}', + '\u{6E0}', + '\u{6E1}', + '\u{6E2}', + '\u{6E4}', + '\u{6E7}', + '\u{6E8}', + '\u{6EB}', + '\u{6EC}', + '\u{730}', + '\u{732}', + '\u{733}', + '\u{735}', + '\u{736}', + '\u{73A}', + '\u{73D}', + '\u{73F}', + '\u{740}', + '\u{741}', + '\u{743}', + '\u{745}', + '\u{747}', + '\u{749}', + '\u{74A}', + '\u{7EB}', + '\u{7EC}', + '\u{7ED}', + '\u{7EE}', + '\u{7EF}', + '\u{7F0}', + '\u{7F1}', + '\u{7F3}', + '\u{816}', + '\u{817}', + '\u{818}', + '\u{819}', + '\u{81B}', + '\u{81C}', + '\u{81D}', + '\u{81E}', + '\u{81F}', + '\u{820}', + '\u{821}', + '\u{822}', + '\u{823}', + '\u{825}', + '\u{826}', + '\u{827}', + '\u{829}', + '\u{82A}', + '\u{82B}', + '\u{82C}', +]; +const cachedResults = new Map(); +const cachedPngResults = new Map< + string, + { ok: true; png: Buffer } | { ok: false; error: string } +>(); + +export function detectTerminalImageProtocol( + env: NodeJS.ProcessEnv = process.env, +): TerminalImageProtocol | null { + const forced = env['QWEN_CODE_MERMAID_IMAGE_PROTOCOL']?.toLowerCase(); + if (forced) { + if (forced === 'kitty') return 'kitty'; + if (forced === 'iterm' || forced === 'iterm2') return 'iterm2'; + if (forced === 'off' || forced === 'none' || forced === '0') return null; + } + + if ( + env['QWEN_CODE_DISABLE_MERMAID_IMAGES'] === '1' || + !process.stdout.isTTY || + env['TMUX'] || + env['SSH_TTY'] || + env['SSH_CLIENT'] + ) { + return null; + } + + const term = env['TERM']?.toLowerCase() ?? ''; + const termProgram = env['TERM_PROGRAM']?.toLowerCase() ?? ''; + + if ( + env['KITTY_WINDOW_ID'] || + term.includes('kitty') || + termProgram.includes('ghostty') + ) { + return 'kitty'; + } + + if (termProgram === 'iterm.app' || termProgram.includes('wezterm')) { + return 'iterm2'; + } + + return null; +} + +export function encodeITerm2InlineImage( + png: Buffer, + widthCells: number, + rows: number, +): string { + return `\u001b]1337;File=inline=1;width=${widthCells};height=${rows};preserveAspectRatio=1:${png.toString( + 'base64', + )}\u0007`; +} + +export function encodeKittyImage( + png: Buffer, + widthCells: number, + rows: number, +): string { + return encodeKittyImageCommand(png, `a=T,f=100,c=${widthCells},r=${rows}`); +} + +export function encodeKittyVirtualImage( + png: Buffer, + imageId: number, + widthCells: number, + rows: number, +): string { + return encodeKittyImageCommand( + png, + `a=T,f=100,i=${imageId},q=2,U=1,c=${widthCells},r=${rows}`, + ); +} + +function encodeKittyImageCommand(png: Buffer, firstControl: string): string { + const encoded = png.toString('base64'); + const chunkSize = 4096; + const chunks: string[] = []; + + for (let offset = 0; offset < encoded.length; offset += chunkSize) { + const chunk = encoded.slice(offset, offset + chunkSize); + const hasMore = offset + chunkSize < encoded.length; + const control = + offset === 0 + ? `${firstControl},m=${hasMore ? 1 : 0}` + : `m=${hasMore ? 1 : 0}`; + chunks.push(`\u001b_G${control};${chunk}\u001b\\`); + } + + return chunks.join(''); +} + +export function buildKittyPlaceholder( + imageId: number, + widthCells: number, + rows: number, +): KittyImagePlaceholder { + const clampedRows = Math.min(rows, KITTY_PLACEHOLDER_DIACRITICS.length); + const lines = Array.from({ length: clampedRows }, (_, row) => { + const rowDiacritic = KITTY_PLACEHOLDER_DIACRITICS[row]; + const cells = Array.from({ length: widthCells }, (_, column) => { + const columnDiacritic = KITTY_PLACEHOLDER_DIACRITICS[column]; + return `${KITTY_PLACEHOLDER}${rowDiacritic}${columnDiacritic}`; + }); + return cells.join(''); + }); + + return { + color: `#${imageId.toString(16).padStart(6, '0')}`, + imageId, + lines, + }; +} + +export function readPngSize(png: Buffer): PngSize | null { + if (png.length < 24 || png.subarray(0, 8).toString('hex') !== PNG_SIGNATURE) { + return null; + } + + return { + width: png.readUInt32BE(16), + height: png.readUInt32BE(20), + }; +} + +export function renderMermaidImageSync({ + source, + contentWidth, + availableTerminalHeight, + env = process.env, +}: MermaidImageRenderOptions): MermaidImageRenderResult { + if ( + env['QWEN_CODE_MERMAID_IMAGE_RENDERING'] === '0' || + env['QWEN_CODE_MERMAID_IMAGE_RENDERING'] === 'off' + ) { + return { + kind: 'unavailable', + reason: 'Mermaid image rendering is disabled.', + }; + } + + const protocol = detectTerminalImageProtocol(env); + const chafa = protocol ? null : findExecutable('chafa', env); + if (!protocol && !chafa) { + return { + kind: 'unavailable', + reason: + 'No supported terminal image protocol or chafa renderer was detected.', + }; + } + + const mmdc = findMmdc(env); + if (!mmdc) { + return { + kind: 'unavailable', + reason: + 'Mermaid CLI (mmdc) was not found. Install @mermaid-js/mermaid-cli, set QWEN_CODE_MERMAID_MMD_CLI, or set QWEN_CODE_MERMAID_ALLOW_NPX=1.', + }; + } + + const cacheKey = createCacheKey( + source, + contentWidth, + availableTerminalHeight, + protocol ?? `chafa:${chafa}`, + mmdc, + ); + const cached = cachedResults.get(cacheKey); + if (cached) return cached; + + const pngCacheKey = createPngCacheKey(source, mmdc, env); + const cachedPng = cachedPngResults.get(pngCacheKey); + const rendered = + cachedPng ?? rememberPng(pngCacheKey, renderPngWithMmdc(source, mmdc, env)); + if (!rendered.ok) { + return remember(cacheKey, { + kind: 'unavailable', + reason: rendered.error, + }); + } + + const pngSize = readPngSize(rendered.png); + if (!pngSize) { + return remember(cacheKey, { + kind: 'unavailable', + reason: 'Mermaid CLI did not produce a valid PNG.', + }); + } + + const imageShape = fitImageToTerminal( + pngSize, + contentWidth, + availableTerminalHeight, + ); + + if (protocol) { + const imageId = + protocol === 'kitty' + ? createKittyImageId(rendered.png, imageShape) + : undefined; + const sequence = + protocol === 'kitty' + ? encodeKittyVirtualImage( + rendered.png, + imageId!, + imageShape.widthCells, + imageShape.rows, + ) + : encodeITerm2InlineImage( + rendered.png, + imageShape.widthCells, + imageShape.rows, + ); + return remember(cacheKey, { + kind: 'terminal-image', + title: `Mermaid diagram image (${protocol})`, + sequence, + rows: imageShape.rows, + protocol, + placeholder: + protocol === 'kitty' + ? buildKittyPlaceholder( + imageId!, + imageShape.widthCells, + imageShape.rows, + ) + : undefined, + }); + } + + const ansi = renderPngWithChafa( + rendered.png, + imageShape.widthCells, + imageShape.rows, + chafa!, + env, + ); + if (!ansi.ok) { + return remember(cacheKey, { + kind: 'unavailable', + reason: ansi.error, + }); + } + + return remember(cacheKey, { + kind: 'ansi', + title: 'Mermaid diagram image (ANSI)', + lines: ansi.output.split(/\r?\n/).filter((line) => line.length > 0), + }); +} + +function createKittyImageId( + png: Buffer, + imageShape: { widthCells: number; rows: number }, +): number { + const hash = crypto + .createHash('sha256') + .update(png) + .update('\0') + .update(String(imageShape.widthCells)) + .update('\0') + .update(String(imageShape.rows)) + .digest(); + const id = hash.readUIntBE(0, 3); + return id === 0 ? 1 : id; +} + +function createPngCacheKey( + source: string, + mmdc: string, + env: NodeJS.ProcessEnv, +): string { + return crypto + .createHash('sha256') + .update(source) + .update('\0') + .update(mmdc) + .update('\0') + .update(String(getMermaidRenderWidth(env))) + .digest('hex'); +} + +function createCacheKey( + source: string, + contentWidth: number, + availableTerminalHeight: number | undefined, + renderer: string, + mmdc: string, +): string { + return crypto + .createHash('sha256') + .update(source) + .update('\0') + .update(String(contentWidth)) + .update('\0') + .update(String(availableTerminalHeight ?? 'auto')) + .update('\0') + .update(renderer) + .update('\0') + .update(mmdc) + .digest('hex'); +} + +function remember( + key: string, + result: T, +): T { + cachedResults.set(key, result); + if (cachedResults.size > CACHE_LIMIT) { + const oldest = cachedResults.keys().next().value; + if (oldest) cachedResults.delete(oldest); + } + return result; +} + +function rememberPng< + T extends { ok: true; png: Buffer } | { ok: false; error: string }, +>(key: string, result: T): T { + cachedPngResults.set(key, result); + if (cachedPngResults.size > PNG_CACHE_LIMIT) { + const oldest = cachedPngResults.keys().next().value; + if (oldest) cachedPngResults.delete(oldest); + } + return result; +} + +function findMmdc(env: NodeJS.ProcessEnv): string | null { + const explicit = env['QWEN_CODE_MERMAID_MMD_CLI']; + if (explicit && isExecutable(explicit)) return explicit; + + const mmdc = findExecutable('mmdc', env); + if (mmdc) return mmdc; + + if ( + env['QWEN_CODE_MERMAID_ALLOW_NPX'] === '1' && + findExecutable('npx', env) + ) { + return NPX_MERMAID_CLI; + } + + return null; +} + +function findExecutable( + command: string, + env: NodeJS.ProcessEnv, +): string | null { + const candidates: string[] = []; + const extensions = + process.platform === 'win32' + ? (env['PATHEXT'] ?? '.EXE;.CMD;.BAT;.COM').split(';').filter(Boolean) + : ['']; + + const addCandidates = (dir: string) => { + for (const extension of extensions) { + candidates.push(path.join(dir, `${command}${extension}`)); + } + }; + + addCandidates(path.join(process.cwd(), 'node_modules', '.bin')); + for (const dir of (env['PATH'] ?? '').split(path.delimiter).filter(Boolean)) { + addCandidates(dir); + } + + return candidates.find(isExecutable) ?? null; +} + +function isExecutable(filePath: string): boolean { + try { + fs.accessSync(filePath, fs.constants.X_OK); + return true; + } catch { + return false; + } +} + +function renderPngWithMmdc( + source: string, + mmdc: string, + env: NodeJS.ProcessEnv, +): { ok: true; png: Buffer } | { ok: false; error: string } { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-mermaid-')); + const inputPath = path.join(tempDir, 'diagram.mmd'); + const outputPath = path.join(tempDir, 'diagram.png'); + const renderWidth = getMermaidRenderWidth(env); + + try { + fs.writeFileSync(inputPath, source, 'utf8'); + const mmdcArgs = [ + '-i', + inputPath, + '-o', + outputPath, + '-b', + 'transparent', + '-w', + String(renderWidth), + ]; + const command = + mmdc === NPX_MERMAID_CLI ? findExecutable('npx', env)! : mmdc; + const args = + mmdc === NPX_MERMAID_CLI + ? ['-y', '@mermaid-js/mermaid-cli@11.12.0', ...mmdcArgs] + : mmdcArgs; + const result = spawnSync(command, args, { + encoding: 'utf8', + env: { + ...process.env, + ...env, + }, + timeout: Number( + env['QWEN_CODE_MERMAID_RENDER_TIMEOUT_MS'] ?? DEFAULT_RENDER_TIMEOUT_MS, + ), + }); + + if (result.error) { + return { ok: false, error: result.error.message }; + } + if (result.status !== 0) { + const stderr = result.stderr?.trim(); + return { + ok: false, + error: stderr || `Mermaid CLI exited with status ${result.status}.`, + }; + } + if (!fs.existsSync(outputPath)) { + return { ok: false, error: 'Mermaid CLI did not write an output file.' }; + } + + return { ok: true, png: fs.readFileSync(outputPath) }; + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function getMermaidRenderWidth(env: NodeJS.ProcessEnv): number { + const configuredWidth = Number(env['QWEN_CODE_MERMAID_RENDER_WIDTH']); + if (Number.isFinite(configuredWidth) && configuredWidth > 0) { + return Math.max(320, Math.min(1800, Math.round(configuredWidth))); + } + return DEFAULT_MERMAID_RENDER_WIDTH; +} + +function renderPngWithChafa( + png: Buffer, + widthCells: number, + rows: number, + chafa: string, + env: NodeJS.ProcessEnv, +): { ok: true; output: string } | { ok: false; error: string } { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-mermaid-')); + const imagePath = path.join(tempDir, 'diagram.png'); + + try { + fs.writeFileSync(imagePath, png); + const result = spawnSync( + chafa, + [ + '--animate=off', + '--format=symbols', + '--symbols=block', + `--size=${widthCells}x${rows}`, + imagePath, + ], + { + encoding: 'utf8', + env: { + ...process.env, + ...env, + }, + timeout: 2000, + }, + ); + + if (result.error) { + return { ok: false, error: result.error.message }; + } + if (result.status !== 0) { + return { + ok: false, + error: + result.stderr?.trim() || `chafa exited with status ${result.status}.`, + }; + } + + return { ok: true, output: result.stdout }; + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } +} + +function fitImageToTerminal( + size: PngSize, + contentWidth: number, + availableTerminalHeight: number | undefined, +): { widthCells: number; rows: number } { + const widthCells = Math.max(16, Math.min(contentWidth, 120)); + const naturalRows = Math.ceil((size.height / size.width) * widthCells * 0.5); + const maxRows = Math.max(4, Math.min(availableTerminalHeight ?? 32, 60)); + + return { + widthCells, + rows: Math.max(4, Math.min(naturalRows, maxRows)), + }; +} diff --git a/packages/cli/src/ui/utils/mermaidVisualRenderer.ts b/packages/cli/src/ui/utils/mermaidVisualRenderer.ts new file mode 100644 index 00000000000..50d756df2ae --- /dev/null +++ b/packages/cli/src/ui/utils/mermaidVisualRenderer.ts @@ -0,0 +1,933 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import stringWidth from 'string-width'; + +interface FlowNode { + id: string; + label: string; + shape: FlowNodeShape; +} + +interface FlowEdge { + from: FlowNode; + to: FlowNode; + label?: string; +} + +interface MermaidVisualResult { + title: string; + lines: string[]; + warning?: string; +} + +type FlowNodeShape = 'rect' | 'diamond' | 'round'; + +interface FlowGraph { + nodes: Map; + outgoing: Map; + incomingCount: Map; + roots: FlowNode[]; +} + +interface PositionedNode { + node: FlowNode; + lines: string[]; + x: number; + y: number; + width: number; + height: number; + centerX: number; + centerY: number; + rank: number; +} + +const FLOW_START_RE = /^(?:flowchart|graph)\s+([A-Za-z]{2})/i; +const SEQUENCE_START_RE = /^sequenceDiagram\b/i; +const LINE_COMMENT_RE = /^%%/; +const MAX_RENDERED_LINES = 80; +const MIN_CANVAS_WIDTH = 24; +const NODE_GAP_X = 4; +const NODE_GAP_Y = 4; + +function truncateToWidth(text: string, width: number): string { + if (width <= 0 || stringWidth(text) <= width) return text; + let result = ''; + for (const char of text) { + if (stringWidth(result + char + '…') > width) break; + result += char; + } + return result + '…'; +} + +function center(text: string, width: number): string { + const padding = Math.max(0, width - stringWidth(text)); + const left = Math.floor(padding / 2); + return ' '.repeat(left) + text + ' '.repeat(padding - left); +} + +function stripMermaidPunctuation(text: string): string { + return text + .trim() + .replace(/[;,]+$/g, '') + .trim(); +} + +function normalizeNodeLabel(label: string): string { + return label + .replace(/^["']|["']$/g, '') + .replace(//gi, '\n') + .replace(/\\n/g, '\n'); +} + +function nodeLabelLines(label: string): string[] { + return label + .split(/\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0); +} + +function singleLineLabel(label: string): string { + return nodeLabelLines(label).join(' '); +} + +function parseNodeToken(rawToken: string): FlowNode | null { + const token = stripMermaidPunctuation(rawToken) + .replace(/^\|.*?\|/, '') + .trim(); + if (!token || /^subgraph\b|^end$/i.test(token)) return null; + + const idMatch = /^([A-Za-z0-9_.$:-]+)\s*(.*)$/.exec(token); + if (!idMatch) { + return { + id: token, + label: normalizeNodeLabel(token), + shape: 'rect', + }; + } + + const id = idMatch[1]!; + const rest = idMatch[2]!.trim(); + const labelMatch = + /^\[\[(.+)\]\]$/.exec(rest) ?? + /^\[(.+)\]$/.exec(rest) ?? + /^\(\((.+)\)\)$/.exec(rest) ?? + /^\((.+)\)$/.exec(rest) ?? + /^\{(.+)\}$/.exec(rest) ?? + /^>\s*(.+)\]$/.exec(rest); + const shape: FlowNodeShape = /^\{(.+)\}$/.test(rest) + ? 'diamond' + : /^\(\((.+)\)\)$/.test(rest) || /^\((.+)\)$/.test(rest) + ? 'round' + : 'rect'; + + return { + id, + label: normalizeNodeLabel(labelMatch?.[1] ?? id), + shape, + }; +} + +function parseFlowEdge(line: string): FlowEdge | null { + const patterns: Array<{ + re: RegExp; + labelIndex?: number; + fromIndex: number; + toIndex: number; + }> = [ + { + re: /^(.+?)\s*--\s*(.+?)\s*-->\s*(.+)$/i, + fromIndex: 1, + labelIndex: 2, + toIndex: 3, + }, + { + re: /^(.+?)\s*-->\|(.+?)\|\s*(.+)$/i, + fromIndex: 1, + labelIndex: 2, + toIndex: 3, + }, + { + re: /^(.+?)\s*(?:-->|---|==>|-\.->|--x|--o)\s*(.+)$/i, + fromIndex: 1, + toIndex: 2, + }, + ]; + + for (const pattern of patterns) { + const match = pattern.re.exec(line); + if (!match) continue; + const from = parseNodeToken(match[pattern.fromIndex]!); + const to = parseNodeToken(match[pattern.toIndex]!); + if (!from || !to) return null; + const label = + pattern.labelIndex !== undefined + ? stripMermaidPunctuation(match[pattern.labelIndex]!) + : undefined; + return { from, to, label: label || undefined }; + } + + return null; +} + +function normalizeFlowNodeLabels(edges: FlowEdge[]): FlowEdge[] { + const labelById = new Map(); + const shapeById = new Map(); + + for (const edge of edges) { + for (const node of [edge.from, edge.to]) { + if (node.label !== node.id && !labelById.has(node.id)) { + labelById.set(node.id, node.label); + } + if (node.shape !== 'rect' && !shapeById.has(node.id)) { + shapeById.set(node.id, node.shape); + } + } + } + + return edges.map((edge) => ({ + ...edge, + from: { + ...edge.from, + label: labelById.get(edge.from.id) ?? edge.from.label, + shape: shapeById.get(edge.from.id) ?? edge.from.shape, + }, + to: { + ...edge.to, + label: labelById.get(edge.to.id) ?? edge.to.label, + shape: shapeById.get(edge.to.id) ?? edge.to.shape, + }, + })); +} + +function boxNode(node: FlowNode, width: number): string[] { + const labels = nodeLabelLines(node.label).map((line) => + truncateToWidth(line, Math.max(3, width - 4)), + ); + const innerWidth = Math.max(4, ...labels.map((label) => stringWidth(label))); + if (node.shape === 'diamond') { + return [ + ` ╱${'─'.repeat(innerWidth + 2)}╲ `, + ...labels.map((label) => ` ◇ ${center(label, innerWidth)} ◇ `), + ` ╲${'─'.repeat(innerWidth + 2)}╱ `, + ]; + } + + if (node.shape === 'round') { + return [ + `╭${'─'.repeat(innerWidth + 2)}╮`, + ...labels.map((label) => `│ ${center(label, innerWidth)} │`), + `╰${'─'.repeat(innerWidth + 2)}╯`, + ]; + } + + return [ + `┌${'─'.repeat(innerWidth + 2)}┐`, + ...labels.map((label) => `│ ${center(label, innerWidth)} │`), + `└${'─'.repeat(innerWidth + 2)}┘`, + ]; +} + +function buildFlowGraph(edges: FlowEdge[]): FlowGraph { + const nodes = new Map(); + const outgoing = new Map(); + const incomingCount = new Map(); + + for (const edge of edges) { + nodes.set(edge.from.id, edge.from); + nodes.set(edge.to.id, edge.to); + const outgoingEdges = outgoing.get(edge.from.id) ?? []; + outgoingEdges.push(edge); + outgoing.set(edge.from.id, outgoingEdges); + incomingCount.set(edge.to.id, (incomingCount.get(edge.to.id) ?? 0) + 1); + if (!incomingCount.has(edge.from.id)) incomingCount.set(edge.from.id, 0); + } + + const roots = Array.from(nodes.values()).filter( + (node) => (incomingCount.get(node.id) ?? 0) === 0, + ); + + return { + nodes, + outgoing, + incomingCount, + roots: roots.length > 0 ? roots : [edges[0]!.from], + }; +} + +function renderNodeLines(node: FlowNode, maxWidth: number): string[] { + return boxNode(node, Math.max(8, maxWidth)); +} + +function lineWidth(line: string): number { + return stringWidth(line); +} + +function computeRanks(graph: FlowGraph): Map { + const ranks = new Map(); + const queue = [...graph.roots]; + + for (const root of graph.roots) { + ranks.set(root.id, 0); + } + + while (queue.length > 0) { + const node = queue.shift()!; + const rank = ranks.get(node.id) ?? 0; + for (const edge of graph.outgoing.get(node.id) ?? []) { + if (ranks.has(edge.to.id)) continue; + ranks.set(edge.to.id, rank + 1); + queue.push(edge.to); + } + } + + for (const node of graph.nodes.values()) { + if (!ranks.has(node.id)) ranks.set(node.id, 0); + } + + return ranks; +} + +function branchPreference(label: string | undefined): number { + if (!label) return 0; + const normalized = label.trim().toLowerCase(); + if (/^(no|false|fail|failed|否|不|失败)$/.test(normalized)) return -1; + if (/^(yes|true|pass|passed|是|成功)$/.test(normalized)) return 1; + return 0; +} + +function groupNodesByRank( + graph: FlowGraph, + ranks: Map, +): FlowNode[][] { + const layers: FlowNode[][] = []; + const preferenceById = new Map(); + const parentEdgesById = new Map(); + const originalIndexById = new Map(); + + Array.from(graph.nodes.values()).forEach((node, index) => { + originalIndexById.set(node.id, index); + }); + + for (const edgeList of graph.outgoing.values()) { + for (const edge of edgeList) { + const parentEdges = parentEdgesById.get(edge.to.id) ?? []; + parentEdges.push(edge); + parentEdgesById.set(edge.to.id, parentEdges); + const preference = branchPreference(edge.label); + if (preference !== 0 && !preferenceById.has(edge.to.id)) { + preferenceById.set(edge.to.id, preference); + } + } + } + + for (const node of graph.nodes.values()) { + const rank = ranks.get(node.id) ?? 0; + layers[rank] ??= []; + layers[rank]!.push(node); + } + const orderById = new Map(); + for (const [rank, layer] of layers.entries()) { + layer?.sort((a, b) => { + const parentOrderDelta = + parentOrder(a, rank, ranks, parentEdgesById, orderById) - + parentOrder(b, rank, ranks, parentEdgesById, orderById); + if (parentOrderDelta !== 0) return parentOrderDelta; + const preferenceDelta = + (preferenceById.get(a.id) ?? 0) - (preferenceById.get(b.id) ?? 0); + if (preferenceDelta !== 0) return preferenceDelta; + return ( + (originalIndexById.get(a.id) ?? 0) - (originalIndexById.get(b.id) ?? 0) + ); + }); + layer?.forEach((node, index) => { + orderById.set(node.id, index); + }); + } + return layers.filter((layer) => layer.length > 0); +} + +function parentOrder( + node: FlowNode, + rank: number, + ranks: Map, + parentEdgesById: Map, + orderById: Map, +): number { + const parentEdges = parentEdgesById.get(node.id) ?? []; + const orders = parentEdges + .filter((edge) => (ranks.get(edge.from.id) ?? 0) < rank) + .map((edge) => orderById.get(edge.from.id)) + .filter((order): order is number => order !== undefined); + + if (orders.length === 0) return Number.POSITIVE_INFINITY; + return orders.reduce((sum, order) => sum + order, 0) / orders.length; +} + +function createCanvas(width: number, height: number): string[][] { + return Array.from({ length: height }, () => Array(width).fill(' ')); +} + +function mergeCanvasChar(existing: string, next: string): string { + if (existing === ' ' || existing === next) return next; + if ('▼▲◀▶→←↩'.includes(existing)) return existing; + if ('▼▲◀▶→←↩'.includes(next)) return next; + if ( + (existing === '│' && next === '─') || + (existing === '─' && next === '│') || + existing === '┼' || + next === '┼' + ) { + return '┼'; + } + if ('┌┐└┘╭╮╰╯╱╲◇'.includes(existing)) return existing; + return next; +} + +function putChar( + canvas: string[][], + x: number, + y: number, + char: string, + overwrite = false, +): void { + if (y < 0 || y >= canvas.length || x < 0 || x >= canvas[y]!.length) return; + canvas[y]![x] = overwrite ? char : mergeCanvasChar(canvas[y]![x]!, char); +} + +function putText( + canvas: string[][], + x: number, + y: number, + text: string, + overwrite = false, +): void { + let cursor = x; + for (const char of text) { + const width = Math.max(1, stringWidth(char)); + putChar(canvas, cursor, y, char, overwrite); + for (let offset = 1; offset < width; offset++) { + if ( + y >= 0 && + y < canvas.length && + cursor + offset >= 0 && + cursor + offset < canvas[y]!.length + ) { + canvas[y]![cursor + offset] = ''; + } + } + cursor += width; + } +} + +function drawHorizontal( + canvas: string[][], + y: number, + x1: number, + x2: number, +): void { + const start = Math.min(x1, x2); + const end = Math.max(x1, x2); + for (let x = start; x <= end; x++) putChar(canvas, x, y, '─'); +} + +function drawVertical( + canvas: string[][], + x: number, + y1: number, + y2: number, +): void { + const start = Math.min(y1, y2); + const end = Math.max(y1, y2); + for (let y = start; y <= end; y++) putChar(canvas, x, y, '│'); +} + +function drawNode(canvas: string[][], positioned: PositionedNode): void { + positioned.lines.forEach((line, offset) => { + putText(canvas, positioned.x, positioned.y + offset, line, true); + }); +} + +function layoutVertical( + graph: FlowGraph, + contentWidth: number, +): PositionedNode[] { + const ranks = computeRanks(graph); + const layers = groupNodesByRank(graph, ranks); + const positioned: PositionedNode[] = []; + let y = 0; + + for (const layer of layers) { + const gapCount = Math.max(0, layer.length - 1); + const maxNodeWidth = Math.max( + 8, + Math.floor((contentWidth - gapCount * NODE_GAP_X) / layer.length), + ); + const rendered = layer.map((node) => ({ + node, + lines: renderNodeLines(node, Math.min(28, maxNodeWidth)), + })); + const totalWidth = + rendered.reduce((sum, item) => sum + lineWidth(item.lines[0]!), 0) + + gapCount * NODE_GAP_X; + let x = Math.max(0, Math.floor((contentWidth - totalWidth) / 2)); + const layerHeight = Math.max(...rendered.map((item) => item.lines.length)); + + for (const item of rendered) { + const width = lineWidth(item.lines[0]!); + const height = item.lines.length; + positioned.push({ + node: item.node, + lines: item.lines, + x, + y, + width, + height, + centerX: x + Math.floor(width / 2), + centerY: y + Math.floor(height / 2), + rank: ranks.get(item.node.id) ?? 0, + }); + x += width + NODE_GAP_X; + } + + y += layerHeight + NODE_GAP_Y; + } + + return positioned; +} + +function layoutHorizontal( + graph: FlowGraph, + contentWidth: number, +): PositionedNode[] | null { + const ranks = computeRanks(graph); + const layers = groupNodesByRank(graph, ranks); + const columnWidth = Math.max( + 10, + Math.min( + 24, + Math.floor( + (contentWidth - (layers.length - 1) * NODE_GAP_X) / layers.length, + ), + ), + ); + const totalWidth = + layers.length * columnWidth + (layers.length - 1) * NODE_GAP_X; + if (totalWidth > contentWidth || layers.length === 0) return null; + + const positioned: PositionedNode[] = []; + let x = Math.max(0, Math.floor((contentWidth - totalWidth) / 2)); + + for (const layer of layers) { + let y = 0; + for (const node of layer) { + const lines = renderNodeLines(node, columnWidth); + const width = lineWidth(lines[0]!); + positioned.push({ + node, + lines, + x: x + Math.floor((columnWidth - width) / 2), + y, + width, + height: lines.length, + centerX: x + Math.floor(columnWidth / 2), + centerY: y + Math.floor(lines.length / 2), + rank: ranks.get(node.id) ?? 0, + }); + y += lines.length + NODE_GAP_Y; + } + x += columnWidth + NODE_GAP_X; + } + + return positioned; +} + +function drawForwardVerticalEdge( + canvas: string[][], + from: PositionedNode, + to: PositionedNode, + label: string | undefined, +): void { + const startY = from.y + from.height; + const endY = to.y - 1; + const midY = Math.max(startY, Math.floor((startY + endY) / 2)); + + if (Math.abs(from.centerX - to.centerX) <= 1) { + drawVertical(canvas, from.centerX, startY, endY); + putChar(canvas, from.centerX, endY, '▼'); + if (label) { + putText( + canvas, + Math.min(canvas[midY]!.length - 1, from.centerX + 2), + midY, + truncateToWidth(label, 14), + ); + } + return; + } + + const bendY = Math.max(startY, Math.min(midY, endY - 1)); + const targetIsRight = to.centerX > from.centerX; + drawVertical(canvas, from.centerX, startY, bendY); + drawHorizontal(canvas, bendY, from.centerX, to.centerX); + if (bendY + 1 <= endY) { + drawVertical(canvas, to.centerX, bendY + 1, endY); + } + putChar(canvas, from.centerX, bendY, targetIsRight ? '└' : '┘', true); + putChar(canvas, to.centerX, bendY, targetIsRight ? '┐' : '┌', true); + putChar(canvas, to.centerX, endY, '▼'); + + if (label) { + const text = truncateToWidth(label, 14); + const labelX = + Math.abs(to.centerX - from.centerX) > stringWidth(text) + 2 + ? Math.min(from.centerX, to.centerX) + + Math.floor( + (Math.abs(to.centerX - from.centerX) - stringWidth(text)) / 2, + ) + : Math.min(canvas[bendY]!.length - stringWidth(text), from.centerX + 2); + putText(canvas, Math.max(0, labelX), bendY, text, true); + } +} + +function drawVerticalFork( + canvas: string[][], + from: PositionedNode, + targets: Array<{ edge: FlowEdge; to: PositionedNode }>, +): void { + if (targets.length === 0) return; + if (targets.length === 1) { + drawForwardVerticalEdge( + canvas, + from, + targets[0]!.to, + targets[0]!.edge.label, + ); + return; + } + + const sortedTargets = [...targets].sort( + (a, b) => a.to.centerX - b.to.centerX, + ); + const startY = from.y + from.height; + const firstTargetTop = Math.min( + ...sortedTargets.map((target) => target.to.y), + ); + const forkY = Math.max(startY, firstTargetTop - 3); + const labelY = Math.min(forkY + 1, firstTargetTop - 2); + const minX = Math.min(...sortedTargets.map((target) => target.to.centerX)); + const maxX = Math.max(...sortedTargets.map((target) => target.to.centerX)); + + drawVertical(canvas, from.centerX, startY, forkY); + drawHorizontal(canvas, forkY, minX, maxX); + putChar(canvas, from.centerX, forkY, '┴', true); + + for (const [index, target] of sortedTargets.entries()) { + const endY = target.to.y - 1; + const targetJunction = + sortedTargets.length === 1 + ? '┴' + : index === 0 + ? '┌' + : index === sortedTargets.length - 1 + ? '┐' + : '┬'; + putChar(canvas, target.to.centerX, forkY, targetJunction, true); + putChar(canvas, target.to.centerX, endY, '▼'); + if (target.edge.label) { + const label = `[${truncateToWidth(target.edge.label, 10)}]`; + const x = Math.max( + 0, + Math.min( + canvas[labelY]!.length - stringWidth(label), + target.to.centerX - Math.floor(stringWidth(label) / 2), + ), + ); + putText(canvas, x, labelY, label, true); + } + if (forkY + 1 <= labelY - 1) { + drawVertical(canvas, target.to.centerX, forkY + 1, labelY - 1); + } + if (labelY + 1 <= endY) { + drawVertical(canvas, target.to.centerX, labelY + 1, endY); + } + } +} + +function formatLoopNote( + from: PositionedNode, + to: PositionedNode, + label: string | undefined, +): string { + const edgeLabel = label ? ` [${label}]` : ''; + return `${singleLineLabel(from.node.label)}${edgeLabel} ↩ to ${singleLineLabel( + to.node.label, + )}`; +} + +function drawHorizontalEdge( + canvas: string[][], + from: PositionedNode, + to: PositionedNode, + label: string | undefined, +): void { + const forward = to.rank > from.rank; + const fromX = forward ? from.x + from.width : from.x - 1; + const toX = forward ? to.x - 1 : to.x + to.width; + const midX = Math.floor((fromX + toX) / 2); + + if (from.centerY === to.centerY) { + drawHorizontal(canvas, from.centerY, fromX, toX); + putChar(canvas, toX, to.centerY, forward ? '▶' : '◀'); + if (label) { + const text = truncateToWidth(label, 12); + putText( + canvas, + Math.max(0, midX - Math.floor(stringWidth(text) / 2)), + from.centerY, + text, + ); + } + return; + } + + drawHorizontal(canvas, from.centerY, fromX, midX); + drawVertical(canvas, midX, from.centerY, to.centerY); + drawHorizontal(canvas, to.centerY, midX, toX); + putChar(canvas, toX, to.centerY, forward ? '▶' : '◀'); + + if (label) { + const text = truncateToWidth(label, 12); + putText( + canvas, + Math.max(0, midX - Math.floor(stringWidth(text) / 2)), + Math.min(from.centerY, to.centerY), + text, + ); + } +} + +function canvasToLines(canvas: string[][], contentWidth: number): string[] { + return canvas + .map((row) => truncateToWidth(row.join('').trimEnd(), contentWidth)) + .filter( + (line, index, lines) => line.length > 0 || index < lines.length - 1, + ); +} + +function renderLayeredFlowchart( + edges: FlowEdge[], + contentWidth: number, + horizontal: boolean, +): string[] { + const width = Math.max(MIN_CANVAS_WIDTH, contentWidth); + const graph = buildFlowGraph(edges); + const positioned = + horizontal && graph.nodes.size <= 8 + ? (layoutHorizontal(graph, width) ?? layoutVertical(graph, width)) + : layoutVertical(graph, width); + const byId = new Map(positioned.map((node) => [node.node.id, node])); + const canvasHeight = + Math.max(...positioned.map((node) => node.y + node.height), 1) + 2; + const canvas = createCanvas(width, canvasHeight); + const loopNotes: string[] = []; + + for (const edge of edges) { + if (!horizontal && (graph.outgoing.get(edge.from.id)?.length ?? 0) > 1) { + continue; + } + const from = byId.get(edge.from.id); + const to = byId.get(edge.to.id); + if (!from || !to) continue; + if (horizontal && to.rank !== from.rank) { + drawHorizontalEdge(canvas, from, to, edge.label); + } else if (to.rank > from.rank) { + drawForwardVerticalEdge(canvas, from, to, edge.label); + } else { + loopNotes.push(formatLoopNote(from, to, edge.label)); + } + } + + if (!horizontal) { + for (const edgeList of graph.outgoing.values()) { + if (edgeList.length <= 1) continue; + const source = byId.get(edgeList[0]!.from.id); + if (!source) continue; + const forwardTargets: Array<{ edge: FlowEdge; to: PositionedNode }> = []; + for (const edge of edgeList) { + const target = byId.get(edge.to.id); + if (!target) continue; + if (target.rank > source.rank) { + forwardTargets.push({ edge, to: target }); + } else { + loopNotes.push(formatLoopNote(source, target, edge.label)); + } + } + drawVerticalFork(canvas, source, forwardTargets); + } + } + + for (const node of positioned) { + drawNode(canvas, node); + } + + const lines = canvasToLines(canvas, contentWidth); + if (loopNotes.length > 0) { + lines.push(''); + lines.push('Cycles:'); + for (const note of loopNotes) { + lines.push(truncateToWidth(` ${note}`, contentWidth)); + } + } + + return lines; +} + +function renderFlowchart( + source: string, + contentWidth: number, +): MermaidVisualResult { + const rawLines = source + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !LINE_COMMENT_RE.test(line)); + const first = rawLines[0] ?? ''; + const direction = FLOW_START_RE.exec(first)?.[1]?.toUpperCase() ?? 'TD'; + const lines = rawLines.slice(FLOW_START_RE.test(first) ? 1 : 0); + const edgeLines = lines.flatMap((line) => + line + .split(';') + .map((part) => part.trim()) + .filter(Boolean), + ); + const edges = edgeLines + .map(parseFlowEdge) + .filter((edge): edge is FlowEdge => edge !== null); + const normalizedEdges = normalizeFlowNodeLabels(edges); + + if (normalizedEdges.length === 0) { + return { + title: 'Mermaid flowchart', + lines: [ + '┌ Mermaid diagram ┐', + '│ No previewable edges found. │', + '└─────────────────┘', + ], + warning: 'Flowchart preview supports simple A --> B style edges.', + }; + } + + const horizontal = direction.includes('LR') || direction.includes('RL'); + const rendered = renderLayeredFlowchart( + normalizedEdges, + contentWidth, + horizontal, + ); + + return { + title: `Mermaid flowchart (${direction})`, + lines: rendered.slice(0, MAX_RENDERED_LINES), + warning: + rendered.length > MAX_RENDERED_LINES + ? `Preview truncated to ${MAX_RENDERED_LINES} lines.` + : undefined, + }; +} + +function parseParticipant(line: string): { id: string; label: string } | null { + const match = /^(?:participant|actor)\s+(.+?)(?:\s+as\s+(.+))?$/i.exec(line); + if (!match) return null; + const id = stripMermaidPunctuation(match[1]!); + return { + id, + label: stripMermaidPunctuation(match[2] ?? id), + }; +} + +function renderSequence( + source: string, + contentWidth: number, +): MermaidVisualResult { + const rawLines = source + .split(/\r?\n/) + .map((line) => line.trim()) + .filter( + (line) => + line.length > 0 && + !LINE_COMMENT_RE.test(line) && + !SEQUENCE_START_RE.test(line), + ); + const participants = new Map(); + const messages: string[] = []; + + for (const line of rawLines) { + const participant = parseParticipant(line); + if (participant) { + participants.set(participant.id, participant.label); + continue; + } + + const messageMatch = + /^(.+?)(-->>|->>|-->|->|--x|-x)\s*(.+?)\s*:\s*(.+)$/.exec(line); + if (!messageMatch) continue; + const from = stripMermaidPunctuation(messageMatch[1]!); + const arrow = messageMatch[2]!.includes('--') ? '⇢' : '→'; + const to = stripMermaidPunctuation(messageMatch[3]!); + const message = stripMermaidPunctuation(messageMatch[4]!); + if (!participants.has(from)) participants.set(from, from); + if (!participants.has(to)) participants.set(to, to); + messages.push( + truncateToWidth( + `${participants.get(from) ?? from} ${arrow} ${participants.get(to) ?? to}: ${message}`, + contentWidth, + ), + ); + } + + const header = + participants.size > 0 + ? `Participants: ${Array.from(participants.values()).join(' | ')}` + : 'Participants: not declared'; + const lines = [truncateToWidth(header, contentWidth), '']; + lines.push( + ...(messages.length > 0 ? messages : ['No previewable messages found.']), + ); + + return { + title: 'Mermaid sequence diagram', + lines: lines.slice(0, MAX_RENDERED_LINES), + warning: + messages.length === 0 + ? 'Sequence preview supports A->>B: message style arrows.' + : undefined, + }; +} + +export function renderMermaidVisual( + source: string, + contentWidth: number, +): MermaidVisualResult { + const trimmed = source.trim(); + const firstLine = trimmed.split(/\r?\n/).find((line) => line.trim()) ?? ''; + if (FLOW_START_RE.test(firstLine)) { + return renderFlowchart(trimmed, contentWidth); + } + if (SEQUENCE_START_RE.test(firstLine)) { + return renderSequence(trimmed, contentWidth); + } + + const type = firstLine.split(/\s+/)[0] || 'unknown'; + return { + title: `Mermaid ${type} diagram`, + lines: [ + '┌─────────────────────────────┐', + '│ Visual preview unavailable. │', + '└─────────────────────────────┘', + ], + warning: + 'First preview supports flowchart/graph and sequenceDiagram diagrams.', + }; +} From f9e838f5ac5ea870b92a0cc1c8f0653285861981 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Mon, 27 Apr 2026 21:53:51 +0800 Subject: [PATCH 02/36] fix(cli): render finalized mermaid images synchronously --- .../cli/src/ui/utils/MarkdownDisplay.test.tsx | 17 ++++++ packages/cli/src/ui/utils/MermaidDiagram.tsx | 54 ++++--------------- 2 files changed, 28 insertions(+), 43 deletions(-) diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx index 95b9e1edfe8..50261865f22 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx @@ -506,6 +506,23 @@ sequenceDiagram expect(output).toContain('User → API: request'); expect(output).not.toContain('sequenceDiagram'); }); + + it('does not leave mermaid image rendering placeholders in finalized output', () => { + const text = ` +\`\`\`mermaid +flowchart TD + A[Start] --> B[End] +\`\`\` +`.replace(/\n/g, eol); + const { lastFrame } = renderWithProviders( + , + ); + const output = lastFrame() ?? ''; + + expect(output).not.toContain('Rendering Mermaid image'); + expect(output).toContain('Start'); + expect(output).toContain('End'); + }); }); it('correctly splits lines using \\n regardless of platform EOL', () => { diff --git a/packages/cli/src/ui/utils/MermaidDiagram.tsx b/packages/cli/src/ui/utils/MermaidDiagram.tsx index 77097b41817..be2f898be87 100644 --- a/packages/cli/src/ui/utils/MermaidDiagram.tsx +++ b/packages/cli/src/ui/utils/MermaidDiagram.tsx @@ -29,48 +29,21 @@ const MermaidDiagramInternal: React.FC = ({ const { write } = useStdout(); const preparedKittySequence = React.useRef(null); const innerWidth = Math.max(8, contentWidth - MERMAID_PADDING); - const renderKey = React.useMemo( - () => `${source}\0${innerWidth}\0${availableTerminalHeight ?? 'auto'}`, - [availableTerminalHeight, innerWidth, source], - ); - const [imageState, setImageState] = React.useState<{ - key: string; - image: ReturnType | null; - }>({ key: '', image: null }); const visual = React.useMemo( () => renderMermaidVisual(source, innerWidth), [source, innerWidth], ); - const image = imageState.key === renderKey ? imageState.image : null; - const imageAttemptPending = !isPending && image === null; - - React.useEffect(() => { - if (isPending) { - setImageState({ key: renderKey, image: null }); - return; - } - - let cancelled = false; - setImageState((current) => - current.key === renderKey ? current : { key: renderKey, image: null }, - ); - - const timer = setTimeout(() => { - const nextImage = renderMermaidImageSync({ - source, - contentWidth: innerWidth, - availableTerminalHeight, - }); - if (!cancelled) { - setImageState({ key: renderKey, image: nextImage }); - } - }, 20); - - return () => { - cancelled = true; - clearTimeout(timer); - }; - }, [availableTerminalHeight, innerWidth, isPending, renderKey, source]); + const image = React.useMemo( + () => + isPending + ? null + : renderMermaidImageSync({ + source, + contentWidth: innerWidth, + availableTerminalHeight, + }), + [availableTerminalHeight, innerWidth, isPending, source], + ); const kittySequence = image?.kind === 'terminal-image' && @@ -189,11 +162,6 @@ const MermaidDiagramInternal: React.FC = ({ {visual.warning} )} - {imageAttemptPending && ( - - Rendering Mermaid image... showing wireframe preview meanwhile. - - )} {!isPending && image?.kind === 'unavailable' && ( Image rendering unavailable: {image.reason} From 5839dcadeae8a04156ca7d1a47a8bc9f5c002ded Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Tue, 28 Apr 2026 10:33:39 +0800 Subject: [PATCH 03/36] fix(cli): harden markdown rendering paths --- docs/design/markdown-syntax-extension.md | 26 +++-- .../ui/utils/InlineMarkdownRenderer.test.tsx | 28 +++++ .../src/ui/utils/InlineMarkdownRenderer.tsx | 22 +++- .../cli/src/ui/utils/MarkdownDisplay.test.tsx | 30 +++++ packages/cli/src/ui/utils/MarkdownDisplay.tsx | 107 ++++++++++++++++-- .../src/ui/utils/mermaidImageRenderer.test.ts | 51 +++++++++ .../cli/src/ui/utils/mermaidImageRenderer.ts | 36 +++++- .../cli/src/ui/utils/mermaidVisualRenderer.ts | 69 ++++++++--- 8 files changed, 323 insertions(+), 46 deletions(-) create mode 100644 packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx diff --git a/docs/design/markdown-syntax-extension.md b/docs/design/markdown-syntax-extension.md index c4197321ce5..c9e6b3c6a52 100644 --- a/docs/design/markdown-syntax-extension.md +++ b/docs/design/markdown-syntax-extension.md @@ -60,25 +60,27 @@ same PNG is rendered as ANSI block graphics. If neither image protocol nor `chafa` is available, the renderer falls back to the synchronous terminal text preview described below. -The image render is scheduled after the first React paint. Users see the -wireframe preview immediately with an explicit rendering status, then the -terminal image replaces it when Mermaid CLI finishes. This avoids a confusing -blank pause during slow `mmdc` startup, especially when the opt-in `npx` path -is cold. +The image render is not attempted while a response is still streaming. During +streaming, Mermaid blocks show a bounded pending preview. Once the response is +finalized, the image path is attempted only when explicitly enabled. This keeps +slow `mmdc` startup, especially the opt-in `npx` path, out of the default +interactive render path. PNG generation is cached independently from terminal placement. Repeated renders of the same Mermaid source, including terminal resize updates, reuse the generated PNG and only recompute the Kitty/iTerm2 placement dimensions. -The image path is intentionally capability-gated instead of always bundling -Puppeteer/Chromium into the hot CLI path. A user can enable the image path by -installing `@mermaid-js/mermaid-cli` so `mmdc` is on `PATH`, or by setting +The image path is intentionally opt-in and capability-gated instead of always +bundling or invoking Puppeteer/Chromium from the hot CLI path. A user can enable +the image path with `QWEN_CODE_MERMAID_IMAGE_RENDERING=1`, then provide +`@mermaid-js/mermaid-cli` by installing `mmdc` on `PATH` or by setting `QWEN_CODE_MERMAID_MMD_CLI` to the binary path. For ad-hoc local verification, `QWEN_CODE_MERMAID_ALLOW_NPX=1` allows the renderer to invoke `npx -y @mermaid-js/mermaid-cli@11.12.0`; this is intentionally opt-in because -the first run may install Puppeteer/Chromium and block rendering. Terminal -protocol selection can be forced with -`QWEN_CODE_MERMAID_IMAGE_PROTOCOL=kitty|iterm2|off`. +the first run may install Puppeteer/Chromium and block rendering. Repo-local +`node_modules/.bin` renderers are not auto-discovered unless +`QWEN_CODE_MERMAID_ALLOW_LOCAL_RENDERERS=1` is set. Terminal protocol selection +can be forced with `QWEN_CODE_MERMAID_IMAGE_PROTOCOL=kitty|iterm2|off`. For Kitty-compatible terminals such as Ghostty, the renderer uses Kitty Unicode placeholders instead of writing the image payload as Ink text. The PNG @@ -109,6 +111,8 @@ model instead of printing one edge at a time: terminal fonts while keeping the loop semantics visible. - The graph is recomputed from `contentWidth`, so resize changes node width, spacing, and connector paths. +- Large previews are bounded before graph layout so very large Mermaid blocks + do not allocate an unbounded terminal canvas during render. Example: diff --git a/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx b/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx new file mode 100644 index 00000000000..b1f0ef819e9 --- /dev/null +++ b/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx @@ -0,0 +1,28 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { renderWithProviders } from '../../test-utils/render.js'; +import { RenderInline } from './InlineMarkdownRenderer.js'; + +describe('', () => { + it('leaves shell-style dollar variables untouched by default', () => { + const { lastFrame } = renderWithProviders( + , + ); + + expect(lastFrame()).toContain('echo $HOME && echo $PATH'); + }); + + it('renders inline math only when explicitly enabled', () => { + const { lastFrame } = renderWithProviders( + , + ); + + expect(lastFrame()).toContain('α'); + expect(lastFrame()).not.toContain('$\\alpha$'); + }); +}); diff --git a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx index 076d6e5c4e8..f7d568c2239 100644 --- a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx +++ b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx @@ -25,21 +25,27 @@ const debugLogger = createDebugLogger('INLINE_MARKDOWN'); interface RenderInlineProps { text: string; textColor?: string; + enableInlineMath?: boolean; } const RenderInlineInternal: React.FC = ({ text, textColor = theme.text.primary, + enableInlineMath = false, }) => { // Early return for plain text without markdown or URLs - if (!/[*_~`<[$]|https?:/.test(text)) { + if ( + !/[*_~`<[]|https?:/.test(text) && + !(enableInlineMath && text.includes('$')) + ) { return {text}; } const nodes: React.ReactNode[] = []; let lastIndex = 0; - const inlineRegex = - /(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|\$(?:\\.|[^$\n])+\$|.*?<\/u>|https?:\/\/\S+)/g; + const inlineRegex = enableInlineMath + ? /(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|\$[^$\n]{1,240}\$|.*?<\/u>|https?:\/\/\S+)/g + : /(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|.*?<\/u>|https?:\/\/\S+)/g; let match; while ((match = inlineRegex.exec(text)) !== null) { @@ -141,6 +147,7 @@ const RenderInlineInternal: React.FC = ({ ); } else if ( + enableInlineMath && fullMatch.startsWith('$') && fullMatch.endsWith('$') && fullMatch.length > INLINE_MATH_MARKER_LENGTH * 2 @@ -184,14 +191,19 @@ export const RenderInline = React.memo(RenderInlineInternal); * Utility function to get the plain text length of a string with markdown formatting * This is useful for calculating column widths in tables */ -export const getPlainTextLength = (text: string): number => { +export const getPlainTextLength = ( + text: string, + enableInlineMath = false, +): number => { const cleanText = text .replace(/\*\*(.*?)\*\*/g, '$1') .replace(/\*(.*?)\*/g, '$1') .replace(/_(.*?)_/g, '$1') .replace(/~~(.*?)~~/g, '$1') .replace(/`(.*?)`/g, '$1') - .replace(/\$(.*?)\$/g, (_match, expr: string) => renderInlineLatex(expr)) + .replace(/\$([^$\n]{1,240})\$/g, (_match, expr: string) => + enableInlineMath ? renderInlineLatex(expr) : _match, + ) .replace(/(.*?)<\/u>/g, '$1') .replace(/.*\[(.*?)\]\(.*\)/g, '$1'); return stringWidth(cleanText); diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx index 50261865f22..7cf631a3d55 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx @@ -488,6 +488,21 @@ flowchart TD expect(wideOutput).not.toBe(narrowOutput); }); + it('bounds large mermaid flowchart previews before layout', () => { + const source = [ + 'flowchart TD', + ...Array.from({ length: 200 }, (_, index) => { + const next = index + 1; + return `N${index}[Node ${index}] --> N${next}[Node ${next}]`; + }), + ].join('\n'); + + const preview = renderMermaidVisual(source, 80); + + expect(preview.warning).toContain('Preview limited'); + expect(preview.lines.length).toBeLessThanOrEqual(80); + }); + it('renders mermaid sequence diagrams as a visual preview', () => { const text = ` \`\`\`mermaid @@ -523,6 +538,21 @@ flowchart TD expect(output).toContain('Start'); expect(output).toContain('End'); }); + + it('does not fully render mermaid diagrams while the code block is pending', () => { + const text = ` +\`\`\`mermaid +flowchart TD + A[Start] --> B[End] +`.replace(/\n/g, eol); + const { lastFrame } = renderWithProviders( + , + ); + const output = lastFrame() ?? ''; + + expect(output).toContain('Mermaid diagram is being written'); + expect(output).not.toContain('Mermaid flowchart'); + }); }); it('correctly splits lines using \\n regardless of platform EOL', () => { diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.tsx index 38426871c0f..856f9a51d50 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.tsx @@ -173,7 +173,11 @@ const MarkdownDisplayInternal: React.FC = ({ addContentBlock( - + , ); @@ -217,7 +221,11 @@ const MarkdownDisplayInternal: React.FC = ({ addContentBlock( - + , ); @@ -244,35 +252,55 @@ const MarkdownDisplayInternal: React.FC = ({ case 1: headerNode = ( - + ); break; case 2: headerNode = ( - + ); break; case 3: headerNode = ( - + ); break; case 4: headerNode = ( - + ); break; default: headerNode = ( - + ); break; @@ -318,7 +346,11 @@ const MarkdownDisplayInternal: React.FC = ({ addContentBlock( - + , ); @@ -385,12 +417,21 @@ const RenderCodeBlockInternal: React.FC = ({ const settings = useSettings(); const MIN_LINES_FOR_MESSAGE = 1; // Minimum lines to show before the "generating more" message const RESERVED_LINES = 2; // Lines reserved for the message itself and potential padding - const fullContent = content.join('\n'); if (lang?.toLowerCase() === 'mermaid') { + if (isPending) { + return ( + + ); + } + return ( = ({ ); } + const fullContent = content.join('\n'); + if (isPending && availableTerminalHeight !== undefined) { const MAX_CODE_LINES_WHEN_PENDING = Math.max( 0, @@ -456,6 +499,42 @@ const RenderCodeBlockInternal: React.FC = ({ const RenderCodeBlock = React.memo(RenderCodeBlockInternal); +interface RenderPendingMermaidBlockProps { + content: string[]; + availableTerminalHeight?: number; + contentWidth: number; +} + +const RenderPendingMermaidBlockInternal: React.FC< + RenderPendingMermaidBlockProps +> = ({ content, availableTerminalHeight, contentWidth }) => { + const maxPreviewLines = + availableTerminalHeight === undefined + ? 6 + : Math.max(0, availableTerminalHeight - 2); + const previewLines = content.slice(0, maxPreviewLines); + return ( + + Mermaid diagram is being written... + {previewLines.map((line, index) => ( + + {line || ' '} + + ))} + {content.length > previewLines.length && ( + ... generating more ... + )} + + ); +}; + +const RenderPendingMermaidBlock = React.memo(RenderPendingMermaidBlockInternal); + interface RenderMathBlockProps { content: string[]; contentWidth: number; @@ -495,7 +574,7 @@ const RenderBlockquoteInternal: React.FC = ({ │ - + @@ -540,7 +619,11 @@ const RenderListItemInternal: React.FC = ({ - + diff --git a/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts b/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts index 1e533d4e5a0..b6ec8907943 100644 --- a/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts +++ b/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts @@ -32,6 +32,55 @@ afterEach(() => { }); describe('mermaid image renderer', () => { + it('keeps external image rendering disabled unless explicitly enabled', () => { + const result = renderMermaidImageSync({ + source: 'flowchart TD\n A[Start] --> B[End]', + contentWidth: 80, + availableTerminalHeight: 20, + env: { + PATH: process.env['PATH'] ?? '', + QWEN_CODE_MERMAID_IMAGE_PROTOCOL: 'kitty', + }, + }); + + expect(result.kind).toBe('unavailable'); + expect(result.kind === 'unavailable' && result.reason).toContain( + 'disabled by default', + ); + }); + + it('does not auto-discover repo-local renderers from the current working directory', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-local-mmdc-')); + tempDirs.push(tempDir); + const binDir = path.join(tempDir, 'node_modules', '.bin'); + fs.mkdirSync(binDir, { recursive: true }); + const localMmdc = path.join(binDir, 'mmdc'); + fs.writeFileSync(localMmdc, '#!/bin/sh\nexit 1\n', 'utf8'); + fs.chmodSync(localMmdc, 0o755); + + const originalCwd = process.cwd(); + process.chdir(tempDir); + try { + const result = renderMermaidImageSync({ + source: 'flowchart TD\n A[Start] --> B[End]', + contentWidth: 80, + availableTerminalHeight: 20, + env: { + PATH: binDir, + QWEN_CODE_MERMAID_IMAGE_RENDERING: '1', + QWEN_CODE_MERMAID_IMAGE_PROTOCOL: 'kitty', + }, + }); + + expect(result.kind).toBe('unavailable'); + expect(result.kind === 'unavailable' && result.reason).toContain( + 'Mermaid CLI (mmdc) was not found', + ); + } finally { + process.chdir(originalCwd); + } + }); + it('detects forced terminal image protocols', () => { expect( detectTerminalImageProtocol({ @@ -97,6 +146,7 @@ describe('mermaid image renderer', () => { availableTerminalHeight: 20, env: { PATH: `${binDir}${path.delimiter}${process.env['PATH'] ?? ''}`, + QWEN_CODE_MERMAID_IMAGE_RENDERING: '1', QWEN_CODE_MERMAID_IMAGE_PROTOCOL: 'iterm2', }, }); @@ -132,6 +182,7 @@ describe('mermaid image renderer', () => { availableTerminalHeight: 20, env: { PATH: `${binDir}${path.delimiter}${process.env['PATH'] ?? ''}`, + QWEN_CODE_MERMAID_IMAGE_RENDERING: '1', QWEN_CODE_MERMAID_IMAGE_PROTOCOL: 'kitty', }, }); diff --git a/packages/cli/src/ui/utils/mermaidImageRenderer.ts b/packages/cli/src/ui/utils/mermaidImageRenderer.ts index e668c0c171f..ebd9d1ef8a4 100644 --- a/packages/cli/src/ui/utils/mermaidImageRenderer.ts +++ b/packages/cli/src/ui/utils/mermaidImageRenderer.ts @@ -323,13 +323,16 @@ export function renderMermaidImageSync({ availableTerminalHeight, env = process.env, }: MermaidImageRenderOptions): MermaidImageRenderResult { + const imageRendering = env['QWEN_CODE_MERMAID_IMAGE_RENDERING']; if ( - env['QWEN_CODE_MERMAID_IMAGE_RENDERING'] === '0' || - env['QWEN_CODE_MERMAID_IMAGE_RENDERING'] === 'off' + imageRendering !== '1' && + imageRendering?.toLowerCase() !== 'on' && + imageRendering?.toLowerCase() !== 'true' ) { return { kind: 'unavailable', - reason: 'Mermaid image rendering is disabled.', + reason: + 'Mermaid image rendering is disabled by default. Set QWEN_CODE_MERMAID_IMAGE_RENDERING=1 to enable external renderers.', }; } @@ -551,14 +554,39 @@ function findExecutable( } }; - addCandidates(path.join(process.cwd(), 'node_modules', '.bin')); + const allowLocalRenderers = + env['QWEN_CODE_MERMAID_ALLOW_LOCAL_RENDERERS'] === '1'; + const localRendererDir = normalizeExecutableDir( + process.cwd(), + 'node_modules', + '.bin', + ); + + if (allowLocalRenderers) { + addCandidates(localRendererDir); + } for (const dir of (env['PATH'] ?? '').split(path.delimiter).filter(Boolean)) { + if ( + !allowLocalRenderers && + normalizeExecutableDir(dir) === localRendererDir + ) { + continue; + } addCandidates(dir); } return candidates.find(isExecutable) ?? null; } +function normalizeExecutableDir(...segments: string[]): string { + const dir = path.resolve(...segments); + try { + return fs.realpathSync.native(dir); + } catch { + return dir; + } +} + function isExecutable(filePath: string): boolean { try { fs.accessSync(filePath, fs.constants.X_OK); diff --git a/packages/cli/src/ui/utils/mermaidVisualRenderer.ts b/packages/cli/src/ui/utils/mermaidVisualRenderer.ts index 50d756df2ae..b519c6b3870 100644 --- a/packages/cli/src/ui/utils/mermaidVisualRenderer.ts +++ b/packages/cli/src/ui/utils/mermaidVisualRenderer.ts @@ -49,6 +49,10 @@ const FLOW_START_RE = /^(?:flowchart|graph)\s+([A-Za-z]{2})/i; const SEQUENCE_START_RE = /^sequenceDiagram\b/i; const LINE_COMMENT_RE = /^%%/; const MAX_RENDERED_LINES = 80; +const MAX_FLOWCHART_PREVIEW_LINES = 120; +const MAX_FLOWCHART_PREVIEW_EDGES = 80; +const MAX_SEQUENCE_PREVIEW_LINES = 160; +const MAX_SEQUENCE_PREVIEW_MESSAGES = 80; const MIN_CANVAS_WIDTH = 24; const NODE_GAP_X = 4; const NODE_GAP_Y = 4; @@ -790,19 +794,29 @@ function renderFlowchart( source: string, contentWidth: number, ): MermaidVisualResult { - const rawLines = source + const allRawLines = source .split(/\r?\n/) .map((line) => line.trim()) .filter((line) => line.length > 0 && !LINE_COMMENT_RE.test(line)); + const rawLines = allRawLines.slice(0, MAX_FLOWCHART_PREVIEW_LINES); + const lineBudgetExceeded = allRawLines.length > rawLines.length; const first = rawLines[0] ?? ''; const direction = FLOW_START_RE.exec(first)?.[1]?.toUpperCase() ?? 'TD'; const lines = rawLines.slice(FLOW_START_RE.test(first) ? 1 : 0); - const edgeLines = lines.flatMap((line) => - line - .split(';') - .map((part) => part.trim()) - .filter(Boolean), - ); + const edgeLines: string[] = []; + let edgeBudgetExceeded = false; + for (const line of lines) { + for (const part of line.split(';')) { + const statement = part.trim(); + if (!statement) continue; + if (edgeLines.length >= MAX_FLOWCHART_PREVIEW_EDGES) { + edgeBudgetExceeded = true; + break; + } + edgeLines.push(statement); + } + if (edgeBudgetExceeded) break; + } const edges = edgeLines .map(parseFlowEdge) .filter((edge): edge is FlowEdge => edge !== null); @@ -831,9 +845,19 @@ function renderFlowchart( title: `Mermaid flowchart (${direction})`, lines: rendered.slice(0, MAX_RENDERED_LINES), warning: - rendered.length > MAX_RENDERED_LINES - ? `Preview truncated to ${MAX_RENDERED_LINES} lines.` - : undefined, + [ + lineBudgetExceeded + ? `Preview limited to ${MAX_FLOWCHART_PREVIEW_LINES} source lines.` + : undefined, + edgeBudgetExceeded + ? `Preview limited to ${MAX_FLOWCHART_PREVIEW_EDGES} edges.` + : undefined, + rendered.length > MAX_RENDERED_LINES + ? `Preview truncated to ${MAX_RENDERED_LINES} rendered lines.` + : undefined, + ] + .filter(Boolean) + .join(' ') || undefined, }; } @@ -851,7 +875,7 @@ function renderSequence( source: string, contentWidth: number, ): MermaidVisualResult { - const rawLines = source + const allRawLines = source .split(/\r?\n/) .map((line) => line.trim()) .filter( @@ -860,8 +884,11 @@ function renderSequence( !LINE_COMMENT_RE.test(line) && !SEQUENCE_START_RE.test(line), ); + const rawLines = allRawLines.slice(0, MAX_SEQUENCE_PREVIEW_LINES); + const lineBudgetExceeded = allRawLines.length > rawLines.length; const participants = new Map(); const messages: string[] = []; + let messageBudgetExceeded = false; for (const line of rawLines) { const participant = parseParticipant(line); @@ -879,6 +906,10 @@ function renderSequence( const message = stripMermaidPunctuation(messageMatch[4]!); if (!participants.has(from)) participants.set(from, from); if (!participants.has(to)) participants.set(to, to); + if (messages.length >= MAX_SEQUENCE_PREVIEW_MESSAGES) { + messageBudgetExceeded = true; + continue; + } messages.push( truncateToWidth( `${participants.get(from) ?? from} ${arrow} ${participants.get(to) ?? to}: ${message}`, @@ -900,9 +931,19 @@ function renderSequence( title: 'Mermaid sequence diagram', lines: lines.slice(0, MAX_RENDERED_LINES), warning: - messages.length === 0 - ? 'Sequence preview supports A->>B: message style arrows.' - : undefined, + [ + messages.length === 0 + ? 'Sequence preview supports A->>B: message style arrows.' + : undefined, + lineBudgetExceeded + ? `Preview limited to ${MAX_SEQUENCE_PREVIEW_LINES} source lines.` + : undefined, + messageBudgetExceeded + ? `Preview limited to ${MAX_SEQUENCE_PREVIEW_MESSAGES} messages.` + : undefined, + ] + .filter(Boolean) + .join(' ') || undefined, }; } From 97a0dea135d34fb842c9061dee61499cbe9e56cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Tue, 28 Apr 2026 11:26:36 +0800 Subject: [PATCH 04/36] fix(cli): preserve mermaid source fallbacks --- docs/design/markdown-syntax-extension.md | 24 +- .../cli/src/ui/utils/MarkdownDisplay.test.tsx | 79 +++ .../cli/src/ui/utils/mermaidVisualRenderer.ts | 588 +++++++++++++++++- 3 files changed, 659 insertions(+), 32 deletions(-) diff --git a/docs/design/markdown-syntax-extension.md b/docs/design/markdown-syntax-extension.md index c9e6b3c6a52..867cda43fe1 100644 --- a/docs/design/markdown-syntax-extension.md +++ b/docs/design/markdown-syntax-extension.md @@ -27,12 +27,16 @@ not separate feature PRs. Included in the first implementation: - Mermaid code blocks render visually in the TUI. -- Mermaid diagrams render through PNG terminal images when `mmdc` and a - supported terminal image path are available. +- Mermaid diagrams render through PNG terminal images when image rendering is + explicitly enabled, `mmdc` is available, and the terminal supports an image + path. - `flowchart` / `graph` Mermaid diagrams fall back to box-and-arrow previews. - `sequenceDiagram` Mermaid diagrams fall back to participant-arrow previews. -- Unsupported Mermaid types render a compact visual placeholder instead of a - raw source block. +- Basic `classDiagram`, `stateDiagram`, `erDiagram`, `gantt`, `pie`, + `journey`, `mindmap`, `gitGraph`, and `requirementDiagram` blocks fall back + to bounded text previews. +- Mermaid types without a text preview fall back to their original fenced + source so the user can still read and copy the diagram definition. - Task list items render checked/unchecked markers. - Blockquotes render with a visible quote bar. - Inline `$...$` math and block `$$...$$` math render with common Unicode @@ -123,8 +127,18 @@ flowchart LR renders as a terminal visual preview rather than Mermaid source. +Other common Mermaid diagram families use bounded text summaries rather than a +full layout engine: class relationships/members, state transitions, ER +entities/relationships, Gantt tasks, pie slices, journey steps, mindmap trees, +git graph entries, and requirement trees. If a diagram type is unknown or not +previewable, the renderer shows the original fenced Mermaid source rather than +a placeholder so the content remains readable and selectable/copyable in the +terminal. A dedicated one-key "copy Mermaid source" interaction is still a +separate UI affordance for a future PR because the current message renderer +does not expose per-block actions. + The fallback is still not a complete Mermaid engine. It is a fast, -dependency-light wireframe preview for common LLM-generated diagrams when +dependency-light preview layer for common LLM-generated diagrams when high-fidelity rendering is not available. ### Future providers diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx index 7cf631a3d55..0c23f27a416 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx @@ -522,6 +522,85 @@ sequenceDiagram expect(output).not.toContain('sequenceDiagram'); }); + it('renders common non-flowchart mermaid diagrams as readable previews', () => { + const classPreview = renderMermaidVisual( + ` +classDiagram + Animal <|-- Duck + Animal: +int age + Duck: +swim() +`, + 80, + ); + const erPreview = renderMermaidVisual( + ` +erDiagram + CUSTOMER ||--o{ ORDER : places + CUSTOMER { + string name + } +`, + 80, + ); + const piePreview = renderMermaidVisual( + ` +pie title Pets + "Dogs" : 40 + "Cats" : 60 +`, + 80, + ); + + expect(classPreview.title).toBe('Mermaid class diagram'); + expect(classPreview.lines.join('\n')).toContain('Animal'); + expect(classPreview.lines.join('\n')).toContain('Duck'); + expect(erPreview.title).toBe('Mermaid ER diagram'); + expect(erPreview.lines.join('\n')).toContain('CUSTOMER'); + expect(erPreview.lines.join('\n')).toContain('ORDER'); + expect(piePreview.title).toBe('Mermaid pie chart'); + expect(piePreview.lines.join('\n')).toContain('Dogs'); + expect(piePreview.lines.join('\n')).toContain('Cats'); + }); + + it('falls back to mermaid source for unsupported diagrams', () => { + const text = ` +\`\`\`mermaid +timeline + title History + 2024 : Start +\`\`\` +`.replace(/\n/g, eol); + const { lastFrame } = renderWithProviders( + , + ); + const output = lastFrame() ?? ''; + + expect(output).toContain('Mermaid source (timeline)'); + expect(output).toContain('```mermaid'); + expect(output).toContain('timeline'); + expect(output).toContain('2024 : Start'); + expect(output).not.toContain('Visual preview unavailable'); + }); + + it('falls back to mermaid source when a known diagram cannot be previewed', () => { + const preview = renderMermaidVisual( + ` +stateDiagram-v2 + note right of StillReadable + Notes are not parsed by the text preview yet. + end note +`, + 80, + ); + const output = preview.lines.join('\n'); + + expect(preview.title).toBe('Mermaid source (stateDiagram)'); + expect(output).toContain('```mermaid'); + expect(output).toContain('stateDiagram-v2'); + expect(output).toContain('Notes are not parsed'); + expect(output).not.toContain('No previewable'); + }); + it('does not leave mermaid image rendering placeholders in finalized output', () => { const text = ` \`\`\`mermaid diff --git a/packages/cli/src/ui/utils/mermaidVisualRenderer.ts b/packages/cli/src/ui/utils/mermaidVisualRenderer.ts index b519c6b3870..ae5f6c0307d 100644 --- a/packages/cli/src/ui/utils/mermaidVisualRenderer.ts +++ b/packages/cli/src/ui/utils/mermaidVisualRenderer.ts @@ -47,12 +47,24 @@ interface PositionedNode { const FLOW_START_RE = /^(?:flowchart|graph)\s+([A-Za-z]{2})/i; const SEQUENCE_START_RE = /^sequenceDiagram\b/i; +const CLASS_START_RE = /^classDiagram(?:-v2)?\b/i; +const STATE_START_RE = /^stateDiagram(?:-v2)?\b/i; +const ER_START_RE = /^erDiagram\b/i; +const GANTT_START_RE = /^gantt\b/i; +const PIE_START_RE = /^pie\b/i; +const JOURNEY_START_RE = /^journey\b/i; +const GIT_GRAPH_START_RE = /^gitGraph\b/i; +const MINDMAP_START_RE = /^mindmap\b/i; +const REQUIREMENT_START_RE = /^requirementDiagram\b/i; const LINE_COMMENT_RE = /^%%/; const MAX_RENDERED_LINES = 80; const MAX_FLOWCHART_PREVIEW_LINES = 120; const MAX_FLOWCHART_PREVIEW_EDGES = 80; const MAX_SEQUENCE_PREVIEW_LINES = 160; const MAX_SEQUENCE_PREVIEW_MESSAGES = 80; +const MAX_GENERIC_PREVIEW_LINES = 80; +const MAX_SOURCE_FALLBACK_LINES = 80; +const MAX_PREVIEW_SOURCE_LINE_LENGTH = 1000; const MIN_CANVAS_WIDTH = 24; const NODE_GAP_X = 4; const NODE_GAP_Y = 4; @@ -80,6 +92,13 @@ function stripMermaidPunctuation(text: string): string { .trim(); } +function normalizePreviewLine(line: string): string { + const trimmed = line.trim(); + return trimmed.length > MAX_PREVIEW_SOURCE_LINE_LENGTH + ? trimmed.slice(0, MAX_PREVIEW_SOURCE_LINE_LENGTH) + : trimmed; +} + function normalizeNodeLabel(label: string): string { return label .replace(/^["']|["']$/g, '') @@ -98,6 +117,60 @@ function singleLineLabel(label: string): string { return nodeLabelLines(label).join(' '); } +function previewSourceFallback( + source: string, + contentWidth: number, + type: string, + warning?: string, +): MermaidVisualResult { + const allSourceLines = source.trim().split(/\r?\n/); + const sourceLines = allSourceLines.slice(0, MAX_SOURCE_FALLBACK_LINES); + const truncated = allSourceLines.length > sourceLines.length; + const lines = ['```mermaid', ...sourceLines, '```'].map((line) => + truncateToWidth(line, contentWidth), + ); + + return { + title: `Mermaid source (${type})`, + lines, + warning: + [ + warning ?? + 'Visual preview is not available; showing source so it remains readable and copyable.', + truncated + ? `Source truncated to ${MAX_SOURCE_FALLBACK_LINES} lines.` + : undefined, + ] + .filter(Boolean) + .join(' ') || undefined, + }; +} + +function diagramLines( + source: string, + startRe: RegExp, + maxLines = MAX_GENERIC_PREVIEW_LINES, +): { lines: string[]; truncated: boolean } { + const allLines = source + .split(/\r?\n/) + .map(normalizePreviewLine) + .filter( + (line) => + line.length > 0 && !LINE_COMMENT_RE.test(line) && !startRe.test(line), + ); + return { + lines: allLines.slice(0, maxLines), + truncated: allLines.length > maxLines, + }; +} + +function budgetWarning( + truncated: boolean, + maxLines = MAX_GENERIC_PREVIEW_LINES, +): string | undefined { + return truncated ? `Preview limited to ${maxLines} source lines.` : undefined; +} + function parseNodeToken(rawToken: string): FlowNode | null { const token = stripMermaidPunctuation(rawToken) .replace(/^\|.*?\|/, '') @@ -796,7 +869,7 @@ function renderFlowchart( ): MermaidVisualResult { const allRawLines = source .split(/\r?\n/) - .map((line) => line.trim()) + .map(normalizePreviewLine) .filter((line) => line.length > 0 && !LINE_COMMENT_RE.test(line)); const rawLines = allRawLines.slice(0, MAX_FLOWCHART_PREVIEW_LINES); const lineBudgetExceeded = allRawLines.length > rawLines.length; @@ -823,15 +896,12 @@ function renderFlowchart( const normalizedEdges = normalizeFlowNodeLabels(edges); if (normalizedEdges.length === 0) { - return { - title: 'Mermaid flowchart', - lines: [ - '┌ Mermaid diagram ┐', - '│ No previewable edges found. │', - '└─────────────────┘', - ], - warning: 'Flowchart preview supports simple A --> B style edges.', - }; + return previewSourceFallback( + source, + contentWidth, + 'flowchart', + 'Flowchart preview supports simple A --> B style edges; showing source instead.', + ); } const horizontal = direction.includes('LR') || direction.includes('RL'); @@ -877,7 +947,7 @@ function renderSequence( ): MermaidVisualResult { const allRawLines = source .split(/\r?\n/) - .map((line) => line.trim()) + .map(normalizePreviewLine) .filter( (line) => line.length > 0 && @@ -922,19 +992,23 @@ function renderSequence( participants.size > 0 ? `Participants: ${Array.from(participants.values()).join(' | ')}` : 'Participants: not declared'; + if (messages.length === 0) { + return previewSourceFallback( + source, + contentWidth, + 'sequenceDiagram', + 'Sequence preview supports A->>B: message style arrows; showing source instead.', + ); + } + const lines = [truncateToWidth(header, contentWidth), '']; - lines.push( - ...(messages.length > 0 ? messages : ['No previewable messages found.']), - ); + lines.push(...messages); return { title: 'Mermaid sequence diagram', lines: lines.slice(0, MAX_RENDERED_LINES), warning: [ - messages.length === 0 - ? 'Sequence preview supports A->>B: message style arrows.' - : undefined, lineBudgetExceeded ? `Preview limited to ${MAX_SEQUENCE_PREVIEW_LINES} source lines.` : undefined, @@ -947,6 +1021,430 @@ function renderSequence( }; } +function renderClassDiagram( + source: string, + contentWidth: number, +): MermaidVisualResult { + const { lines: rawLines, truncated } = diagramLines(source, CLASS_START_RE); + const classes = new Set(); + const relationships: string[] = []; + const members: string[] = []; + let currentClass: string | null = null; + + for (const line of rawLines) { + const classBlockMatch = /^class\s+([A-Za-z0-9_.$:-]+)\s*\{?$/i.exec(line); + if (classBlockMatch) { + currentClass = classBlockMatch[1]!; + classes.add(currentClass); + continue; + } + if (line === '}') { + currentClass = null; + continue; + } + + const relationMatch = + /^([A-Za-z0-9_.$:-]+)\s+([<|*o.]*--[|>*o.]*|<\|--|--\|>|\*--|o--|-->|<--|\.\.>)\s+([A-Za-z0-9_.$:-]+)(?:\s*:\s*(.+))?$/i.exec( + line, + ); + if (relationMatch) { + const from = relationMatch[1]!; + const relation = relationMatch[2]!; + const to = relationMatch[3]!; + const label = stripMermaidPunctuation(relationMatch[4] ?? ''); + classes.add(from); + classes.add(to); + relationships.push( + truncateToWidth( + `${from} ${relation} ${to}${label ? `: ${label}` : ''}`, + contentWidth, + ), + ); + continue; + } + + const inlineMemberMatch = /^([A-Za-z0-9_.$:-]+)\s*:\s*(.+)$/i.exec(line); + if (inlineMemberMatch) { + classes.add(inlineMemberMatch[1]!); + members.push( + truncateToWidth( + `${inlineMemberMatch[1]}: ${stripMermaidPunctuation(inlineMemberMatch[2]!)}`, + contentWidth, + ), + ); + continue; + } + + if (currentClass) { + members.push( + truncateToWidth( + `${currentClass}: ${stripMermaidPunctuation(line)}`, + contentWidth, + ), + ); + } + } + + if ( + classes.size === 0 && + relationships.length === 0 && + members.length === 0 + ) { + return previewSourceFallback( + source, + contentWidth, + 'classDiagram', + 'Class preview found no previewable classes or relationships; showing source instead.', + ); + } + + const output = [ + truncateToWidth( + `Classes: ${Array.from(classes).join(' | ') || 'not declared'}`, + contentWidth, + ), + '', + ...(relationships.length > 0 + ? ['Relationships:', ...relationships] + : ['Relationships: none previewed']), + ...(members.length > 0 ? ['', 'Members:', ...members.slice(0, 24)] : []), + ]; + + return { + title: 'Mermaid class diagram', + lines: output.slice(0, MAX_RENDERED_LINES), + warning: budgetWarning(truncated), + }; +} + +function renderStateDiagram( + source: string, + contentWidth: number, +): MermaidVisualResult { + const { lines: rawLines, truncated } = diagramLines(source, STATE_START_RE); + const transitions: string[] = []; + const declarations: string[] = []; + + for (const line of rawLines) { + const transitionMatch = /^(.+?)\s*-->\s*(.+?)(?:\s*:\s*(.+))?$/.exec(line); + if (transitionMatch) { + const from = stripMermaidPunctuation(transitionMatch[1]!); + const to = stripMermaidPunctuation(transitionMatch[2]!); + const label = stripMermaidPunctuation(transitionMatch[3] ?? ''); + transitions.push( + truncateToWidth( + `${from} → ${to}${label ? `: ${label}` : ''}`, + contentWidth, + ), + ); + continue; + } + + const stateMatch = /^state\s+(.+?)(?:\s+as\s+(.+))?$/i.exec(line); + if (stateMatch) { + declarations.push( + truncateToWidth( + stripMermaidPunctuation(stateMatch[2] ?? stateMatch[1]!), + contentWidth, + ), + ); + } + } + + if (declarations.length === 0 && transitions.length === 0) { + return previewSourceFallback( + source, + contentWidth, + 'stateDiagram', + 'State preview found no previewable states or transitions; showing source instead.', + ); + } + + const output = [ + ...(declarations.length > 0 + ? ['States:', ...declarations.slice(0, 20), ''] + : []), + ...(transitions.length > 0 + ? ['Transitions:', ...transitions] + : ['Transitions: none previewed']), + ]; + + return { + title: 'Mermaid state diagram', + lines: output.slice(0, MAX_RENDERED_LINES), + warning: budgetWarning(truncated), + }; +} + +function renderErDiagram( + source: string, + contentWidth: number, +): MermaidVisualResult { + const { lines: rawLines, truncated } = diagramLines(source, ER_START_RE); + const entities = new Set(); + const relationships: string[] = []; + const attributes: string[] = []; + let currentEntity: string | null = null; + + for (const line of rawLines) { + const entityBlock = /^([A-Za-z0-9_.$:-]+)\s*\{$/.exec(line); + if (entityBlock) { + currentEntity = entityBlock[1]!; + entities.add(currentEntity); + continue; + } + if (line === '}') { + currentEntity = null; + continue; + } + + const relationMatch = + /^([A-Za-z0-9_.$:-]+)\s+([|o}{]{1,2}--[|o}{]{1,2})\s+([A-Za-z0-9_.$:-]+)(?:\s*:\s*(.+))?$/i.exec( + line, + ); + if (relationMatch) { + entities.add(relationMatch[1]!); + entities.add(relationMatch[3]!); + relationships.push( + truncateToWidth( + `${relationMatch[1]} ${relationMatch[2]} ${relationMatch[3]}${relationMatch[4] ? `: ${stripMermaidPunctuation(relationMatch[4])}` : ''}`, + contentWidth, + ), + ); + continue; + } + + if (currentEntity) { + attributes.push( + truncateToWidth( + `${currentEntity}: ${stripMermaidPunctuation(line)}`, + contentWidth, + ), + ); + } + } + + if ( + entities.size === 0 && + relationships.length === 0 && + attributes.length === 0 + ) { + return previewSourceFallback( + source, + contentWidth, + 'erDiagram', + 'ER preview found no previewable entities or relationships; showing source instead.', + ); + } + + return { + title: 'Mermaid ER diagram', + lines: [ + truncateToWidth( + `Entities: ${Array.from(entities).join(' | ') || 'not declared'}`, + contentWidth, + ), + '', + ...(relationships.length > 0 + ? ['Relationships:', ...relationships] + : ['Relationships: none previewed']), + ...(attributes.length > 0 + ? ['', 'Attributes:', ...attributes.slice(0, 24)] + : []), + ].slice(0, MAX_RENDERED_LINES), + warning: budgetWarning(truncated), + }; +} + +function renderPieDiagram( + source: string, + contentWidth: number, +): MermaidVisualResult { + const { lines: rawLines, truncated } = diagramLines(source, PIE_START_RE); + const titleLine = rawLines.find((line) => /^title\b/i.test(line)); + const slices = rawLines + .map((line) => /^["']?(.+?)["']?\s*:\s*([0-9.]+)\s*$/.exec(line)) + .filter((match): match is RegExpExecArray => match !== null) + .map((match) => + truncateToWidth( + `${stripMermaidPunctuation(match[1]!)}: ${match[2]}`, + contentWidth, + ), + ); + + if (slices.length === 0) { + return previewSourceFallback( + source, + contentWidth, + 'pie', + 'Pie preview found no previewable slices; showing source instead.', + ); + } + + return { + title: 'Mermaid pie chart', + lines: [ + ...(titleLine + ? [ + truncateToWidth(stripMermaidPunctuation(titleLine), contentWidth), + '', + ] + : []), + ...slices, + ], + warning: budgetWarning(truncated), + }; +} + +function renderGanttDiagram( + source: string, + contentWidth: number, +): MermaidVisualResult { + const { lines: rawLines, truncated } = diagramLines(source, GANTT_START_RE); + const output: string[] = []; + + for (const line of rawLines) { + if ( + /^(dateFormat|axisFormat|tickInterval|weekday|excludes|todayMarker)\b/i.test( + line, + ) + ) { + continue; + } + const section = /^section\s+(.+)$/i.exec(line); + if (section) { + output.push(''); + output.push( + truncateToWidth( + `Section: ${stripMermaidPunctuation(section[1]!)}`, + contentWidth, + ), + ); + continue; + } + const title = /^title\s+(.+)$/i.exec(line); + if (title) { + output.push( + truncateToWidth( + `Title: ${stripMermaidPunctuation(title[1]!)}`, + contentWidth, + ), + ); + continue; + } + output.push( + truncateToWidth(`• ${stripMermaidPunctuation(line)}`, contentWidth), + ); + } + + if (output.length === 0) { + return previewSourceFallback( + source, + contentWidth, + 'gantt', + 'Gantt preview found no previewable tasks; showing source instead.', + ); + } + + return { + title: 'Mermaid gantt chart', + lines: output.slice(0, MAX_RENDERED_LINES), + warning: budgetWarning(truncated), + }; +} + +function renderJourneyDiagram( + source: string, + contentWidth: number, +): MermaidVisualResult { + const { lines: rawLines, truncated } = diagramLines(source, JOURNEY_START_RE); + const output: string[] = []; + + for (const line of rawLines) { + const title = /^title\s+(.+)$/i.exec(line); + if (title) { + output.push( + truncateToWidth( + `Title: ${stripMermaidPunctuation(title[1]!)}`, + contentWidth, + ), + ); + continue; + } + const section = /^section\s+(.+)$/i.exec(line); + if (section) { + output.push(''); + output.push( + truncateToWidth( + `Section: ${stripMermaidPunctuation(section[1]!)}`, + contentWidth, + ), + ); + continue; + } + output.push( + truncateToWidth(`• ${stripMermaidPunctuation(line)}`, contentWidth), + ); + } + + if (output.length === 0) { + return previewSourceFallback( + source, + contentWidth, + 'journey', + 'Journey preview found no previewable steps; showing source instead.', + ); + } + + return { + title: 'Mermaid journey diagram', + lines: output.slice(0, MAX_RENDERED_LINES), + warning: budgetWarning(truncated), + }; +} + +function renderIndentedTreeDiagram( + source: string, + contentWidth: number, + startRe: RegExp, + title: string, + sourceType: string, +): MermaidVisualResult { + const allLines = source + .split(/\r?\n/) + .filter( + (line) => + normalizePreviewLine(line).length > 0 && + !LINE_COMMENT_RE.test(normalizePreviewLine(line)) && + !startRe.test(normalizePreviewLine(line)), + ); + const rawLines = allLines.slice(0, MAX_GENERIC_PREVIEW_LINES); + const truncated = allLines.length > rawLines.length; + const lines = rawLines.map((line) => { + const indentation = /^\s*/.exec(line)?.[0].length ?? 0; + const depth = Math.floor(indentation / 2); + return truncateToWidth( + `${' '.repeat(depth)}• ${line.trim()}`, + contentWidth, + ); + }); + + if (lines.length === 0) { + return previewSourceFallback( + source, + contentWidth, + sourceType, + `${title} preview found no previewable nodes; showing source instead.`, + ); + } + + return { + title, + lines: lines.slice(0, MAX_RENDERED_LINES), + warning: budgetWarning(truncated), + }; +} + export function renderMermaidVisual( source: string, contentWidth: number, @@ -959,16 +1457,52 @@ export function renderMermaidVisual( if (SEQUENCE_START_RE.test(firstLine)) { return renderSequence(trimmed, contentWidth); } + if (CLASS_START_RE.test(firstLine)) { + return renderClassDiagram(trimmed, contentWidth); + } + if (STATE_START_RE.test(firstLine)) { + return renderStateDiagram(trimmed, contentWidth); + } + if (ER_START_RE.test(firstLine)) { + return renderErDiagram(trimmed, contentWidth); + } + if (GANTT_START_RE.test(firstLine)) { + return renderGanttDiagram(trimmed, contentWidth); + } + if (PIE_START_RE.test(firstLine)) { + return renderPieDiagram(trimmed, contentWidth); + } + if (JOURNEY_START_RE.test(firstLine)) { + return renderJourneyDiagram(trimmed, contentWidth); + } + if (MINDMAP_START_RE.test(firstLine)) { + return renderIndentedTreeDiagram( + trimmed, + contentWidth, + MINDMAP_START_RE, + 'Mermaid mindmap', + 'mindmap', + ); + } + if (GIT_GRAPH_START_RE.test(firstLine)) { + return renderIndentedTreeDiagram( + trimmed, + contentWidth, + GIT_GRAPH_START_RE, + 'Mermaid git graph', + 'gitGraph', + ); + } + if (REQUIREMENT_START_RE.test(firstLine)) { + return renderIndentedTreeDiagram( + trimmed, + contentWidth, + REQUIREMENT_START_RE, + 'Mermaid requirement diagram', + 'requirementDiagram', + ); + } const type = firstLine.split(/\s+/)[0] || 'unknown'; - return { - title: `Mermaid ${type} diagram`, - lines: [ - '┌─────────────────────────────┐', - '│ Visual preview unavailable. │', - '└─────────────────────────────┘', - ], - warning: - 'First preview supports flowchart/graph and sequenceDiagram diagrams.', - }; + return previewSourceFallback(trimmed, contentWidth, type); } From c36e1e8f6e8620641119f555a2857b71f2d79d9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Tue, 28 Apr 2026 14:15:37 +0800 Subject: [PATCH 05/36] test(cli): make mermaid image renderer mock cross-platform --- .../src/ui/utils/mermaidImageRenderer.test.ts | 63 ++++++++++--------- 1 file changed, 35 insertions(+), 28 deletions(-) diff --git a/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts b/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts index b6ec8907943..8ccbf2f0c16 100644 --- a/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts +++ b/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts @@ -25,6 +25,39 @@ const PNG_1X1 = Buffer.from( const tempDirs: string[] = []; +function createFakeMmdc(binDir: string): void { + const fakeMmdcScript = path.join(binDir, 'fake-mmdc.cjs'); + fs.writeFileSync( + fakeMmdcScript, + [ + 'const fs = require("node:fs");', + 'const out = process.argv[process.argv.indexOf("-o") + 1];', + `fs.writeFileSync(out, Buffer.from("${PNG_1X1.toString( + 'base64', + )}", "base64"));`, + ].join('\n'), + 'utf8', + ); + + const fakeMmdc = + process.platform === 'win32' + ? path.join(binDir, 'mmdc.cmd') + : path.join(binDir, 'mmdc'); + const command = + process.platform === 'win32' + ? `@echo off\r\n"${process.execPath}" "${fakeMmdcScript}" %*\r\n` + : [ + '#!/usr/bin/env node', + 'const fs = require("node:fs");', + 'const out = process.argv[process.argv.indexOf("-o") + 1];', + `fs.writeFileSync(out, Buffer.from("${PNG_1X1.toString( + 'base64', + )}", "base64"));`, + ].join('\n'); + fs.writeFileSync(fakeMmdc, command, 'utf8'); + fs.chmodSync(fakeMmdc, 0o755); +} + afterEach(() => { for (const dir of tempDirs.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); @@ -125,20 +158,7 @@ describe('mermaid image renderer', () => { it('renders Mermaid through mmdc when terminal images are available', () => { const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-mmdc-')); tempDirs.push(binDir); - const fakeMmdc = path.join(binDir, 'mmdc'); - fs.writeFileSync( - fakeMmdc, - [ - '#!/usr/bin/env node', - 'const fs = require("node:fs");', - 'const out = process.argv[process.argv.indexOf("-o") + 1];', - `fs.writeFileSync(out, Buffer.from("${PNG_1X1.toString( - 'base64', - )}", "base64"));`, - ].join('\n'), - 'utf8', - ); - fs.chmodSync(fakeMmdc, 0o755); + createFakeMmdc(binDir); const result = renderMermaidImageSync({ source: 'flowchart TD\n A[Start] --> B[End]', @@ -161,20 +181,7 @@ describe('mermaid image renderer', () => { it('renders Kitty terminal images as virtual placements', () => { const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-mmdc-')); tempDirs.push(binDir); - const fakeMmdc = path.join(binDir, 'mmdc'); - fs.writeFileSync( - fakeMmdc, - [ - '#!/usr/bin/env node', - 'const fs = require("node:fs");', - 'const out = process.argv[process.argv.indexOf("-o") + 1];', - `fs.writeFileSync(out, Buffer.from("${PNG_1X1.toString( - 'base64', - )}", "base64"));`, - ].join('\n'), - 'utf8', - ); - fs.chmodSync(fakeMmdc, 0o755); + createFakeMmdc(binDir); const result = renderMermaidImageSync({ source: 'flowchart TD\n A[Start] --> B[End]', From dc3bc86603ff20214cc3ae29a32ce82b22beb24e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Tue, 28 Apr 2026 15:24:57 +0800 Subject: [PATCH 06/36] fix(cli): run windows mmdc shims through shell --- packages/cli/src/ui/utils/mermaidImageRenderer.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/cli/src/ui/utils/mermaidImageRenderer.ts b/packages/cli/src/ui/utils/mermaidImageRenderer.ts index ebd9d1ef8a4..a22fac62509 100644 --- a/packages/cli/src/ui/utils/mermaidImageRenderer.ts +++ b/packages/cli/src/ui/utils/mermaidImageRenderer.ts @@ -630,6 +630,7 @@ function renderPngWithMmdc( ...process.env, ...env, }, + shell: shouldRunThroughShell(command), timeout: Number( env['QWEN_CODE_MERMAID_RENDER_TIMEOUT_MS'] ?? DEFAULT_RENDER_TIMEOUT_MS, ), @@ -655,6 +656,10 @@ function renderPngWithMmdc( } } +function shouldRunThroughShell(command: string): boolean { + return process.platform === 'win32' && /\.(?:cmd|bat)$/i.test(command); +} + function getMermaidRenderWidth(env: NodeJS.ProcessEnv): number { const configuredWidth = Number(env['QWEN_CODE_MERMAID_RENDER_WIDTH']); if (Number.isFinite(configuredWidth) && configuredWidth > 0) { From bd826de884acd7eb025601b678e4c965b673977c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Tue, 28 Apr 2026 16:23:45 +0800 Subject: [PATCH 07/36] fix(cli): address markdown rendering review comments --- .../ui/utils/InlineMarkdownRenderer.test.tsx | 8 +++ .../src/ui/utils/InlineMarkdownRenderer.tsx | 12 +++-- .../cli/src/ui/utils/MarkdownDisplay.test.tsx | 26 +++++++++ packages/cli/src/ui/utils/MarkdownDisplay.tsx | 4 +- .../src/ui/utils/mermaidImageRenderer.test.ts | 53 +++++++++++++------ .../cli/src/ui/utils/mermaidImageRenderer.ts | 8 +++ 6 files changed, 90 insertions(+), 21 deletions(-) diff --git a/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx b/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx index b1f0ef819e9..b33d2cc885c 100644 --- a/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx +++ b/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx @@ -25,4 +25,12 @@ describe('', () => { expect(lastFrame()).toContain('α'); expect(lastFrame()).not.toContain('$\\alpha$'); }); + + it('does not parse ordinary dollar amounts as inline math', () => { + const { lastFrame } = renderWithProviders( + , + ); + + expect(lastFrame()).toContain('cost is $5 and $10 later'); + }); }); diff --git a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx index f7d568c2239..345ef49d1b2 100644 --- a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx +++ b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx @@ -19,6 +19,8 @@ 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_MATH_PATTERN = + /(? = ({ const nodes: React.ReactNode[] = []; let lastIndex = 0; const inlineRegex = enableInlineMath - ? /(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|\$[^$\n]{1,240}\$|.*?<\/u>|https?:\/\/\S+)/g + ? /(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|(?.*?<\/u>|https?:\/\/\S+)/g : /(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|.*?<\/u>|https?:\/\/\S+)/g; let match; @@ -201,8 +203,12 @@ export const getPlainTextLength = ( .replace(/_(.*?)_/g, '$1') .replace(/~~(.*?)~~/g, '$1') .replace(/`(.*?)`/g, '$1') - .replace(/\$([^$\n]{1,240})\$/g, (_match, expr: string) => - enableInlineMath ? renderInlineLatex(expr) : _match, + .replace(INLINE_MATH_PATTERN, (match: string) => + enableInlineMath + ? renderInlineLatex( + match.slice(INLINE_MATH_MARKER_LENGTH, -INLINE_MATH_MARKER_LENGTH), + ) + : match, ) .replace(/(.*?)<\/u>/g, '$1') .replace(/.*\[(.*?)\]\(.*\)/g, '$1'); diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx index 0c23f27a416..43d5acb4bf7 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx @@ -401,6 +401,15 @@ $$ expect(output).toContain('Σᵢ₌₁ⁿ xᵢ'); }); + it('does not treat ordinary dollar amounts as inline math', () => { + const text = 'The cost is $5 and $10 later.'.replace(/\n/g, eol); + const { lastFrame } = renderWithProviders( + , + ); + + expect(lastFrame()).toContain('The cost is $5 and $10 later.'); + }); + it('renders mermaid flowcharts as a visual preview', () => { const text = ` \`\`\`mermaid @@ -419,6 +428,23 @@ flowchart LR expect(output).not.toContain('flowchart LR'); }); + it('renders mermaid fences with info-string metadata as a visual preview', () => { + const text = ` +\`\`\`mermaid title="Flow" +flowchart LR + A[Client] --> B[API] +\`\`\` +`.replace(/\n/g, eol); + const { lastFrame } = renderWithProviders( + , + ); + const output = lastFrame(); + expect(output).toContain('Mermaid flowchart (LR)'); + expect(output).toContain('Client'); + expect(output).toContain('API'); + expect(output).not.toContain('flowchart LR'); + }); + it('reuses mermaid node labels when later edges reference node ids', () => { const text = ` \`\`\`mermaid diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.tsx index 856f9a51d50..5a5b6c8befc 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.tsx @@ -42,7 +42,7 @@ const MarkdownDisplayInternal: React.FC = ({ const lines = text.split(/\r?\n/); const headerRegex = /^ *(#{1,4}) +(.*)/; - const codeFenceRegex = /^ *(`{3,}|~{3,}) *(\w*?) *$/; + const codeFenceRegex = /^ *(`{3,}|~{3,}) *([^`]*)$/; const ulItemRegex = /^([ \t]*)([-*+]) +(.*)/; const olItemRegex = /^([ \t]*)(\d+)\. +(.*)/; const hrRegex = /^ *([-*_] *){3,} *$/; @@ -146,7 +146,7 @@ const MarkdownDisplayInternal: React.FC = ({ if (codeFenceMatch) { inCodeBlock = true; codeBlockFence = codeFenceMatch[1]; - codeBlockLang = codeFenceMatch[2] || null; + codeBlockLang = codeFenceMatch[2]?.trim().split(/\s+/)[0] || null; } else if (mathFenceMatch) { inMathBlock = true; mathBlockContent = []; diff --git a/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts b/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts index 8ccbf2f0c16..cee8ae11091 100644 --- a/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts +++ b/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts @@ -25,17 +25,18 @@ const PNG_1X1 = Buffer.from( const tempDirs: string[] = []; -function createFakeMmdc(binDir: string): void { +function createFakeMmdc(binDir: string, bodyLines?: string[]): void { const fakeMmdcScript = path.join(binDir, 'fake-mmdc.cjs'); + const defaultBodyLines = [ + 'const fs = require("node:fs");', + 'const out = process.argv[process.argv.indexOf("-o") + 1];', + `fs.writeFileSync(out, Buffer.from("${PNG_1X1.toString( + 'base64', + )}", "base64"));`, + ]; fs.writeFileSync( fakeMmdcScript, - [ - 'const fs = require("node:fs");', - 'const out = process.argv[process.argv.indexOf("-o") + 1];', - `fs.writeFileSync(out, Buffer.from("${PNG_1X1.toString( - 'base64', - )}", "base64"));`, - ].join('\n'), + (bodyLines ?? defaultBodyLines).join('\n'), 'utf8', ); @@ -46,14 +47,7 @@ function createFakeMmdc(binDir: string): void { const command = process.platform === 'win32' ? `@echo off\r\n"${process.execPath}" "${fakeMmdcScript}" %*\r\n` - : [ - '#!/usr/bin/env node', - 'const fs = require("node:fs");', - 'const out = process.argv[process.argv.indexOf("-o") + 1];', - `fs.writeFileSync(out, Buffer.from("${PNG_1X1.toString( - 'base64', - )}", "base64"));`, - ].join('\n'); + : ['#!/usr/bin/env node', ...(bodyLines ?? defaultBodyLines)].join('\n'); fs.writeFileSync(fakeMmdc, command, 'utf8'); fs.chmodSync(fakeMmdc, 0o755); } @@ -204,4 +198,31 @@ describe('mermaid image renderer', () => { result.kind === 'terminal-image' && result.placeholder?.lines[0], ).toContain('\u{10EEEE}'); }); + + it('rejects oversized Mermaid PNG output before reading it', () => { + const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-mmdc-')); + tempDirs.push(binDir); + createFakeMmdc(binDir, [ + 'const fs = require("node:fs");', + 'const out = process.argv[process.argv.indexOf("-o") + 1];', + 'fs.closeSync(fs.openSync(out, "w"));', + 'fs.truncateSync(out, 8 * 1024 * 1024 + 1);', + ]); + + const result = renderMermaidImageSync({ + source: 'flowchart TD\n A[Start] --> B[End]', + contentWidth: 80, + availableTerminalHeight: 20, + env: { + PATH: `${binDir}${path.delimiter}${process.env['PATH'] ?? ''}`, + QWEN_CODE_MERMAID_IMAGE_RENDERING: '1', + QWEN_CODE_MERMAID_IMAGE_PROTOCOL: 'kitty', + }, + }); + + expect(result.kind).toBe('unavailable'); + expect(result.kind === 'unavailable' && result.reason).toContain( + 'exceeded', + ); + }); }); diff --git a/packages/cli/src/ui/utils/mermaidImageRenderer.ts b/packages/cli/src/ui/utils/mermaidImageRenderer.ts index a22fac62509..827e4688430 100644 --- a/packages/cli/src/ui/utils/mermaidImageRenderer.ts +++ b/packages/cli/src/ui/utils/mermaidImageRenderer.ts @@ -59,6 +59,7 @@ const CACHE_LIMIT = 40; const PNG_CACHE_LIMIT = 20; const DEFAULT_RENDER_TIMEOUT_MS = 8000; const DEFAULT_MERMAID_RENDER_WIDTH = 1280; +const MAX_MERMAID_PNG_BYTES = 8 * 1024 * 1024; const NPX_MERMAID_CLI = 'npx:@mermaid-js/mermaid-cli@11.12.0'; const PNG_SIGNATURE = '89504e470d0a1a0a'; const KITTY_PLACEHOLDER = '\u{10EEEE}'; @@ -649,6 +650,13 @@ function renderPngWithMmdc( if (!fs.existsSync(outputPath)) { return { ok: false, error: 'Mermaid CLI did not write an output file.' }; } + const outputSize = fs.statSync(outputPath).size; + if (outputSize > MAX_MERMAID_PNG_BYTES) { + return { + ok: false, + error: `Mermaid CLI output exceeded ${MAX_MERMAID_PNG_BYTES} bytes.`, + }; + } return { ok: true, png: fs.readFileSync(outputPath) }; } finally { From 1a5793d3397fce0537dbc5b180c2e3fd6bf64cab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Tue, 28 Apr 2026 16:29:47 +0800 Subject: [PATCH 08/36] fix(cli): validate mermaid render timeout --- .../src/ui/utils/mermaidImageRenderer.test.ts | 21 +++++++++++++++++++ .../cli/src/ui/utils/mermaidImageRenderer.ts | 12 ++++++++--- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts b/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts index cee8ae11091..24c9b181393 100644 --- a/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts +++ b/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts @@ -172,6 +172,27 @@ describe('mermaid image renderer', () => { ); }); + it('falls back to the default render timeout when configured timeout is invalid', () => { + const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-mmdc-')); + tempDirs.push(binDir); + createFakeMmdc(binDir); + + const result = renderMermaidImageSync({ + source: 'flowchart TD\n A[Start] --> B[End invalid timeout]', + contentWidth: 80, + availableTerminalHeight: 20, + env: { + PATH: `${binDir}${path.delimiter}${process.env['PATH'] ?? ''}`, + QWEN_CODE_MERMAID_IMAGE_RENDERING: '1', + QWEN_CODE_MERMAID_IMAGE_PROTOCOL: 'iterm2', + QWEN_CODE_MERMAID_RENDER_TIMEOUT_MS: 'not-a-number', + }, + }); + + expect(result.kind).toBe('terminal-image'); + expect(result.kind === 'terminal-image' && result.protocol).toBe('iterm2'); + }); + it('renders Kitty terminal images as virtual placements', () => { const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-mmdc-')); tempDirs.push(binDir); diff --git a/packages/cli/src/ui/utils/mermaidImageRenderer.ts b/packages/cli/src/ui/utils/mermaidImageRenderer.ts index 827e4688430..e474a6abea5 100644 --- a/packages/cli/src/ui/utils/mermaidImageRenderer.ts +++ b/packages/cli/src/ui/utils/mermaidImageRenderer.ts @@ -632,9 +632,7 @@ function renderPngWithMmdc( ...env, }, shell: shouldRunThroughShell(command), - timeout: Number( - env['QWEN_CODE_MERMAID_RENDER_TIMEOUT_MS'] ?? DEFAULT_RENDER_TIMEOUT_MS, - ), + timeout: getMermaidRenderTimeout(env), }); if (result.error) { @@ -676,6 +674,14 @@ function getMermaidRenderWidth(env: NodeJS.ProcessEnv): number { return DEFAULT_MERMAID_RENDER_WIDTH; } +function getMermaidRenderTimeout(env: NodeJS.ProcessEnv): number { + const configuredTimeout = Number(env['QWEN_CODE_MERMAID_RENDER_TIMEOUT_MS']); + if (Number.isFinite(configuredTimeout) && configuredTimeout > 0) { + return Math.round(configuredTimeout); + } + return DEFAULT_RENDER_TIMEOUT_MS; +} + function renderPngWithChafa( png: Buffer, widthCells: number, From 4df35e3894805fdcf07b7b40b178c81218244bdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Tue, 28 Apr 2026 19:49:53 +0800 Subject: [PATCH 09/36] feat(cli): expose rendered markdown source blocks --- packages/cli/src/config/keyBindings.ts | 2 + packages/cli/src/ui/AppContainer.tsx | 24 ++- .../cli/src/ui/commands/copyCommand.test.ts | 156 ++++++++++++++++++ packages/cli/src/ui/commands/copyCommand.ts | 98 ++++++++++- .../ui/contexts/MarkdownRenderingContext.tsx | 26 +++ packages/cli/src/ui/keyMatchers.test.ts | 7 + .../cli/src/ui/utils/MarkdownDisplay.test.tsx | 26 +++ packages/cli/src/ui/utils/MarkdownDisplay.tsx | 14 +- packages/cli/src/ui/utils/MermaidDiagram.tsx | 13 +- 9 files changed, 356 insertions(+), 10 deletions(-) create mode 100644 packages/cli/src/ui/contexts/MarkdownRenderingContext.tsx diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index 196602ba1d0..d1490c98e4a 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -52,6 +52,7 @@ export enum Command { SHOW_MORE_LINES = 'showMoreLines', RETRY_LAST = 'retryLast', TOGGLE_COMPACT_MODE = 'toggleCompactMode', + TOGGLE_MARKDOWN_RENDER_MODE = 'toggleMarkdownRenderMode', // Shell commands REVERSE_SEARCH = 'reverseSearch', @@ -174,6 +175,7 @@ export const defaultKeyBindings: KeyBindingConfig = { [Command.SHOW_MORE_LINES]: [{ key: 's', ctrl: true }], [Command.RETRY_LAST]: [{ key: 'y', ctrl: true }], [Command.TOGGLE_COMPACT_MODE]: [{ key: 'o', ctrl: true }], + [Command.TOGGLE_MARKDOWN_RENDER_MODE]: [{ key: 'm', meta: true }], // Shell commands [Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }], diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index e33beca8ac0..6acd0cdc048 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -126,6 +126,10 @@ import { } from './hooks/useExtensionUpdates.js'; import { useCodingPlanUpdates } from './hooks/useCodingPlanUpdates.js'; import { ShellFocusContext } from './contexts/ShellFocusContext.js'; +import { + MarkdownRenderingProvider, + type MermaidRenderMode, +} from './contexts/MarkdownRenderingContext.js'; import { useAgentViewState } from './contexts/AgentViewContext.js'; import { useBackgroundTaskViewState, @@ -1532,6 +1536,8 @@ export const AppContainer = (props: AppContainerProps) => { const [compactMode, setCompactMode] = useState( settings.merged.ui?.compactMode ?? false, ); + const [mermaidRenderMode, setMermaidRenderMode] = + useState('visual'); const [ctrlCPressedOnce, setCtrlCPressedOnce] = useState(false); const ctrlCTimerRef = useRef(null); const [ctrlDPressedOnce, setCtrlDPressedOnce] = useState(false); @@ -2151,6 +2157,11 @@ export const AppContainer = (props: AppContainerProps) => { setCompactMode(newValue); void settings.setValue(SettingScope.User, 'ui.compactMode', newValue); refreshStatic(); + } else if (keyMatchers[Command.TOGGLE_MARKDOWN_RENDER_MODE](key)) { + setMermaidRenderMode((current) => + current === 'visual' ? 'source' : 'visual', + ); + refreshStatic(); } }, [ @@ -2186,6 +2197,7 @@ export const AppContainer = (props: AppContainerProps) => { isAuthenticating, compactMode, setCompactMode, + setMermaidRenderMode, refreshStatic, handleDoubleEscRewind, ], @@ -2665,6 +2677,10 @@ export const AppContainer = (props: AppContainerProps) => { () => ({ compactMode, setCompactMode }), [compactMode, setCompactMode], ); + const markdownRenderingValue = useMemo( + () => ({ mermaidRenderMode, setMermaidRenderMode }), + [mermaidRenderMode, setMermaidRenderMode], + ); return ( @@ -2677,9 +2693,11 @@ export const AppContainer = (props: AppContainerProps) => { }} > - - - + + + + + diff --git a/packages/cli/src/ui/commands/copyCommand.test.ts b/packages/cli/src/ui/commands/copyCommand.test.ts index b075319674c..ffdb0f24780 100644 --- a/packages/cli/src/ui/commands/copyCommand.test.ts +++ b/packages/cli/src/ui/commands/copyCommand.test.ts @@ -215,6 +215,162 @@ describe('copyCommand', () => { }); }); + it('should copy the last fenced code block with /copy code', async () => { + if (!copyCommand.action) throw new Error('Command has no action'); + + mockGetHistory.mockReturnValue([ + { + role: 'model', + parts: [ + { + text: [ + 'Example:', + '```js', + 'const first = true;', + '```', + '```mermaid', + 'flowchart TD', + ' A --> B', + '```', + ].join('\n'), + }, + ], + }, + ]); + mockCopyToClipboard.mockResolvedValue(undefined); + + const result = await copyCommand.action(mockContext, 'code'); + + expect(mockCopyToClipboard).toHaveBeenCalledWith('flowchart TD\n A --> B'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Code block 2 copied to the clipboard', + }); + }); + + it('should copy a numbered fenced code block with /copy code 2', async () => { + if (!copyCommand.action) throw new Error('Command has no action'); + + mockGetHistory.mockReturnValue([ + { + role: 'model', + parts: [ + { + text: [ + '```ts', + 'const first = 1;', + '```', + '```json', + '{"second": true}', + '```', + ].join('\n'), + }, + ], + }, + ]); + mockCopyToClipboard.mockResolvedValue(undefined); + + const result = await copyCommand.action(mockContext, 'code 2'); + + expect(mockCopyToClipboard).toHaveBeenCalledWith('{"second": true}'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Code block 2 copied to the clipboard', + }); + }); + + it('should copy the last matching language code block with /copy code mermaid', async () => { + if (!copyCommand.action) throw new Error('Command has no action'); + + mockGetHistory.mockReturnValue([ + { + role: 'model', + parts: [ + { + text: [ + '```mermaid title="First"', + 'flowchart LR', + ' A --> B', + '```', + '```mermaid', + 'sequenceDiagram', + ' A->>B: hello', + '```', + ].join('\n'), + }, + ], + }, + ]); + mockCopyToClipboard.mockResolvedValue(undefined); + + const result = await copyCommand.action(mockContext, 'code mermaid'); + + expect(mockCopyToClipboard).toHaveBeenCalledWith( + 'sequenceDiagram\n A->>B: hello', + ); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Code block 2 copied to the clipboard', + }); + }); + + it('should copy a numbered matching language block with /copy code mermaid 1', async () => { + if (!copyCommand.action) throw new Error('Command has no action'); + + mockGetHistory.mockReturnValue([ + { + role: 'model', + parts: [ + { + text: [ + '```mermaid', + 'flowchart LR', + ' A --> B', + '```', + '```mermaid', + 'flowchart TD', + ' C --> D', + '```', + ].join('\n'), + }, + ], + }, + ]); + mockCopyToClipboard.mockResolvedValue(undefined); + + const result = await copyCommand.action(mockContext, 'code mermaid 1'); + + expect(mockCopyToClipboard).toHaveBeenCalledWith('flowchart LR\n A --> B'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Code block 1 copied to the clipboard', + }); + }); + + it('should report when /copy code has no matching code block', async () => { + if (!copyCommand.action) throw new Error('Command has no action'); + + mockGetHistory.mockReturnValue([ + { + role: 'model', + parts: [{ text: 'No code here.' }], + }, + ]); + + const result = await copyCommand.action(mockContext, 'code'); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'No matching code block found in the last AI output.', + }); + expect(mockCopyToClipboard).not.toHaveBeenCalled(); + }); + it('should handle clipboard copy error', async () => { if (!copyCommand.action) throw new Error('Command has no action'); diff --git a/packages/cli/src/ui/commands/copyCommand.ts b/packages/cli/src/ui/commands/copyCommand.ts index 6349869021f..81c597702b3 100644 --- a/packages/cli/src/ui/commands/copyCommand.ts +++ b/packages/cli/src/ui/commands/copyCommand.ts @@ -9,6 +9,88 @@ import type { SlashCommand, SlashCommandActionReturn } from './types.js'; import { CommandKind } from './types.js'; import { t } from '../../i18n/index.js'; +interface FencedCodeBlock { + lang: string | null; + content: string; + index: number; +} + +function parseFencedCodeBlocks(markdown: string): FencedCodeBlock[] { + const blocks: FencedCodeBlock[] = []; + const lines = markdown.split(/\r?\n/); + const fenceRegex = /^ *(`{3,}|~{3,}) *([^`]*)$/; + let activeFence: string | null = null; + let activeLang: string | null = null; + let activeLines: string[] = []; + + for (const line of lines) { + const match = line.match(fenceRegex); + if (!activeFence) { + if (match) { + activeFence = match[1]; + activeLang = match[2]?.trim().split(/\s+/)[0]?.toLowerCase() || null; + activeLines = []; + } + continue; + } + + if ( + match && + match[1].startsWith(activeFence[0]) && + match[1].length >= activeFence.length + ) { + blocks.push({ + lang: activeLang, + content: activeLines.join('\n'), + index: blocks.length + 1, + }); + activeFence = null; + activeLang = null; + activeLines = []; + continue; + } + + activeLines.push(line); + } + + return blocks; +} + +function selectCodeBlock( + markdown: string, + args: string, +): FencedCodeBlock | null | undefined { + const tokens = args.trim().split(/\s+/).filter(Boolean); + if (tokens[0]?.toLowerCase() !== 'code') return undefined; + + const blocks = parseFencedCodeBlocks(markdown); + if (blocks.length === 0) return null; + + let lang: string | null = null; + let requestedIndex: number | null = null; + for (const token of tokens.slice(1)) { + if (/^\d+$/.test(token)) { + requestedIndex = Number(token); + } else { + lang = token.toLowerCase(); + } + } + + const candidates = lang + ? blocks.filter((block) => block.lang === lang) + : blocks; + if (candidates.length === 0) return null; + + if (requestedIndex !== null) { + const requested = lang + ? candidates[requestedIndex - 1] + : blocks.find((block) => block.index === requestedIndex); + return requested ?? null; + } + + return candidates[candidates.length - 1] ?? null; +} + export const copyCommand: SlashCommand = { name: 'copy', get description() { @@ -40,12 +122,24 @@ export const copyCommand: SlashCommand = { if (lastAiOutput) { try { - await copyToClipboard(lastAiOutput); + const selectedCodeBlock = selectCodeBlock(lastAiOutput, _args); + if (selectedCodeBlock === null) { + return { + type: 'message', + messageType: 'info', + content: 'No matching code block found in the last AI output.', + }; + } + + const copiedText = selectedCodeBlock?.content ?? lastAiOutput; + await copyToClipboard(copiedText); return { type: 'message', messageType: 'info', - content: 'Last output copied to the clipboard', + content: selectedCodeBlock + ? `Code block ${selectedCodeBlock.index} copied to the clipboard` + : 'Last output copied to the clipboard', }; } catch (error) { const message = error instanceof Error ? error.message : String(error); diff --git a/packages/cli/src/ui/contexts/MarkdownRenderingContext.tsx b/packages/cli/src/ui/contexts/MarkdownRenderingContext.tsx new file mode 100644 index 00000000000..c3303f4c3d7 --- /dev/null +++ b/packages/cli/src/ui/contexts/MarkdownRenderingContext.tsx @@ -0,0 +1,26 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; + +export type MermaidRenderMode = 'visual' | 'source'; + +interface MarkdownRenderingContextValue { + mermaidRenderMode: MermaidRenderMode; + setMermaidRenderMode: React.Dispatch>; +} + +const MarkdownRenderingContext = + React.createContext({ + mermaidRenderMode: 'visual', + setMermaidRenderMode: () => undefined, + }); + +export const MarkdownRenderingProvider = MarkdownRenderingContext.Provider; + +export function useMarkdownRendering(): MarkdownRenderingContextValue { + return React.useContext(MarkdownRenderingContext); +} diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts index d98cc2dceb5..ffee0ea2e4c 100644 --- a/packages/cli/src/ui/keyMatchers.test.ts +++ b/packages/cli/src/ui/keyMatchers.test.ts @@ -61,6 +61,8 @@ describe('keyMatchers', () => { [Command.SHOW_MORE_LINES]: (key: Key) => key.ctrl && key.name === 's', [Command.RETRY_LAST]: (key: Key) => key.ctrl && key.name === 'y', [Command.TOGGLE_COMPACT_MODE]: (key: Key) => key.ctrl && key.name === 'o', + [Command.TOGGLE_MARKDOWN_RENDER_MODE]: (key: Key) => + key.meta && key.name === 'm', [Command.REVERSE_SEARCH]: (key: Key) => key.ctrl && key.name === 'r', [Command.SUBMIT_REVERSE_SEARCH]: (key: Key) => key.name === 'return' && !key.ctrl, @@ -286,6 +288,11 @@ describe('keyMatchers', () => { positive: [createKey('f', { ctrl: true })], negative: [createKey('f')], }, + { + command: Command.TOGGLE_MARKDOWN_RENDER_MODE, + positive: [createKey('m', { meta: true })], + negative: [createKey('m'), createKey('m', { ctrl: true })], + }, ]; describe('Data-driven key binding matches original logic', () => { diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx index 43d5acb4bf7..e198f7e4faf 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx @@ -9,6 +9,7 @@ import { MarkdownDisplay } from './MarkdownDisplay.js'; import { LoadedSettings } from '../../config/settings.js'; import { renderWithProviders } from '../../test-utils/render.js'; import { renderMermaidVisual } from './mermaidVisualRenderer.js'; +import { MarkdownRenderingProvider } from '../contexts/MarkdownRenderingContext.js'; describe('', () => { const baseProps = { @@ -422,6 +423,7 @@ flowchart LR ); const output = lastFrame(); expect(output).toContain('Mermaid flowchart (LR)'); + expect(output).toContain('source: /copy code 1'); expect(output).toContain('Client'); expect(output).toContain('API'); expect(output).toContain('▶'); @@ -440,11 +442,35 @@ flowchart LR ); const output = lastFrame(); expect(output).toContain('Mermaid flowchart (LR)'); + expect(output).toContain('source: /copy code 1'); expect(output).toContain('Client'); expect(output).toContain('API'); expect(output).not.toContain('flowchart LR'); }); + it('can render mermaid fences as source when source mode is active', () => { + const text = ` +\`\`\`mermaid +flowchart LR + A[Client] --> B[API] +\`\`\` +`.replace(/\n/g, eol); + const { lastFrame } = renderWithProviders( + undefined, + }} + > + + , + ); + const output = lastFrame(); + expect(output).toContain('flowchart LR'); + expect(output).toContain('A[Client] --> B[API]'); + expect(output).not.toContain('Mermaid flowchart'); + }); + it('reuses mermaid node labels when later edges reference node ids', () => { const text = ` \`\`\`mermaid diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.tsx index 5a5b6c8befc..d33e0f7d679 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.tsx @@ -13,6 +13,7 @@ import { RenderInline } from './InlineMarkdownRenderer.js'; import { useSettings } from '../contexts/SettingsContext.js'; import { MermaidDiagram } from './MermaidDiagram.js'; import { renderInlineLatex } from './latexRenderer.js'; +import { useMarkdownRendering } from '../contexts/MarkdownRenderingContext.js'; interface MarkdownDisplayProps { text: string; @@ -68,6 +69,8 @@ const MarkdownDisplayInternal: React.FC = ({ const contentBlocks: React.ReactNode[] = []; let inCodeBlock = false; + let codeBlockIndex = 0; + let currentCodeBlockIndex = 0; let lastLineEmpty = true; let codeBlockContent: string[] = []; let codeBlockLang: string | null = null; @@ -101,12 +104,14 @@ const MarkdownDisplayInternal: React.FC = ({ key={key} content={codeBlockContent} lang={codeBlockLang} + codeBlockIndex={currentCodeBlockIndex} isPending={isPending} availableTerminalHeight={availableTerminalHeight} contentWidth={contentWidth} />, ); inCodeBlock = false; + currentCodeBlockIndex = 0; codeBlockContent = []; codeBlockLang = null; codeBlockFence = ''; @@ -145,6 +150,8 @@ const MarkdownDisplayInternal: React.FC = ({ if (codeFenceMatch) { inCodeBlock = true; + codeBlockIndex += 1; + currentCodeBlockIndex = codeBlockIndex; codeBlockFence = codeFenceMatch[1]; codeBlockLang = codeFenceMatch[2]?.trim().split(/\s+/)[0] || null; } else if (mathFenceMatch) { @@ -364,6 +371,7 @@ const MarkdownDisplayInternal: React.FC = ({ key="line-eof" content={codeBlockContent} lang={codeBlockLang} + codeBlockIndex={currentCodeBlockIndex} isPending={isPending} availableTerminalHeight={availableTerminalHeight} contentWidth={contentWidth} @@ -402,6 +410,7 @@ const MarkdownDisplayInternal: React.FC = ({ interface RenderCodeBlockProps { content: string[]; lang: string | null; + codeBlockIndex: number; isPending: boolean; availableTerminalHeight?: number; contentWidth: number; @@ -410,15 +419,17 @@ interface RenderCodeBlockProps { const RenderCodeBlockInternal: React.FC = ({ content, lang, + codeBlockIndex, isPending, availableTerminalHeight, contentWidth, }) => { const settings = useSettings(); + const { mermaidRenderMode } = useMarkdownRendering(); const MIN_LINES_FOR_MESSAGE = 1; // Minimum lines to show before the "generating more" message const RESERVED_LINES = 2; // Lines reserved for the message itself and potential padding - if (lang?.toLowerCase() === 'mermaid') { + if (lang?.toLowerCase() === 'mermaid' && mermaidRenderMode === 'visual') { if (isPending) { return ( = ({ return ( = ({ source, + sourceCodeIndex, contentWidth, isPending, availableTerminalHeight, @@ -60,6 +62,9 @@ const MermaidDiagramInternal: React.FC = ({ write(kittySequence); }, [kittySequence, write]); + const titleWithSourceHint = (title: string) => + `${title} · source: /copy code ${sourceCodeIndex}`; + if ( image?.kind === 'terminal-image' && image.protocol === 'kitty' && @@ -73,7 +78,7 @@ const MermaidDiagramInternal: React.FC = ({ flexShrink={0} > - {image.title} + {titleWithSourceHint(visual.title)} = ({ flexShrink={0} > - {image.title} + {titleWithSourceHint(visual.title)} {image.sequence} @@ -119,7 +124,7 @@ const MermaidDiagramInternal: React.FC = ({ flexShrink={0} > - {image.title} + {titleWithSourceHint(visual.title)} = ({ flexShrink={0} > - {visual.title} + {titleWithSourceHint(visual.title)} Date: Tue, 28 Apr 2026 20:22:19 +0800 Subject: [PATCH 10/36] fix(cli): align mermaid source copy controls --- packages/cli/src/config/keyBindings.ts | 5 +- .../cli/src/ui/commands/copyCommand.test.ts | 37 +++++++++++- packages/cli/src/ui/commands/copyCommand.ts | 57 ++++++++++++++++--- .../src/ui/contexts/KeypressContext.test.tsx | 31 ++++++++++ .../cli/src/ui/contexts/KeypressContext.tsx | 14 ++++- packages/cli/src/ui/keyMatchers.test.ts | 7 ++- .../cli/src/ui/utils/MarkdownDisplay.test.tsx | 23 +++++++- packages/cli/src/ui/utils/MarkdownDisplay.tsx | 18 +++++- packages/cli/src/ui/utils/MermaidDiagram.tsx | 6 +- 9 files changed, 177 insertions(+), 21 deletions(-) diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index d1490c98e4a..dd0a4ba0d1c 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -175,7 +175,10 @@ export const defaultKeyBindings: KeyBindingConfig = { [Command.SHOW_MORE_LINES]: [{ key: 's', ctrl: true }], [Command.RETRY_LAST]: [{ key: 'y', ctrl: true }], [Command.TOGGLE_COMPACT_MODE]: [{ key: 'o', ctrl: true }], - [Command.TOGGLE_MARKDOWN_RENDER_MODE]: [{ key: 'm', meta: true }], + [Command.TOGGLE_MARKDOWN_RENDER_MODE]: [ + { key: 'm', meta: true }, + { sequence: 'µ' }, + ], // Shell commands [Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }], diff --git a/packages/cli/src/ui/commands/copyCommand.test.ts b/packages/cli/src/ui/commands/copyCommand.test.ts index ffdb0f24780..b168ce64453 100644 --- a/packages/cli/src/ui/commands/copyCommand.test.ts +++ b/packages/cli/src/ui/commands/copyCommand.test.ts @@ -313,7 +313,7 @@ describe('copyCommand', () => { expect(result).toEqual({ type: 'message', messageType: 'info', - content: 'Code block 2 copied to the clipboard', + content: 'mermaid code block 2 copied to the clipboard', }); }); @@ -347,7 +347,40 @@ describe('copyCommand', () => { expect(result).toEqual({ type: 'message', messageType: 'info', - content: 'Code block 1 copied to the clipboard', + content: 'mermaid code block 1 copied to the clipboard', + }); + }); + + it('should copy a numbered matching language block with /copy mermaid 1', async () => { + if (!copyCommand.action) throw new Error('Command has no action'); + + mockGetHistory.mockReturnValue([ + { + role: 'model', + parts: [ + { + text: [ + '```ts', + 'const before = true;', + '```', + '```mermaid', + 'flowchart LR', + ' A --> B', + '```', + ].join('\n'), + }, + ], + }, + ]); + mockCopyToClipboard.mockResolvedValue(undefined); + + const result = await copyCommand.action(mockContext, 'mermaid 1'); + + expect(mockCopyToClipboard).toHaveBeenCalledWith('flowchart LR\n A --> B'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'mermaid code block 1 copied to the clipboard', }); }); diff --git a/packages/cli/src/ui/commands/copyCommand.ts b/packages/cli/src/ui/commands/copyCommand.ts index 81c597702b3..45b2d0b17f2 100644 --- a/packages/cli/src/ui/commands/copyCommand.ts +++ b/packages/cli/src/ui/commands/copyCommand.ts @@ -13,14 +13,22 @@ interface FencedCodeBlock { lang: string | null; content: string; index: number; + langIndex: number | null; +} + +interface SelectedCodeBlock { + block: FencedCodeBlock; + label: string; } function parseFencedCodeBlocks(markdown: string): FencedCodeBlock[] { const blocks: FencedCodeBlock[] = []; const lines = markdown.split(/\r?\n/); const fenceRegex = /^ *(`{3,}|~{3,}) *([^`]*)$/; + const languageCounts = new Map(); let activeFence: string | null = null; let activeLang: string | null = null; + let activeLangIndex: number | null = null; let activeLines: string[] = []; for (const line of lines) { @@ -29,6 +37,13 @@ function parseFencedCodeBlocks(markdown: string): FencedCodeBlock[] { if (match) { activeFence = match[1]; activeLang = match[2]?.trim().split(/\s+/)[0]?.toLowerCase() || null; + if (activeLang) { + const nextLangIndex = (languageCounts.get(activeLang) ?? 0) + 1; + languageCounts.set(activeLang, nextLangIndex); + activeLangIndex = nextLangIndex; + } else { + activeLangIndex = null; + } activeLines = []; } continue; @@ -43,9 +58,11 @@ function parseFencedCodeBlocks(markdown: string): FencedCodeBlock[] { lang: activeLang, content: activeLines.join('\n'), index: blocks.length + 1, + langIndex: activeLangIndex, }); activeFence = null; activeLang = null; + activeLangIndex = null; activeLines = []; continue; } @@ -59,19 +76,26 @@ function parseFencedCodeBlocks(markdown: string): FencedCodeBlock[] { function selectCodeBlock( markdown: string, args: string, -): FencedCodeBlock | null | undefined { +): SelectedCodeBlock | null | undefined { const tokens = args.trim().split(/\s+/).filter(Boolean); - if (tokens[0]?.toLowerCase() !== 'code') return undefined; + const firstToken = tokens[0]?.toLowerCase(); + if (!firstToken) return undefined; const blocks = parseFencedCodeBlocks(markdown); if (blocks.length === 0) return null; let lang: string | null = null; let requestedIndex: number | null = null; - for (const token of tokens.slice(1)) { + const selectorTokens = + firstToken === 'code' ? tokens.slice(1) : tokens.map((token) => token); + if (firstToken !== 'code') { + lang = firstToken; + } + + for (const token of selectorTokens) { if (/^\d+$/.test(token)) { requestedIndex = Number(token); - } else { + } else if (token.toLowerCase() !== 'code') { lang = token.toLowerCase(); } } @@ -83,12 +107,27 @@ function selectCodeBlock( if (requestedIndex !== null) { const requested = lang - ? candidates[requestedIndex - 1] + ? candidates.find((block) => block.langIndex === requestedIndex) : blocks.find((block) => block.index === requestedIndex); - return requested ?? null; + return requested + ? { block: requested, label: formatCodeBlockLabel(requested, lang) } + : null; } - return candidates[candidates.length - 1] ?? null; + const selected = candidates[candidates.length - 1]; + return selected + ? { block: selected, label: formatCodeBlockLabel(selected, lang) } + : null; +} + +function formatCodeBlockLabel( + block: FencedCodeBlock, + selectedLanguage: string | null, +): string { + if (selectedLanguage && block.langIndex !== null) { + return `${selectedLanguage} code block ${block.langIndex}`; + } + return `Code block ${block.index}`; } export const copyCommand: SlashCommand = { @@ -131,14 +170,14 @@ export const copyCommand: SlashCommand = { }; } - const copiedText = selectedCodeBlock?.content ?? lastAiOutput; + const copiedText = selectedCodeBlock?.block.content ?? lastAiOutput; await copyToClipboard(copiedText); return { type: 'message', messageType: 'info', content: selectedCodeBlock - ? `Code block ${selectedCodeBlock.index} copied to the clipboard` + ? `${selectedCodeBlock.label} copied to the clipboard` : 'Last output copied to the clipboard', }; } catch (error) { diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx index 73542306566..504b4f5a727 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx @@ -91,6 +91,37 @@ describe('KeypressContext - Kitty Protocol', () => { }); describe('Enter key handling', () => { + it('normalizes macOS option-m text into a meta-m shortcut event', () => { + const keyHandler = vi.fn(); + + const { result } = renderHook(() => useKeypressContext(), { + wrapper, + }); + + act(() => { + result.current.subscribe(keyHandler); + }); + + act(() => { + stdin.pressKey({ + name: 'µ', + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: 'µ', + }); + }); + + expect(keyHandler).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'm', + meta: true, + sequence: 'µ', + }), + ); + }); + it('should recognize regular enter key (keycode 13) in kitty protocol', async () => { const keyHandler = vi.fn(); diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index 06c16d6ea9a..5fa53f09358 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -652,9 +652,21 @@ export function KeypressProvider({ }; }; + const normalizeKey = (key: Key): Key => { + if (key.sequence === 'µ' && !key.ctrl && !key.meta && !key.paste) { + return { + ...key, + name: 'm', + meta: true, + }; + } + return key; + }; + const broadcast = (key: Key) => { + const normalizedKey = normalizeKey(key); for (const handler of subscribers) { - handler(key); + handler(normalizedKey); } }; diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts index ffee0ea2e4c..5d6ac6c4c01 100644 --- a/packages/cli/src/ui/keyMatchers.test.ts +++ b/packages/cli/src/ui/keyMatchers.test.ts @@ -62,7 +62,7 @@ describe('keyMatchers', () => { [Command.RETRY_LAST]: (key: Key) => key.ctrl && key.name === 'y', [Command.TOGGLE_COMPACT_MODE]: (key: Key) => key.ctrl && key.name === 'o', [Command.TOGGLE_MARKDOWN_RENDER_MODE]: (key: Key) => - key.meta && key.name === 'm', + (key.meta && key.name === 'm') || key.sequence === 'µ', [Command.REVERSE_SEARCH]: (key: Key) => key.ctrl && key.name === 'r', [Command.SUBMIT_REVERSE_SEARCH]: (key: Key) => key.name === 'return' && !key.ctrl, @@ -290,7 +290,10 @@ describe('keyMatchers', () => { }, { command: Command.TOGGLE_MARKDOWN_RENDER_MODE, - positive: [createKey('m', { meta: true })], + positive: [ + createKey('m', { meta: true }), + createKey('', { sequence: 'µ' }), + ], negative: [createKey('m'), createKey('m', { ctrl: true })], }, ]; diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx index e198f7e4faf..79911dd717b 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx @@ -423,7 +423,7 @@ flowchart LR ); const output = lastFrame(); expect(output).toContain('Mermaid flowchart (LR)'); - expect(output).toContain('source: /copy code 1'); + expect(output).toContain('source: /copy mermaid 1'); expect(output).toContain('Client'); expect(output).toContain('API'); expect(output).toContain('▶'); @@ -442,12 +442,31 @@ flowchart LR ); const output = lastFrame(); expect(output).toContain('Mermaid flowchart (LR)'); - expect(output).toContain('source: /copy code 1'); + expect(output).toContain('source: /copy mermaid 1'); expect(output).toContain('Client'); expect(output).toContain('API'); expect(output).not.toContain('flowchart LR'); }); + it('labels mermaid source hints with language-specific numbering', () => { + const text = ` +\`\`\`ts +const before = true; +\`\`\` + +\`\`\`mermaid +flowchart LR + A[Client] --> B[API] +\`\`\` +`.replace(/\n/g, eol); + const { lastFrame } = renderWithProviders( + , + ); + const output = lastFrame(); + expect(output).toContain('source: /copy mermaid 1'); + expect(output).not.toContain('source: /copy code 2'); + }); + it('can render mermaid fences as source when source mode is active', () => { const text = ` \`\`\`mermaid diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.tsx index d33e0f7d679..f87f66f4265 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.tsx @@ -71,6 +71,8 @@ const MarkdownDisplayInternal: React.FC = ({ let inCodeBlock = false; let codeBlockIndex = 0; let currentCodeBlockIndex = 0; + let currentCodeBlockLangIndex = 0; + const codeBlockLanguageCounts = new Map(); let lastLineEmpty = true; let codeBlockContent: string[] = []; let codeBlockLang: string | null = null; @@ -105,6 +107,7 @@ const MarkdownDisplayInternal: React.FC = ({ content={codeBlockContent} lang={codeBlockLang} codeBlockIndex={currentCodeBlockIndex} + codeBlockLangIndex={currentCodeBlockLangIndex} isPending={isPending} availableTerminalHeight={availableTerminalHeight} contentWidth={contentWidth} @@ -112,6 +115,7 @@ const MarkdownDisplayInternal: React.FC = ({ ); inCodeBlock = false; currentCodeBlockIndex = 0; + currentCodeBlockLangIndex = 0; codeBlockContent = []; codeBlockLang = null; codeBlockFence = ''; @@ -154,6 +158,15 @@ const MarkdownDisplayInternal: React.FC = ({ currentCodeBlockIndex = codeBlockIndex; codeBlockFence = codeFenceMatch[1]; codeBlockLang = codeFenceMatch[2]?.trim().split(/\s+/)[0] || null; + if (codeBlockLang) { + const normalizedLang = codeBlockLang.toLowerCase(); + const nextLangIndex = + (codeBlockLanguageCounts.get(normalizedLang) ?? 0) + 1; + codeBlockLanguageCounts.set(normalizedLang, nextLangIndex); + currentCodeBlockLangIndex = nextLangIndex; + } else { + currentCodeBlockLangIndex = 0; + } } else if (mathFenceMatch) { inMathBlock = true; mathBlockContent = []; @@ -372,6 +385,7 @@ const MarkdownDisplayInternal: React.FC = ({ content={codeBlockContent} lang={codeBlockLang} codeBlockIndex={currentCodeBlockIndex} + codeBlockLangIndex={currentCodeBlockLangIndex} isPending={isPending} availableTerminalHeight={availableTerminalHeight} contentWidth={contentWidth} @@ -411,6 +425,7 @@ interface RenderCodeBlockProps { content: string[]; lang: string | null; codeBlockIndex: number; + codeBlockLangIndex: number; isPending: boolean; availableTerminalHeight?: number; contentWidth: number; @@ -420,6 +435,7 @@ const RenderCodeBlockInternal: React.FC = ({ content, lang, codeBlockIndex, + codeBlockLangIndex, isPending, availableTerminalHeight, contentWidth, @@ -443,7 +459,7 @@ const RenderCodeBlockInternal: React.FC = ({ return ( = ({ source, - sourceCodeIndex, + sourceCopyCommand, contentWidth, isPending, availableTerminalHeight, @@ -63,7 +63,7 @@ const MermaidDiagramInternal: React.FC = ({ }, [kittySequence, write]); const titleWithSourceHint = (title: string) => - `${title} · source: /copy code ${sourceCodeIndex}`; + `${title} · source: ${sourceCopyCommand}`; if ( image?.kind === 'terminal-image' && From d0348c56fa58e6a8a8e7eb11a0a5067a5b29bc32 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Tue, 28 Apr 2026 20:43:11 +0800 Subject: [PATCH 11/36] fix(cli): make markdown render toggle visible --- packages/cli/src/config/keyBindings.ts | 2 +- packages/cli/src/ui/AppContainer.tsx | 27 +++++- .../src/ui/components/BaseTextInput.test.tsx | 96 +++++++++++++++++++ .../cli/src/ui/components/BaseTextInput.tsx | 4 + .../cli/src/ui/components/MainContent.tsx | 4 +- packages/cli/src/ui/keyMatchers.test.ts | 9 +- 6 files changed, 135 insertions(+), 7 deletions(-) create mode 100644 packages/cli/src/ui/components/BaseTextInput.test.tsx diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index dd0a4ba0d1c..c78f42b75a1 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -177,7 +177,7 @@ export const defaultKeyBindings: KeyBindingConfig = { [Command.TOGGLE_COMPACT_MODE]: [{ key: 'o', ctrl: true }], [Command.TOGGLE_MARKDOWN_RENDER_MODE]: [ { key: 'm', meta: true }, - { sequence: 'µ' }, + { sequence: 'µ', ctrl: false, meta: false, paste: false }, ], // Shell commands diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 6acd0cdc048..db7808862a4 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1538,6 +1538,15 @@ export const AppContainer = (props: AppContainerProps) => { ); const [mermaidRenderMode, setMermaidRenderMode] = useState('visual'); + const mermaidRenderModeMountedRef = useRef(false); + useEffect(() => { + if (!mermaidRenderModeMountedRef.current) { + mermaidRenderModeMountedRef.current = true; + return; + } + + refreshStatic(); + }, [mermaidRenderMode, refreshStatic]); const [ctrlCPressedOnce, setCtrlCPressedOnce] = useState(false); const ctrlCTimerRef = useRef(null); const [ctrlDPressedOnce, setCtrlDPressedOnce] = useState(false); @@ -2158,10 +2167,20 @@ export const AppContainer = (props: AppContainerProps) => { void settings.setValue(SettingScope.User, 'ui.compactMode', newValue); refreshStatic(); } else if (keyMatchers[Command.TOGGLE_MARKDOWN_RENDER_MODE](key)) { - setMermaidRenderMode((current) => - current === 'visual' ? 'source' : 'visual', + const nextMermaidRenderMode = + mermaidRenderMode === 'visual' ? 'source' : 'visual'; + setMermaidRenderMode(nextMermaidRenderMode); + historyManager.addItem( + { + type: MessageType.INFO, + text: `Markdown Mermaid rendering switched to ${ + nextMermaidRenderMode === 'source' + ? 'source code' + : 'visual preview' + }. Press Alt/Option+M to switch back.`, + }, + Date.now(), ); - refreshStatic(); } }, [ @@ -2197,8 +2216,10 @@ export const AppContainer = (props: AppContainerProps) => { isAuthenticating, compactMode, setCompactMode, + mermaidRenderMode, setMermaidRenderMode, refreshStatic, + historyManager, handleDoubleEscRewind, ], ); diff --git a/packages/cli/src/ui/components/BaseTextInput.test.tsx b/packages/cli/src/ui/components/BaseTextInput.test.tsx new file mode 100644 index 00000000000..6efb9d3dc43 --- /dev/null +++ b/packages/cli/src/ui/components/BaseTextInput.test.tsx @@ -0,0 +1,96 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render } from 'ink-testing-library'; +import { BaseTextInput } from './BaseTextInput.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import type { Key } from '../hooks/useKeypress.js'; +import type { TextBuffer } from './shared/text-buffer.js'; + +vi.mock('../hooks/useKeypress.js', () => ({ + useKeypress: vi.fn(), +})); + +const mockedUseKeypress = vi.mocked(useKeypress); + +function makeKey(overrides: Partial): Key { + return { + name: '', + ctrl: false, + meta: false, + shift: false, + paste: false, + sequence: '', + ...overrides, + }; +} + +function createBuffer() { + return { + text: '', + viewportVisualLines: [''], + visualCursor: [0, 0], + visualScrollRow: 0, + setText: vi.fn(), + newline: vi.fn(), + move: vi.fn(), + killLineRight: vi.fn(), + killLineLeft: vi.fn(), + deleteWordLeft: vi.fn(), + openInExternalEditor: vi.fn(), + backspace: vi.fn(), + handleInput: vi.fn(), + } as unknown as TextBuffer; +} + +function captureKeypressHandler(): (key: Key) => void { + const calls = mockedUseKeypress.mock.calls; + if (calls.length === 0) { + throw new Error('useKeypress was not called'); + } + return calls[calls.length - 1]![0] as (key: Key) => void; +} + +describe('BaseTextInput', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('does not type the Markdown render-mode shortcut into the buffer', () => { + const buffer = createBuffer(); + + render(); + + const handler = captureKeypressHandler(); + handler(makeKey({ name: 'm', meta: true, sequence: 'µ' })); + + expect(buffer.handleInput).not.toHaveBeenCalled(); + }); + + it('does not type the raw macOS Option+M sequence into the buffer', () => { + const buffer = createBuffer(); + + render(); + + const handler = captureKeypressHandler(); + handler(makeKey({ name: '', sequence: 'µ' })); + + expect(buffer.handleInput).not.toHaveBeenCalled(); + }); + + it('still passes pasted µ text through to the buffer', () => { + const buffer = createBuffer(); + + render(); + + const handler = captureKeypressHandler(); + const pastedKey = makeKey({ sequence: 'µ', paste: true }); + handler(pastedKey); + + expect(buffer.handleInput).toHaveBeenCalledWith(pastedKey); + }); +}); diff --git a/packages/cli/src/ui/components/BaseTextInput.tsx b/packages/cli/src/ui/components/BaseTextInput.tsx index a0e44591ac1..31302c1b5f5 100644 --- a/packages/cli/src/ui/components/BaseTextInput.tsx +++ b/packages/cli/src/ui/components/BaseTextInput.tsx @@ -145,6 +145,10 @@ export const BaseTextInput: React.FC = ({ return; } + if (keyMatchers[Command.TOGGLE_MARKDOWN_RENDER_MODE](key)) { + return; + } + // ── Standard readline shortcuts ── // Submit (Enter, no modifiers) diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index 172419ee9c9..3e90292f50b 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -17,6 +17,7 @@ import { useAppContext } from '../contexts/AppContext.js'; import { AppHeader } from './AppHeader.js'; import { DebugModeNotification } from './DebugModeNotification.js'; import { useCompactMode } from '../contexts/CompactModeContext.js'; +import { useMarkdownRendering } from '../contexts/MarkdownRenderingContext.js'; import { isForceExpandGroup, mergeCompactToolGroups, @@ -33,6 +34,7 @@ export const MainContent = () => { const uiState = useUIState(); const uiActions = useUIActions(); const { compactMode } = useCompactMode(); + const { mermaidRenderMode } = useMarkdownRendering(); const { pendingHistoryItems, terminalWidth, @@ -179,7 +181,7 @@ export const MainContent = () => { return ( <> , , diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts index 5d6ac6c4c01..bfb65f2dff9 100644 --- a/packages/cli/src/ui/keyMatchers.test.ts +++ b/packages/cli/src/ui/keyMatchers.test.ts @@ -62,7 +62,8 @@ describe('keyMatchers', () => { [Command.RETRY_LAST]: (key: Key) => key.ctrl && key.name === 'y', [Command.TOGGLE_COMPACT_MODE]: (key: Key) => key.ctrl && key.name === 'o', [Command.TOGGLE_MARKDOWN_RENDER_MODE]: (key: Key) => - (key.meta && key.name === 'm') || key.sequence === 'µ', + (key.meta && key.name === 'm') || + (key.sequence === 'µ' && !key.ctrl && !key.meta && !key.paste), [Command.REVERSE_SEARCH]: (key: Key) => key.ctrl && key.name === 'r', [Command.SUBMIT_REVERSE_SEARCH]: (key: Key) => key.name === 'return' && !key.ctrl, @@ -294,7 +295,11 @@ describe('keyMatchers', () => { createKey('m', { meta: true }), createKey('', { sequence: 'µ' }), ], - negative: [createKey('m'), createKey('m', { ctrl: true })], + negative: [ + createKey('m'), + createKey('m', { ctrl: true }), + createKey('', { sequence: 'µ', paste: true }), + ], }, ]; From 398d9a5a1ea80d1cde2ff38d8e85d28d4fdfca8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Tue, 28 Apr 2026 20:49:51 +0800 Subject: [PATCH 12/36] fix(cli): keep markdown render toggle quiet --- packages/cli/src/ui/AppContainer.tsx | 16 ++-------------- 1 file changed, 2 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index db7808862a4..6ea585ba9b0 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -2167,19 +2167,8 @@ export const AppContainer = (props: AppContainerProps) => { void settings.setValue(SettingScope.User, 'ui.compactMode', newValue); refreshStatic(); } else if (keyMatchers[Command.TOGGLE_MARKDOWN_RENDER_MODE](key)) { - const nextMermaidRenderMode = - mermaidRenderMode === 'visual' ? 'source' : 'visual'; - setMermaidRenderMode(nextMermaidRenderMode); - historyManager.addItem( - { - type: MessageType.INFO, - text: `Markdown Mermaid rendering switched to ${ - nextMermaidRenderMode === 'source' - ? 'source code' - : 'visual preview' - }. Press Alt/Option+M to switch back.`, - }, - Date.now(), + setMermaidRenderMode( + mermaidRenderMode === 'visual' ? 'source' : 'visual', ); } }, @@ -2219,7 +2208,6 @@ export const AppContainer = (props: AppContainerProps) => { mermaidRenderMode, setMermaidRenderMode, refreshStatic, - historyManager, handleDoubleEscRewind, ], ); From 96cad6b099c1c47c686338a2eaaec9446c4fe2de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Tue, 28 Apr 2026 20:56:22 +0800 Subject: [PATCH 13/36] fix(cli): generalize markdown render mode --- packages/cli/src/ui/AppContainer.tsx | 26 ++++++------ .../cli/src/ui/components/MainContent.tsx | 4 +- .../ui/contexts/MarkdownRenderingContext.tsx | 12 +++--- .../cli/src/ui/utils/MarkdownDisplay.test.tsx | 36 +++++++++++++++- packages/cli/src/ui/utils/MarkdownDisplay.tsx | 41 ++++++++++++------- 5 files changed, 83 insertions(+), 36 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 6ea585ba9b0..603da340620 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -128,7 +128,7 @@ import { useCodingPlanUpdates } from './hooks/useCodingPlanUpdates.js'; import { ShellFocusContext } from './contexts/ShellFocusContext.js'; import { MarkdownRenderingProvider, - type MermaidRenderMode, + type MarkdownRenderMode, } from './contexts/MarkdownRenderingContext.js'; import { useAgentViewState } from './contexts/AgentViewContext.js'; import { @@ -1536,17 +1536,17 @@ export const AppContainer = (props: AppContainerProps) => { const [compactMode, setCompactMode] = useState( settings.merged.ui?.compactMode ?? false, ); - const [mermaidRenderMode, setMermaidRenderMode] = - useState('visual'); - const mermaidRenderModeMountedRef = useRef(false); + const [markdownRenderMode, setMarkdownRenderMode] = + useState('visual'); + const markdownRenderModeMountedRef = useRef(false); useEffect(() => { - if (!mermaidRenderModeMountedRef.current) { - mermaidRenderModeMountedRef.current = true; + if (!markdownRenderModeMountedRef.current) { + markdownRenderModeMountedRef.current = true; return; } refreshStatic(); - }, [mermaidRenderMode, refreshStatic]); + }, [markdownRenderMode, refreshStatic]); const [ctrlCPressedOnce, setCtrlCPressedOnce] = useState(false); const ctrlCTimerRef = useRef(null); const [ctrlDPressedOnce, setCtrlDPressedOnce] = useState(false); @@ -2167,8 +2167,8 @@ export const AppContainer = (props: AppContainerProps) => { void settings.setValue(SettingScope.User, 'ui.compactMode', newValue); refreshStatic(); } else if (keyMatchers[Command.TOGGLE_MARKDOWN_RENDER_MODE](key)) { - setMermaidRenderMode( - mermaidRenderMode === 'visual' ? 'source' : 'visual', + setMarkdownRenderMode( + markdownRenderMode === 'visual' ? 'source' : 'visual', ); } }, @@ -2205,8 +2205,8 @@ export const AppContainer = (props: AppContainerProps) => { isAuthenticating, compactMode, setCompactMode, - mermaidRenderMode, - setMermaidRenderMode, + markdownRenderMode, + setMarkdownRenderMode, refreshStatic, handleDoubleEscRewind, ], @@ -2687,8 +2687,8 @@ export const AppContainer = (props: AppContainerProps) => { [compactMode, setCompactMode], ); const markdownRenderingValue = useMemo( - () => ({ mermaidRenderMode, setMermaidRenderMode }), - [mermaidRenderMode, setMermaidRenderMode], + () => ({ markdownRenderMode, setMarkdownRenderMode }), + [markdownRenderMode, setMarkdownRenderMode], ); return ( diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index 3e90292f50b..0bb1a2c40fa 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -34,7 +34,7 @@ export const MainContent = () => { const uiState = useUIState(); const uiActions = useUIActions(); const { compactMode } = useCompactMode(); - const { mermaidRenderMode } = useMarkdownRendering(); + const { markdownRenderMode } = useMarkdownRendering(); const { pendingHistoryItems, terminalWidth, @@ -181,7 +181,7 @@ export const MainContent = () => { return ( <> , , diff --git a/packages/cli/src/ui/contexts/MarkdownRenderingContext.tsx b/packages/cli/src/ui/contexts/MarkdownRenderingContext.tsx index c3303f4c3d7..b1fb2c83d86 100644 --- a/packages/cli/src/ui/contexts/MarkdownRenderingContext.tsx +++ b/packages/cli/src/ui/contexts/MarkdownRenderingContext.tsx @@ -6,17 +6,19 @@ import React from 'react'; -export type MermaidRenderMode = 'visual' | 'source'; +export type MarkdownRenderMode = 'visual' | 'source'; interface MarkdownRenderingContextValue { - mermaidRenderMode: MermaidRenderMode; - setMermaidRenderMode: React.Dispatch>; + markdownRenderMode: MarkdownRenderMode; + setMarkdownRenderMode: React.Dispatch< + React.SetStateAction + >; } const MarkdownRenderingContext = React.createContext({ - mermaidRenderMode: 'visual', - setMermaidRenderMode: () => undefined, + markdownRenderMode: 'visual', + setMarkdownRenderMode: () => undefined, }); export const MarkdownRenderingProvider = MarkdownRenderingContext.Provider; diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx index 79911dd717b..3991318b199 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx @@ -477,8 +477,8 @@ flowchart LR const { lastFrame } = renderWithProviders( undefined, + markdownRenderMode: 'source', + setMarkdownRenderMode: () => undefined, }} > @@ -490,6 +490,38 @@ flowchart LR expect(output).not.toContain('Mermaid flowchart'); }); + it('keeps enhanced markdown blocks as markdown source in source mode', () => { + const text = ` +| Name | Value | +|------|-------| +| Alpha | $x^2$ | + +$$ +\\alpha + \\beta +$$ +`.replace(/\n/g, eol); + const { lastFrame } = renderWithProviders( + undefined, + }} + > + + , + ); + + const output = lastFrame(); + expect(output).toContain('| Name | Value |'); + expect(output).toContain('|------|-------|'); + expect(output).toContain('$x^2$'); + expect(output).toContain('$$'); + expect(output).toContain('\\alpha + \\beta'); + expect(output).not.toContain('┌'); + expect(output).not.toContain('x²'); + expect(output).not.toContain('α + β'); + }); + it('reuses mermaid node labels when later edges reference node ids', () => { const text = ` \`\`\`mermaid diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.tsx index f87f66f4265..d212dbf3aad 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.tsx @@ -39,8 +39,10 @@ const MarkdownDisplayInternal: React.FC = ({ contentWidth, textColor = theme.text.primary, }) => { + const { markdownRenderMode } = useMarkdownRendering(); if (!text) return <>; + const renderVisualBlocks = markdownRenderMode === 'visual'; const lines = text.split(/\r?\n/); const headerRegex = /^ *(#{1,4}) +(.*)/; const codeFenceRegex = /^ *(`{3,}|~{3,}) *([^`]*)$/; @@ -167,10 +169,10 @@ const MarkdownDisplayInternal: React.FC = ({ } else { currentCodeBlockLangIndex = 0; } - } else if (mathFenceMatch) { + } else if (mathFenceMatch && renderVisualBlocks) { inMathBlock = true; mathBlockContent = []; - } else if (tableRowMatch && !inTable) { + } else if (tableRowMatch && !inTable && renderVisualBlocks) { // Potential table start - check if next line is separator with matching column count const potentialHeaders = tableRowMatch[1] .split(/(? = ({ , @@ -244,7 +246,7 @@ const MarkdownDisplayInternal: React.FC = ({ , @@ -262,6 +264,7 @@ const MarkdownDisplayInternal: React.FC = ({ key={key} quoteText={blockquoteMatch[1]} textColor={textColor} + enableInlineMath={renderVisualBlocks} />, ); } else if (headerMatch) { @@ -275,7 +278,7 @@ const MarkdownDisplayInternal: React.FC = ({ ); @@ -286,7 +289,7 @@ const MarkdownDisplayInternal: React.FC = ({ ); @@ -297,7 +300,7 @@ const MarkdownDisplayInternal: React.FC = ({ ); @@ -308,7 +311,7 @@ const MarkdownDisplayInternal: React.FC = ({ ); @@ -319,7 +322,7 @@ const MarkdownDisplayInternal: React.FC = ({ ); @@ -338,6 +341,7 @@ const MarkdownDisplayInternal: React.FC = ({ marker={marker} leadingWhitespace={leadingWhitespace} textColor={textColor} + enableInlineMath={renderVisualBlocks} />, ); } else if (olMatch) { @@ -352,6 +356,7 @@ const MarkdownDisplayInternal: React.FC = ({ marker={marker} leadingWhitespace={leadingWhitespace} textColor={textColor} + enableInlineMath={renderVisualBlocks} />, ); } else { @@ -369,7 +374,7 @@ const MarkdownDisplayInternal: React.FC = ({ , @@ -441,11 +446,11 @@ const RenderCodeBlockInternal: React.FC = ({ contentWidth, }) => { const settings = useSettings(); - const { mermaidRenderMode } = useMarkdownRendering(); + const { markdownRenderMode } = useMarkdownRendering(); const MIN_LINES_FOR_MESSAGE = 1; // Minimum lines to show before the "generating more" message const RESERVED_LINES = 2; // Lines reserved for the message itself and potential padding - if (lang?.toLowerCase() === 'mermaid' && mermaidRenderMode === 'visual') { + if (lang?.toLowerCase() === 'mermaid' && markdownRenderMode === 'visual') { if (isPending) { return ( = ({ quoteText, textColor = theme.text.primary, + enableInlineMath = true, }) => ( │ - + @@ -616,6 +627,7 @@ interface RenderListItemProps { marker: string; leadingWhitespace?: string; textColor?: string; + enableInlineMath?: boolean; } const RenderListItemInternal: React.FC = ({ @@ -624,6 +636,7 @@ const RenderListItemInternal: React.FC = ({ marker, leadingWhitespace = '', textColor = theme.text.primary, + enableInlineMath = true, }) => { const taskMatch = itemText.match(/^\[([ xX])\]\s+(.*)$/); const isTaskItem = taskMatch !== null; @@ -650,7 +663,7 @@ const RenderListItemInternal: React.FC = ({ From f47fc266fa1a5ed98744eeca6ae16ca10c6957fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Tue, 28 Apr 2026 21:04:20 +0800 Subject: [PATCH 14/36] fix(cli): broaden render mode and copy latex blocks --- packages/cli/src/config/keyBindings.ts | 4 +- packages/cli/src/ui/AppContainer.tsx | 37 ++++---- .../cli/src/ui/commands/copyCommand.test.ts | 84 ++++++++++++++++++ packages/cli/src/ui/commands/copyCommand.ts | 88 +++++++++++++++++++ .../src/ui/components/BaseTextInput.test.tsx | 2 +- .../cli/src/ui/components/BaseTextInput.tsx | 2 +- .../cli/src/ui/components/MainContent.tsx | 6 +- .../ui/contexts/MarkdownRenderingContext.tsx | 28 ------ .../cli/src/ui/contexts/RenderModeContext.tsx | 25 ++++++ packages/cli/src/ui/keyMatchers.test.ts | 4 +- .../cli/src/ui/utils/MarkdownDisplay.test.tsx | 23 ++--- packages/cli/src/ui/utils/MarkdownDisplay.tsx | 22 +++-- 12 files changed, 252 insertions(+), 73 deletions(-) delete mode 100644 packages/cli/src/ui/contexts/MarkdownRenderingContext.tsx create mode 100644 packages/cli/src/ui/contexts/RenderModeContext.tsx diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index c78f42b75a1..96e61fbdb77 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -52,7 +52,7 @@ export enum Command { SHOW_MORE_LINES = 'showMoreLines', RETRY_LAST = 'retryLast', TOGGLE_COMPACT_MODE = 'toggleCompactMode', - TOGGLE_MARKDOWN_RENDER_MODE = 'toggleMarkdownRenderMode', + TOGGLE_RENDER_MODE = 'toggleRenderMode', // Shell commands REVERSE_SEARCH = 'reverseSearch', @@ -175,7 +175,7 @@ export const defaultKeyBindings: KeyBindingConfig = { [Command.SHOW_MORE_LINES]: [{ key: 's', ctrl: true }], [Command.RETRY_LAST]: [{ key: 'y', ctrl: true }], [Command.TOGGLE_COMPACT_MODE]: [{ key: 'o', ctrl: true }], - [Command.TOGGLE_MARKDOWN_RENDER_MODE]: [ + [Command.TOGGLE_RENDER_MODE]: [ { key: 'm', meta: true }, { sequence: 'µ', ctrl: false, meta: false, paste: false }, ], diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 603da340620..916c85816ef 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -127,9 +127,9 @@ import { import { useCodingPlanUpdates } from './hooks/useCodingPlanUpdates.js'; import { ShellFocusContext } from './contexts/ShellFocusContext.js'; import { - MarkdownRenderingProvider, - type MarkdownRenderMode, -} from './contexts/MarkdownRenderingContext.js'; + RenderModeProvider, + type RenderMode, +} from './contexts/RenderModeContext.js'; import { useAgentViewState } from './contexts/AgentViewContext.js'; import { useBackgroundTaskViewState, @@ -1536,17 +1536,16 @@ export const AppContainer = (props: AppContainerProps) => { const [compactMode, setCompactMode] = useState( settings.merged.ui?.compactMode ?? false, ); - const [markdownRenderMode, setMarkdownRenderMode] = - useState('visual'); - const markdownRenderModeMountedRef = useRef(false); + const [renderMode, setRenderMode] = useState('render'); + const renderModeMountedRef = useRef(false); useEffect(() => { - if (!markdownRenderModeMountedRef.current) { - markdownRenderModeMountedRef.current = true; + if (!renderModeMountedRef.current) { + renderModeMountedRef.current = true; return; } refreshStatic(); - }, [markdownRenderMode, refreshStatic]); + }, [renderMode, refreshStatic]); const [ctrlCPressedOnce, setCtrlCPressedOnce] = useState(false); const ctrlCTimerRef = useRef(null); const [ctrlDPressedOnce, setCtrlDPressedOnce] = useState(false); @@ -2166,10 +2165,8 @@ export const AppContainer = (props: AppContainerProps) => { setCompactMode(newValue); void settings.setValue(SettingScope.User, 'ui.compactMode', newValue); refreshStatic(); - } else if (keyMatchers[Command.TOGGLE_MARKDOWN_RENDER_MODE](key)) { - setMarkdownRenderMode( - markdownRenderMode === 'visual' ? 'source' : 'visual', - ); + } else if (keyMatchers[Command.TOGGLE_RENDER_MODE](key)) { + setRenderMode(renderMode === 'render' ? 'raw' : 'render'); } }, [ @@ -2205,8 +2202,8 @@ export const AppContainer = (props: AppContainerProps) => { isAuthenticating, compactMode, setCompactMode, - markdownRenderMode, - setMarkdownRenderMode, + renderMode, + setRenderMode, refreshStatic, handleDoubleEscRewind, ], @@ -2686,9 +2683,9 @@ export const AppContainer = (props: AppContainerProps) => { () => ({ compactMode, setCompactMode }), [compactMode, setCompactMode], ); - const markdownRenderingValue = useMemo( - () => ({ markdownRenderMode, setMarkdownRenderMode }), - [markdownRenderMode, setMarkdownRenderMode], + const renderModeValue = useMemo( + () => ({ renderMode, setRenderMode }), + [renderMode, setRenderMode], ); return ( @@ -2702,11 +2699,11 @@ export const AppContainer = (props: AppContainerProps) => { }} > - + - + diff --git a/packages/cli/src/ui/commands/copyCommand.test.ts b/packages/cli/src/ui/commands/copyCommand.test.ts index b168ce64453..7d9f745b00e 100644 --- a/packages/cli/src/ui/commands/copyCommand.test.ts +++ b/packages/cli/src/ui/commands/copyCommand.test.ts @@ -384,6 +384,90 @@ describe('copyCommand', () => { }); }); + it('should copy the last LaTeX block with /copy latex', async () => { + if (!copyCommand.action) throw new Error('Command has no action'); + + mockGetHistory.mockReturnValue([ + { + role: 'model', + parts: [ + { + text: [ + '$$', + '\\alpha + \\beta', + '$$', + '$$', + '\\sum_{i=1}^{n} x_i', + '$$', + ].join('\n'), + }, + ], + }, + ]); + mockCopyToClipboard.mockResolvedValue(undefined); + + const result = await copyCommand.action(mockContext, 'latex'); + + expect(mockCopyToClipboard).toHaveBeenCalledWith('\\sum_{i=1}^{n} x_i'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'LaTeX block 2 copied to the clipboard', + }); + }); + + it('should copy a numbered LaTeX block with /copy latex 1', async () => { + if (!copyCommand.action) throw new Error('Command has no action'); + + mockGetHistory.mockReturnValue([ + { + role: 'model', + parts: [ + { + text: [ + '$$', + '\\alpha + \\beta', + '$$', + '$$', + '\\gamma + \\delta', + '$$', + ].join('\n'), + }, + ], + }, + ]); + mockCopyToClipboard.mockResolvedValue(undefined); + + const result = await copyCommand.action(mockContext, 'latex 1'); + + expect(mockCopyToClipboard).toHaveBeenCalledWith('\\alpha + \\beta'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'LaTeX block 1 copied to the clipboard', + }); + }); + + it('should report when /copy latex has no matching LaTeX block', async () => { + if (!copyCommand.action) throw new Error('Command has no action'); + + mockGetHistory.mockReturnValue([ + { + role: 'model', + parts: [{ text: 'No math block here.' }], + }, + ]); + + const result = await copyCommand.action(mockContext, 'latex'); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'No matching LaTeX block found in the last AI output.', + }); + expect(mockCopyToClipboard).not.toHaveBeenCalled(); + }); + it('should report when /copy code has no matching code block', async () => { if (!copyCommand.action) throw new Error('Command has no action'); diff --git a/packages/cli/src/ui/commands/copyCommand.ts b/packages/cli/src/ui/commands/copyCommand.ts index 45b2d0b17f2..41e7fa1a891 100644 --- a/packages/cli/src/ui/commands/copyCommand.ts +++ b/packages/cli/src/ui/commands/copyCommand.ts @@ -21,6 +21,16 @@ interface SelectedCodeBlock { label: string; } +interface LatexBlock { + content: string; + index: number; +} + +interface SelectedLatexBlock { + block: LatexBlock; + label: string; +} + function parseFencedCodeBlocks(markdown: string): FencedCodeBlock[] { const blocks: FencedCodeBlock[] = []; const lines = markdown.split(/\r?\n/); @@ -73,6 +83,66 @@ function parseFencedCodeBlocks(markdown: string): FencedCodeBlock[] { return blocks; } +function parseLatexBlocks(markdown: string): LatexBlock[] { + const blocks: LatexBlock[] = []; + const lines = markdown.split(/\r?\n/); + const mathFenceRegex = /^ *\$\$ *$/; + let inLatexBlock = false; + let activeLines: string[] = []; + + for (const line of lines) { + if (!inLatexBlock) { + if (mathFenceRegex.test(line)) { + inLatexBlock = true; + activeLines = []; + } + continue; + } + + if (mathFenceRegex.test(line)) { + blocks.push({ + content: activeLines.join('\n'), + index: blocks.length + 1, + }); + inLatexBlock = false; + activeLines = []; + continue; + } + + activeLines.push(line); + } + + return blocks; +} + +function selectLatexBlock( + markdown: string, + args: string, +): SelectedLatexBlock | null | undefined { + const tokens = args.trim().split(/\s+/).filter(Boolean); + const firstToken = tokens[0]?.toLowerCase(); + if (firstToken !== 'latex' && firstToken !== 'math') return undefined; + + const blocks = parseLatexBlocks(markdown); + if (blocks.length === 0) return null; + + let requestedIndex: number | null = null; + for (const token of tokens.slice(1)) { + if (/^\d+$/.test(token)) { + requestedIndex = Number(token); + } + } + + const selected = + requestedIndex !== null + ? blocks.find((block) => block.index === requestedIndex) + : blocks[blocks.length - 1]; + + return selected + ? { block: selected, label: `LaTeX block ${selected.index}` } + : null; +} + function selectCodeBlock( markdown: string, args: string, @@ -161,6 +231,24 @@ export const copyCommand: SlashCommand = { if (lastAiOutput) { try { + const selectedLatexBlock = selectLatexBlock(lastAiOutput, _args); + if (selectedLatexBlock === null) { + return { + type: 'message', + messageType: 'info', + content: 'No matching LaTeX block found in the last AI output.', + }; + } + if (selectedLatexBlock !== undefined) { + await copyToClipboard(selectedLatexBlock.block.content); + + return { + type: 'message', + messageType: 'info', + content: `${selectedLatexBlock.label} copied to the clipboard`, + }; + } + const selectedCodeBlock = selectCodeBlock(lastAiOutput, _args); if (selectedCodeBlock === null) { return { diff --git a/packages/cli/src/ui/components/BaseTextInput.test.tsx b/packages/cli/src/ui/components/BaseTextInput.test.tsx index 6efb9d3dc43..5e8cfdc64e0 100644 --- a/packages/cli/src/ui/components/BaseTextInput.test.tsx +++ b/packages/cli/src/ui/components/BaseTextInput.test.tsx @@ -60,7 +60,7 @@ describe('BaseTextInput', () => { vi.clearAllMocks(); }); - it('does not type the Markdown render-mode shortcut into the buffer', () => { + it('does not type the render-mode shortcut into the buffer', () => { const buffer = createBuffer(); render(); diff --git a/packages/cli/src/ui/components/BaseTextInput.tsx b/packages/cli/src/ui/components/BaseTextInput.tsx index 31302c1b5f5..df15b564a86 100644 --- a/packages/cli/src/ui/components/BaseTextInput.tsx +++ b/packages/cli/src/ui/components/BaseTextInput.tsx @@ -145,7 +145,7 @@ export const BaseTextInput: React.FC = ({ return; } - if (keyMatchers[Command.TOGGLE_MARKDOWN_RENDER_MODE](key)) { + if (keyMatchers[Command.TOGGLE_RENDER_MODE](key)) { return; } diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index 0bb1a2c40fa..5eea431824e 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -17,7 +17,7 @@ import { useAppContext } from '../contexts/AppContext.js'; import { AppHeader } from './AppHeader.js'; import { DebugModeNotification } from './DebugModeNotification.js'; import { useCompactMode } from '../contexts/CompactModeContext.js'; -import { useMarkdownRendering } from '../contexts/MarkdownRenderingContext.js'; +import { useRenderMode } from '../contexts/RenderModeContext.js'; import { isForceExpandGroup, mergeCompactToolGroups, @@ -34,7 +34,7 @@ export const MainContent = () => { const uiState = useUIState(); const uiActions = useUIActions(); const { compactMode } = useCompactMode(); - const { markdownRenderMode } = useMarkdownRendering(); + const { renderMode } = useRenderMode(); const { pendingHistoryItems, terminalWidth, @@ -181,7 +181,7 @@ export const MainContent = () => { return ( <> , , diff --git a/packages/cli/src/ui/contexts/MarkdownRenderingContext.tsx b/packages/cli/src/ui/contexts/MarkdownRenderingContext.tsx deleted file mode 100644 index b1fb2c83d86..00000000000 --- a/packages/cli/src/ui/contexts/MarkdownRenderingContext.tsx +++ /dev/null @@ -1,28 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import React from 'react'; - -export type MarkdownRenderMode = 'visual' | 'source'; - -interface MarkdownRenderingContextValue { - markdownRenderMode: MarkdownRenderMode; - setMarkdownRenderMode: React.Dispatch< - React.SetStateAction - >; -} - -const MarkdownRenderingContext = - React.createContext({ - markdownRenderMode: 'visual', - setMarkdownRenderMode: () => undefined, - }); - -export const MarkdownRenderingProvider = MarkdownRenderingContext.Provider; - -export function useMarkdownRendering(): MarkdownRenderingContextValue { - return React.useContext(MarkdownRenderingContext); -} diff --git a/packages/cli/src/ui/contexts/RenderModeContext.tsx b/packages/cli/src/ui/contexts/RenderModeContext.tsx new file mode 100644 index 00000000000..e3597ea0f2f --- /dev/null +++ b/packages/cli/src/ui/contexts/RenderModeContext.tsx @@ -0,0 +1,25 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; + +export type RenderMode = 'render' | 'raw'; + +interface RenderModeContextValue { + renderMode: RenderMode; + setRenderMode: React.Dispatch>; +} + +const RenderModeContext = React.createContext({ + renderMode: 'render', + setRenderMode: () => undefined, +}); + +export const RenderModeProvider = RenderModeContext.Provider; + +export function useRenderMode(): RenderModeContextValue { + return React.useContext(RenderModeContext); +} diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts index bfb65f2dff9..0a2f52a7767 100644 --- a/packages/cli/src/ui/keyMatchers.test.ts +++ b/packages/cli/src/ui/keyMatchers.test.ts @@ -61,7 +61,7 @@ describe('keyMatchers', () => { [Command.SHOW_MORE_LINES]: (key: Key) => key.ctrl && key.name === 's', [Command.RETRY_LAST]: (key: Key) => key.ctrl && key.name === 'y', [Command.TOGGLE_COMPACT_MODE]: (key: Key) => key.ctrl && key.name === 'o', - [Command.TOGGLE_MARKDOWN_RENDER_MODE]: (key: Key) => + [Command.TOGGLE_RENDER_MODE]: (key: Key) => (key.meta && key.name === 'm') || (key.sequence === 'µ' && !key.ctrl && !key.meta && !key.paste), [Command.REVERSE_SEARCH]: (key: Key) => key.ctrl && key.name === 'r', @@ -290,7 +290,7 @@ describe('keyMatchers', () => { negative: [createKey('f')], }, { - command: Command.TOGGLE_MARKDOWN_RENDER_MODE, + command: Command.TOGGLE_RENDER_MODE, positive: [ createKey('m', { meta: true }), createKey('', { sequence: 'µ' }), diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx index 3991318b199..faa42656b3b 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx @@ -9,7 +9,7 @@ import { MarkdownDisplay } from './MarkdownDisplay.js'; import { LoadedSettings } from '../../config/settings.js'; import { renderWithProviders } from '../../test-utils/render.js'; import { renderMermaidVisual } from './mermaidVisualRenderer.js'; -import { MarkdownRenderingProvider } from '../contexts/MarkdownRenderingContext.js'; +import { RenderModeProvider } from '../contexts/RenderModeContext.js'; describe('', () => { const baseProps = { @@ -399,6 +399,7 @@ $$ ); const output = lastFrame(); expect(output).toContain('x² + α'); + expect(output).toContain('LaTeX block · source: /copy latex 1'); expect(output).toContain('Σᵢ₌₁ⁿ xᵢ'); }); @@ -467,7 +468,7 @@ flowchart LR expect(output).not.toContain('source: /copy code 2'); }); - it('can render mermaid fences as source when source mode is active', () => { + it('can render mermaid fences as source when raw mode is active', () => { const text = ` \`\`\`mermaid flowchart LR @@ -475,14 +476,14 @@ flowchart LR \`\`\` `.replace(/\n/g, eol); const { lastFrame } = renderWithProviders( - undefined, + renderMode: 'raw', + setRenderMode: () => undefined, }} > - , + , ); const output = lastFrame(); expect(output).toContain('flowchart LR'); @@ -490,7 +491,7 @@ flowchart LR expect(output).not.toContain('Mermaid flowchart'); }); - it('keeps enhanced markdown blocks as markdown source in source mode', () => { + it('keeps enhanced markdown blocks as markdown source in raw mode', () => { const text = ` | Name | Value | |------|-------| @@ -501,14 +502,14 @@ $$ $$ `.replace(/\n/g, eol); const { lastFrame } = renderWithProviders( - undefined, + renderMode: 'raw', + setRenderMode: () => undefined, }} > - , + , ); const output = lastFrame(); diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.tsx index d212dbf3aad..3936ffcea1b 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.tsx @@ -13,7 +13,7 @@ import { RenderInline } from './InlineMarkdownRenderer.js'; import { useSettings } from '../contexts/SettingsContext.js'; import { MermaidDiagram } from './MermaidDiagram.js'; import { renderInlineLatex } from './latexRenderer.js'; -import { useMarkdownRendering } from '../contexts/MarkdownRenderingContext.js'; +import { useRenderMode } from '../contexts/RenderModeContext.js'; interface MarkdownDisplayProps { text: string; @@ -39,10 +39,10 @@ const MarkdownDisplayInternal: React.FC = ({ contentWidth, textColor = theme.text.primary, }) => { - const { markdownRenderMode } = useMarkdownRendering(); + const { renderMode } = useRenderMode(); if (!text) return <>; - const renderVisualBlocks = markdownRenderMode === 'visual'; + const renderVisualBlocks = renderMode === 'render'; const lines = text.split(/\r?\n/); const headerRegex = /^ *(#{1,4}) +(.*)/; const codeFenceRegex = /^ *(`{3,}|~{3,}) *([^`]*)$/; @@ -80,6 +80,8 @@ const MarkdownDisplayInternal: React.FC = ({ let codeBlockLang: string | null = null; let codeBlockFence = ''; let inMathBlock = false; + let mathBlockIndex = 0; + let currentMathBlockIndex = 0; let mathBlockContent: string[] = []; let inTable = false; let tableRows: string[][] = []; @@ -133,10 +135,12 @@ const MarkdownDisplayInternal: React.FC = ({ , ); inMathBlock = false; + currentMathBlockIndex = 0; mathBlockContent = []; } else { mathBlockContent.push(line); @@ -171,6 +175,8 @@ const MarkdownDisplayInternal: React.FC = ({ } } else if (mathFenceMatch && renderVisualBlocks) { inMathBlock = true; + mathBlockIndex += 1; + currentMathBlockIndex = mathBlockIndex; mathBlockContent = []; } else if (tableRowMatch && !inTable && renderVisualBlocks) { // Potential table start - check if next line is separator with matching column count @@ -403,6 +409,7 @@ const MarkdownDisplayInternal: React.FC = ({ , ); @@ -446,11 +453,11 @@ const RenderCodeBlockInternal: React.FC = ({ contentWidth, }) => { const settings = useSettings(); - const { markdownRenderMode } = useMarkdownRendering(); + const { renderMode } = useRenderMode(); const MIN_LINES_FOR_MESSAGE = 1; // Minimum lines to show before the "generating more" message const RESERVED_LINES = 2; // Lines reserved for the message itself and potential padding - if (lang?.toLowerCase() === 'mermaid' && markdownRenderMode === 'visual') { + if (lang?.toLowerCase() === 'mermaid' && renderMode === 'render') { if (isPending) { return ( = ({ content, + sourceCopyCommand, contentWidth, }) => { const rendered = renderInlineLatex(content.join(' ')); @@ -585,6 +594,9 @@ const RenderMathBlockInternal: React.FC = ({ width={contentWidth} flexShrink={0} > + + LaTeX block · source: {sourceCopyCommand} + {rendered} From e032f644e6a8f5316a857d1e3afec21b2ca0d34f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Tue, 28 Apr 2026 21:11:06 +0800 Subject: [PATCH 15/36] fix(cli): align rendered copy source indices --- .../src/ui/components/HistoryItemDisplay.tsx | 9 +- .../cli/src/ui/components/MainContent.tsx | 88 ++++++++++++++++--- .../messages/ConversationMessages.tsx | 24 ++++- .../cli/src/ui/utils/MarkdownDisplay.test.tsx | 27 ++++++ packages/cli/src/ui/utils/MarkdownDisplay.tsx | 71 ++++++++++++++- 5 files changed, 200 insertions(+), 19 deletions(-) diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 5344da6a1aa..bb745639a05 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -32,7 +32,10 @@ import { } from './messages/StatusMessages.js'; import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; -import { MarkdownDisplay } from '../utils/MarkdownDisplay.js'; +import { + MarkdownDisplay, + type MarkdownSourceCopyIndexOffsets, +} from '../utils/MarkdownDisplay.js'; import { AboutBox } from './AboutBox.js'; import { StatsDisplay } from './StatsDisplay.js'; import { ModelStatsDisplay } from './ModelStatsDisplay.js'; @@ -79,6 +82,7 @@ interface HistoryItemDisplayProps { * path to the screen) and for all tool_use_summary items in full mode. */ summaryAbsorbed?: boolean; + sourceCopyIndexOffsets?: MarkdownSourceCopyIndexOffsets; } const HistoryItemDisplayComponent: React.FC = ({ @@ -94,6 +98,7 @@ const HistoryItemDisplayComponent: React.FC = ({ availableTerminalHeightGemini, compactLabel, summaryAbsorbed = false, + sourceCopyIndexOffsets, }) => { const marginTop = item.type === 'gemini_content' || item.type === 'gemini_thought_content' @@ -131,6 +136,7 @@ const HistoryItemDisplayComponent: React.FC = ({ availableTerminalHeightGemini ?? availableTerminalHeight } contentWidth={contentWidth} + sourceCopyIndexOffsets={sourceCopyIndexOffsets} /> )} {itemForDisplay.type === 'gemini_content' && ( @@ -141,6 +147,7 @@ const HistoryItemDisplayComponent: React.FC = ({ availableTerminalHeightGemini ?? availableTerminalHeight } contentWidth={contentWidth} + sourceCopyIndexOffsets={sourceCopyIndexOffsets} /> )} {!compactMode && itemForDisplay.type === 'gemini_thought' && ( diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index 5eea431824e..3d97aa8e0e1 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -18,6 +18,10 @@ import { AppHeader } from './AppHeader.js'; import { DebugModeNotification } from './DebugModeNotification.js'; import { useCompactMode } from '../contexts/CompactModeContext.js'; import { useRenderMode } from '../contexts/RenderModeContext.js'; +import { + countMarkdownSourceBlocks, + type MarkdownSourceCopyIndexOffsets, +} from '../utils/MarkdownDisplay.js'; import { isForceExpandGroup, mergeCompactToolGroups, @@ -29,6 +33,34 @@ import { // usage. const MAX_GEMINI_MESSAGE_LINES = 65536; +function createEmptySourceCopyOffsets(): MarkdownSourceCopyIndexOffsets { + return { + codeBlockLanguageCounts: new Map(), + mathBlockCount: 0, + }; +} + +function cloneSourceCopyOffsets( + offsets: MarkdownSourceCopyIndexOffsets, +): MarkdownSourceCopyIndexOffsets { + return { + codeBlockLanguageCounts: new Map(offsets.codeBlockLanguageCounts), + mathBlockCount: offsets.mathBlockCount, + }; +} + +function addSourceBlockCounts( + offsets: MarkdownSourceCopyIndexOffsets, + text: string, +) { + const counts = countMarkdownSourceBlocks(text); + for (const [lang, count] of counts.codeBlockLanguageCounts) { + const current = offsets.codeBlockLanguageCounts.get(lang) ?? 0; + offsets.codeBlockLanguageCounts.set(lang, current + count); + } + offsets.mathBlockCount += counts.mathBlockCount; +} + export const MainContent = () => { const { version } = useAppContext(); const uiState = useUIState(); @@ -178,6 +210,31 @@ export const MainContent = () => { prevMergedLengthRef.current = currMLen; }, [compactMode, uiState.history, mergedHistory, uiActions]); + const historyItemsWithSourceCopyOffsets = useMemo(() => { + let runningOffsets = createEmptySourceCopyOffsets(); + + return mergedHistory.map((item) => { + if (item.type === 'gemini') { + runningOffsets = createEmptySourceCopyOffsets(); + const offsets = cloneSourceCopyOffsets(runningOffsets); + addSourceBlockCounts(runningOffsets, item.text); + return { item, sourceCopyIndexOffsets: offsets }; + } + + if (item.type === 'gemini_content') { + const offsets = cloneSourceCopyOffsets(runningOffsets); + addSourceBlockCounts(runningOffsets, item.text); + return { item, sourceCopyIndexOffsets: offsets }; + } + + if (item.type === 'user') { + runningOffsets = createEmptySourceCopyOffsets(); + } + + return { item, sourceCopyIndexOffsets: undefined }; + }); + }, [mergedHistory]); + return ( <> { , , , - ...mergedHistory.map((h) => ( - - )), + ...historyItemsWithSourceCopyOffsets.map( + ({ item: h, sourceCopyIndexOffsets }) => ( + + ), + ), ]} > {(item) => item} diff --git a/packages/cli/src/ui/components/messages/ConversationMessages.tsx b/packages/cli/src/ui/components/messages/ConversationMessages.tsx index 526bc9cfe6a..b8d77596f1b 100644 --- a/packages/cli/src/ui/components/messages/ConversationMessages.tsx +++ b/packages/cli/src/ui/components/messages/ConversationMessages.tsx @@ -7,7 +7,10 @@ import type React from 'react'; import { Box, Text } from 'ink'; import stringWidth from 'string-width'; -import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js'; +import { + MarkdownDisplay, + type MarkdownSourceCopyIndexOffsets, +} from '../../utils/MarkdownDisplay.js'; import { theme } from '../../semantic-colors.js'; import { SCREEN_READER_MODEL_PREFIX, @@ -27,6 +30,7 @@ interface AssistantMessageProps { isPending: boolean; availableTerminalHeight?: number; contentWidth: number; + sourceCopyIndexOffsets?: MarkdownSourceCopyIndexOffsets; } interface AssistantMessageContentProps { @@ -34,6 +38,7 @@ interface AssistantMessageContentProps { isPending: boolean; availableTerminalHeight?: number; contentWidth: number; + sourceCopyIndexOffsets?: MarkdownSourceCopyIndexOffsets; } interface ThinkMessageProps { @@ -69,6 +74,7 @@ interface PrefixedMarkdownMessageProps { contentWidth: number; ariaLabel?: string; textColor?: string; + sourceCopyIndexOffsets?: MarkdownSourceCopyIndexOffsets; } interface ContinuationMarkdownMessageProps { @@ -78,6 +84,7 @@ interface ContinuationMarkdownMessageProps { contentWidth: number; basePrefix: string; textColor?: string; + sourceCopyIndexOffsets?: MarkdownSourceCopyIndexOffsets; } function getPrefixWidth(prefix: string): number { @@ -126,6 +133,7 @@ const PrefixedMarkdownMessage: React.FC = ({ contentWidth, ariaLabel, textColor, + sourceCopyIndexOffsets, }) => { const prefixWidth = getPrefixWidth(prefix); @@ -143,6 +151,7 @@ const PrefixedMarkdownMessage: React.FC = ({ availableTerminalHeight={availableTerminalHeight} contentWidth={contentWidth - prefixWidth} textColor={textColor} + sourceCopyIndexOffsets={sourceCopyIndexOffsets} /> @@ -158,6 +167,7 @@ const ContinuationMarkdownMessage: React.FC< contentWidth, basePrefix, textColor, + sourceCopyIndexOffsets, }) => { const prefixWidth = getPrefixWidth(basePrefix); @@ -169,6 +179,7 @@ const ContinuationMarkdownMessage: React.FC< availableTerminalHeight={availableTerminalHeight} contentWidth={contentWidth - prefixWidth} textColor={textColor} + sourceCopyIndexOffsets={sourceCopyIndexOffsets} /> ); @@ -203,6 +214,7 @@ export const AssistantMessage: React.FC = ({ isPending, availableTerminalHeight, contentWidth, + sourceCopyIndexOffsets, }) => ( = ({ isPending={isPending} availableTerminalHeight={availableTerminalHeight} contentWidth={contentWidth} + sourceCopyIndexOffsets={sourceCopyIndexOffsets} /> ); export const AssistantMessageContent: React.FC< AssistantMessageContentProps -> = ({ text, isPending, availableTerminalHeight, contentWidth }) => ( +> = ({ + text, + isPending, + availableTerminalHeight, + contentWidth, + sourceCopyIndexOffsets, +}) => ( ); diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx index faa42656b3b..de6e6c7c78a 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx @@ -523,6 +523,33 @@ $$ expect(output).not.toContain('α + β'); }); + it('applies source copy offsets from previous assistant chunks', () => { + const text = ` +\`\`\`mermaid +sequenceDiagram + A->>B: hello +\`\`\` + +$$ +\\gamma + \\delta +$$ +`.replace(/\n/g, eol); + const { lastFrame } = renderWithProviders( + , + ); + + const output = lastFrame(); + expect(output).toContain('source: /copy mermaid 2'); + expect(output).toContain('source: /copy latex 3'); + }); + it('reuses mermaid node labels when later edges reference node ids', () => { const text = ` \`\`\`mermaid diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.tsx index 3936ffcea1b..d08b0f89640 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.tsx @@ -21,6 +21,70 @@ interface MarkdownDisplayProps { availableTerminalHeight?: number; contentWidth: number; textColor?: string; + sourceCopyIndexOffsets?: MarkdownSourceCopyIndexOffsets; +} + +export interface MarkdownSourceCopyIndexOffsets { + codeBlockLanguageCounts: Map; + mathBlockCount: number; +} + +export interface MarkdownSourceBlockCounts { + codeBlockLanguageCounts: Map; + mathBlockCount: number; +} + +export function countMarkdownSourceBlocks( + text: string, +): MarkdownSourceBlockCounts { + const codeBlockLanguageCounts = new Map(); + const lines = text.split(/\r?\n/); + const codeFenceRegex = /^ *(`{3,}|~{3,}) *([^`]*)$/; + const mathFenceRegex = /^ *\$\$ *$/; + let activeCodeFence: string | null = null; + let inMathBlock = false; + let mathBlockCount = 0; + + for (const line of lines) { + const codeFenceMatch = line.match(codeFenceRegex); + if (activeCodeFence) { + if ( + codeFenceMatch && + codeFenceMatch[1].startsWith(activeCodeFence[0]) && + codeFenceMatch[1].length >= activeCodeFence.length + ) { + activeCodeFence = null; + } + continue; + } + + if (inMathBlock) { + if (mathFenceRegex.test(line)) { + inMathBlock = false; + } + continue; + } + + if (codeFenceMatch) { + activeCodeFence = codeFenceMatch[1]; + const lang = + codeFenceMatch[2]?.trim().split(/\s+/)[0]?.toLowerCase() || null; + if (lang) { + codeBlockLanguageCounts.set( + lang, + (codeBlockLanguageCounts.get(lang) ?? 0) + 1, + ); + } + continue; + } + + if (mathFenceRegex.test(line)) { + inMathBlock = true; + mathBlockCount += 1; + } + } + + return { codeBlockLanguageCounts, mathBlockCount }; } // Constants for Markdown parsing and rendering @@ -38,6 +102,7 @@ const MarkdownDisplayInternal: React.FC = ({ availableTerminalHeight, contentWidth, textColor = theme.text.primary, + sourceCopyIndexOffsets, }) => { const { renderMode } = useRenderMode(); if (!text) return <>; @@ -74,13 +139,15 @@ const MarkdownDisplayInternal: React.FC = ({ let codeBlockIndex = 0; let currentCodeBlockIndex = 0; let currentCodeBlockLangIndex = 0; - const codeBlockLanguageCounts = new Map(); + const codeBlockLanguageCounts = new Map( + sourceCopyIndexOffsets?.codeBlockLanguageCounts, + ); let lastLineEmpty = true; let codeBlockContent: string[] = []; let codeBlockLang: string | null = null; let codeBlockFence = ''; let inMathBlock = false; - let mathBlockIndex = 0; + let mathBlockIndex = sourceCopyIndexOffsets?.mathBlockCount ?? 0; let currentMathBlockIndex = 0; let mathBlockContent: string[] = []; let inTable = false; From 5ad907c0b92f314342e3c76c505caa4e9b712a89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Tue, 28 Apr 2026 21:29:05 +0800 Subject: [PATCH 16/36] fix(cli): support copying inline latex expressions --- .../cli/src/ui/commands/copyCommand.test.ts | 113 +++++++++++++++ packages/cli/src/ui/commands/copyCommand.ts | 136 +++++++++++++++++- 2 files changed, 244 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/ui/commands/copyCommand.test.ts b/packages/cli/src/ui/commands/copyCommand.test.ts index 7d9f745b00e..5258b5f2826 100644 --- a/packages/cli/src/ui/commands/copyCommand.test.ts +++ b/packages/cli/src/ui/commands/copyCommand.test.ts @@ -448,6 +448,119 @@ describe('copyCommand', () => { }); }); + it('should copy the last inline LaTeX expression with /copy latex inline', async () => { + if (!copyCommand.action) throw new Error('Command has no action'); + + mockGetHistory.mockReturnValue([ + { + role: 'model', + parts: [ + { + text: [ + 'Inline math: $x^2 + \\alpha$ and $e^{i\\pi} + 1 = 0$', + '$$', + '\\sum_{i=1}^{n} x_i', + '$$', + ].join('\n'), + }, + ], + }, + ]); + mockCopyToClipboard.mockResolvedValue(undefined); + + const result = await copyCommand.action(mockContext, 'latex inline'); + + expect(mockCopyToClipboard).toHaveBeenCalledWith('e^{i\\pi} + 1 = 0'); + expect(result).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'); + + mockGetHistory.mockReturnValue([ + { + role: 'model', + parts: [ + { + text: [ + 'Formula $x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$', + 'Identity $e^{i\\pi} + 1 = 0$', + ].join('\n'), + }, + ], + }, + ]); + mockCopyToClipboard.mockResolvedValue(undefined); + + const result = await copyCommand.action(mockContext, 'latex inline 1'); + + expect(mockCopyToClipboard).toHaveBeenCalledWith( + 'x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}', + ); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Inline LaTeX expression 1 copied to the clipboard', + }); + }); + + it('should copy inline LaTeX with the /copy inline-latex alias', async () => { + if (!copyCommand.action) throw new Error('Command has no action'); + + mockGetHistory.mockReturnValue([ + { + role: 'model', + parts: [{ text: 'Inline math: $\\alpha + \\beta$' }], + }, + ]); + mockCopyToClipboard.mockResolvedValue(undefined); + + const result = await copyCommand.action(mockContext, 'inline-latex 1'); + + expect(mockCopyToClipboard).toHaveBeenCalledWith('\\alpha + \\beta'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Inline LaTeX expression 1 copied to the clipboard', + }); + }); + + it('should not copy inline LaTeX from code fences or display math blocks', async () => { + if (!copyCommand.action) throw new Error('Command has no action'); + + mockGetHistory.mockReturnValue([ + { + role: 'model', + parts: [ + { + text: [ + '```md', + 'Ignored $x^2$', + '```', + '$$', + '\\alpha + \\beta', + '$$', + ].join('\n'), + }, + ], + }, + ]); + + const result = await copyCommand.action(mockContext, 'latex inline'); + + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: + 'No matching inline LaTeX expression found in the last AI output.', + }); + expect(mockCopyToClipboard).not.toHaveBeenCalled(); + }); + it('should report when /copy latex has no matching LaTeX block', async () => { if (!copyCommand.action) throw new Error('Command has no action'); diff --git a/packages/cli/src/ui/commands/copyCommand.ts b/packages/cli/src/ui/commands/copyCommand.ts index 41e7fa1a891..17a1b4e3594 100644 --- a/packages/cli/src/ui/commands/copyCommand.ts +++ b/packages/cli/src/ui/commands/copyCommand.ts @@ -31,6 +31,19 @@ interface SelectedLatexBlock { label: string; } +interface InlineLatexExpression { + content: string; + index: number; +} + +interface SelectedInlineLatexExpression { + expression: InlineLatexExpression; + label: string; +} + +const INLINE_MATH_REGEX = + /(?= activeCodeFence.length + ) { + activeCodeFence = null; + } + continue; + } + + if (inLatexBlock) { + if (mathFenceRegex.test(line)) { + inLatexBlock = false; + } + continue; + } + + if (codeFenceMatch) { + activeCodeFence = codeFenceMatch[1]; + continue; + } + + if (mathFenceRegex.test(line)) { + inLatexBlock = true; + continue; + } + + for (const match of line.matchAll(INLINE_MATH_REGEX)) { + const content = match[1]; + if (content) { + expressions.push({ + content, + index: expressions.length + 1, + }); + } + } + } + + return expressions; +} + +function selectInlineLatexExpression( + markdown: string, + tokens: string[], +): SelectedInlineLatexExpression | null { + const expressions = parseInlineLatexExpressions(markdown); + if (expressions.length === 0) return null; + + let requestedIndex: number | null = null; + for (const token of tokens) { + if (/^\d+$/.test(token)) { + requestedIndex = Number(token); + } + } + + const selected = + requestedIndex !== null + ? expressions.find((expression) => expression.index === requestedIndex) + : expressions[expressions.length - 1]; + + return selected + ? { + expression: selected, + label: `Inline LaTeX expression ${selected.index}`, + } + : null; +} + function parseLatexBlocks(markdown: string): LatexBlock[] { const blocks: LatexBlock[] = []; const lines = markdown.split(/\r?\n/); @@ -118,16 +212,37 @@ function parseLatexBlocks(markdown: string): LatexBlock[] { function selectLatexBlock( markdown: string, args: string, -): SelectedLatexBlock | null | undefined { +): SelectedLatexBlock | SelectedInlineLatexExpression | null | undefined { const tokens = args.trim().split(/\s+/).filter(Boolean); const firstToken = tokens[0]?.toLowerCase(); - if (firstToken !== 'latex' && firstToken !== 'math') return undefined; + if ( + firstToken !== 'latex' && + firstToken !== 'math' && + firstToken !== 'inline-latex' + ) { + return undefined; + } + + if (firstToken === 'inline-latex') { + return selectInlineLatexExpression(markdown, tokens.slice(1)); + } + + const selectorTokens = tokens.slice(1); + const inlineSelectorIndex = selectorTokens.findIndex( + (token) => token.toLowerCase() === 'inline', + ); + if (inlineSelectorIndex !== -1) { + return selectInlineLatexExpression( + markdown, + selectorTokens.filter((_, index) => index !== inlineSelectorIndex), + ); + } const blocks = parseLatexBlocks(markdown); if (blocks.length === 0) return null; let requestedIndex: number | null = null; - for (const token of tokens.slice(1)) { + for (const token of selectorTokens) { if (/^\d+$/.test(token)) { requestedIndex = Number(token); } @@ -236,11 +351,22 @@ export const copyCommand: SlashCommand = { return { type: 'message', messageType: 'info', - content: 'No matching LaTeX block found in the last AI output.', + content: + _args + .trim() + .split(/\s+/) + .some((token) => token === 'inline') || + _args.trim().toLowerCase().startsWith('inline-latex') + ? 'No matching inline LaTeX expression found in the last AI output.' + : 'No matching LaTeX block found in the last AI output.', }; } if (selectedLatexBlock !== undefined) { - await copyToClipboard(selectedLatexBlock.block.content); + const copiedLatex = + 'expression' in selectedLatexBlock + ? selectedLatexBlock.expression.content + : selectedLatexBlock.block.content; + await copyToClipboard(copiedLatex); return { type: 'message', From 0b371a2c9bebabb7b27d5ac1788cbdd082ead6eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Tue, 28 Apr 2026 21:51:35 +0800 Subject: [PATCH 17/36] feat(cli): document markdown render controls --- docs/design/markdown-syntax-extension.md | 10 +- docs/users/configuration/settings.md | 1 + docs/users/features/_meta.ts | 1 + docs/users/features/markdown-rendering.md | 151 +++++++++++++++++++++ docs/users/reference/keyboard-shortcuts.md | 1 + packages/cli/src/config/settingsSchema.ts | 14 ++ packages/cli/src/ui/AppContainer.tsx | 8 +- 7 files changed, 182 insertions(+), 4 deletions(-) create mode 100644 docs/users/features/markdown-rendering.md diff --git a/docs/design/markdown-syntax-extension.md b/docs/design/markdown-syntax-extension.md index 867cda43fe1..0be07f294f4 100644 --- a/docs/design/markdown-syntax-extension.md +++ b/docs/design/markdown-syntax-extension.md @@ -43,6 +43,10 @@ Included in the first implementation: substitutions. - Existing Markdown tables continue to use `TableRenderer`. - Existing non-Mermaid fenced code blocks continue to use `CodeColorizer`. +- Rendered visual blocks keep source reachable through `/copy mermaid N`, + `/copy latex N`, `/copy latex inline N`, and raw mode. +- `ui.renderMode` controls whether sessions start in rendered or raw/source + mode, while `Alt/Option+M` toggles the active session view. ## Mermaid Rendering Strategy @@ -133,9 +137,9 @@ entities/relationships, Gantt tasks, pie slices, journey steps, mindmap trees, git graph entries, and requirement trees. If a diagram type is unknown or not previewable, the renderer shows the original fenced Mermaid source rather than a placeholder so the content remains readable and selectable/copyable in the -terminal. A dedicated one-key "copy Mermaid source" interaction is still a -separate UI affordance for a future PR because the current message renderer -does not expose per-block actions. +terminal. Rendered Mermaid headings also show the Mermaid-specific copy command, +for example `/copy mermaid 2`, so users can recover the original diagram source +without switching the whole view to raw mode. The fallback is still not a complete Mermaid engine. It is a fast, dependency-light preview layer for common LLM-generated diagrams when diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 751bdb35fb9..6804549b8be 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -107,6 +107,7 @@ Settings are organized into categories. All settings should be placed within the | `ui.hideFooter` | boolean | Hide the footer from the UI. | `false` | | `ui.showMemoryUsage` | boolean | Display memory usage information in the UI. | `false` | | `ui.showLineNumbers` | boolean | Show line numbers in code blocks in the CLI output. | `true` | +| `ui.renderMode` | string | Default Markdown display mode. Use `"render"` for rich visual previews or `"raw"` to show source-oriented Markdown by default. Toggle during a session with `Alt/Option+M`. See [Markdown Rendering](../features/markdown-rendering). | `"render"` | | `ui.showCitations` | boolean | Show citations for generated text in the chat. | `true` | | `ui.compactMode` | boolean | Hide tool output and thinking for a cleaner view. Toggle with `Ctrl+O` during a session or via the Settings dialog. Tool approval prompts are never hidden, even in compact mode. The setting persists across sessions. | `false` | | `ui.shellOutputMaxLines` | number | Max number of shell output lines shown inline. Set to `0` to disable the cap and show full output. Hidden lines are surfaced via the `+N lines` indicator. Errors, `!`-prefix user-initiated commands, confirming tools, and focused embedded shells always show full output. | `5` | diff --git a/docs/users/features/_meta.ts b/docs/users/features/_meta.ts index 661a26c0395..7f76ae1526d 100644 --- a/docs/users/features/_meta.ts +++ b/docs/users/features/_meta.ts @@ -3,6 +3,7 @@ export default { 'code-review': 'Code Review', 'followup-suggestions': 'Followup Suggestions', 'tool-use-summaries': 'Tool-Use Summaries', + 'markdown-rendering': 'Markdown Rendering', 'sub-agents': 'SubAgents', arena: 'Agent Arena', skills: 'Skills', diff --git a/docs/users/features/markdown-rendering.md b/docs/users/features/markdown-rendering.md new file mode 100644 index 00000000000..f8e91578fbe --- /dev/null +++ b/docs/users/features/markdown-rendering.md @@ -0,0 +1,151 @@ +# Markdown Rendering + +Qwen Code renders common Markdown structures directly in the TUI so model +answers are easier to scan without leaving the terminal. The renderer is +designed to keep the original source reachable, especially for visual blocks +such as Mermaid diagrams and LaTeX math. + +## Render and Raw Modes + +By default, Markdown is shown in `render` mode. Supported blocks render as +visual previews where possible: + +- Mermaid fenced code blocks +- Markdown tables +- task lists +- blockquotes +- inline and block LaTeX math +- fenced code blocks with syntax highlighting + +Press `Alt/Option+M` to toggle the current session between: + +- `render`: show rich terminal previews for supported Markdown. +- `raw`: show source-oriented Markdown for visual blocks such as Mermaid, + tables, and LaTeX. + +To start Qwen Code in raw mode by default, set `ui.renderMode`: + +```json +{ + "ui": { + "renderMode": "raw" + } +} +``` + +Accepted values are `"render"` and `"raw"`. The shortcut only changes the +current session view; it does not rewrite your settings file. + +## Mermaid + +Fenced `mermaid` code blocks render visually in `render` mode. The TUI uses a +layered strategy: + +1. If enabled and supported, Qwen Code asks Mermaid CLI (`mmdc`) to render the + diagram to a PNG and sends it to the terminal image protocol. +2. If terminal images are unavailable but `chafa` is installed, the same PNG can + be converted to ANSI block graphics. +3. Otherwise, Qwen Code falls back to a terminal wireframe or compact text + preview. +4. If a Mermaid diagram type cannot be previewed, Qwen Code shows the original + fenced source instead of hiding it behind a placeholder. + +Mermaid image rendering is disabled by default because it requires external +renderers and terminal image support. Enable it with: + +```bash +QWEN_CODE_MERMAID_IMAGE_RENDERING=1 qwen +``` + +Optional environment variables: + +| Variable | Description | +| ------------------------------------------- | ----------------------------------------------------------------------------------- | +| `QWEN_CODE_MERMAID_IMAGE_RENDERING=1` | Enables external Mermaid image rendering. | +| `QWEN_CODE_DISABLE_MERMAID_IMAGES=1` | Disables Mermaid image rendering even when enabled elsewhere. | +| `QWEN_CODE_MERMAID_IMAGE_PROTOCOL=kitty` | Forces Kitty protocol output. Useful for terminals such as Kitty and Ghostty. | +| `QWEN_CODE_MERMAID_IMAGE_PROTOCOL=iterm2` | Forces iTerm2 inline image output. | +| `QWEN_CODE_MERMAID_IMAGE_PROTOCOL=off` | Disables terminal image protocols and allows text or `chafa` fallback. | +| `QWEN_CODE_MERMAID_MMD_CLI=/path/to/mmdc` | Uses a specific Mermaid CLI executable. | +| `QWEN_CODE_MERMAID_ALLOW_NPX=1` | Allows Qwen Code to run `npx @mermaid-js/mermaid-cli` when `mmdc` is not installed. | +| `QWEN_CODE_MERMAID_ALLOW_LOCAL_RENDERERS=1` | Allows project-local renderer binaries under `node_modules/.bin`. | +| `QWEN_CODE_MERMAID_RENDER_WIDTH=1200` | Overrides the PNG render width. | +| `QWEN_CODE_MERMAID_RENDER_TIMEOUT_MS=10000` | Overrides the external render timeout. | + +The first image render can be slow, especially when `npx` needs to resolve or +download Mermaid CLI. During streaming, Qwen Code shows a bounded text preview +and attempts image rendering only after the model response is complete. + +### Mermaid Source Copy + +Every rendered Mermaid block includes a source hint such as: + +```text +Mermaid flowchart (TD) · source: /copy mermaid 1 +``` + +Use these commands to copy Mermaid source from the last AI response: + +| Command | Behavior | +| ---------------------- | --------------------------------------------- | +| `/copy mermaid` | Copies the last Mermaid block. | +| `/copy mermaid 1` | Copies the first Mermaid block. | +| `/copy code mermaid` | Copies the last fenced `mermaid` code block. | +| `/copy code mermaid 1` | Copies the first fenced `mermaid` code block. | + +`/copy code 1` counts all fenced code blocks, not only Mermaid blocks. Use +`/copy mermaid N` when you want the Mermaid-specific sequence shown in the +rendered title. + +## LaTeX Math + +Qwen Code supports basic inline and block LaTeX rendering in the terminal: + +```markdown +Inline math: $x = \frac{-b \pm \sqrt{b^2 - 4ac}}{2a}$ + +$$ +\sum_{n=1}^{\infty} 1/n^2 = \pi^2/6 +$$ +``` + +The renderer focuses on common symbols and readable terminal output. It is not +a full TeX engine; complex layouts such as matrices, aligned equations, and +large nested expressions may be simplified. + +### LaTeX Source Copy + +Use these commands to copy LaTeX source from the last AI response: + +| Command | Behavior | +| ---------------------- | --------------------------------------- | +| `/copy latex` | Copies the last block LaTeX expression. | +| `/copy latex 2` | Copies the second block expression. | +| `/copy latex inline` | Copies the last inline expression. | +| `/copy latex inline 2` | Copies the second inline expression. | +| `/copy inline-latex 2` | Alias for `/copy latex inline 2`. | + +Inline LaTeX does not show a per-expression copy hint in rendered text to avoid +making prose noisy. Switch to raw mode with `Alt/Option+M` when you want to +inspect inline source in place. + +## General Code Copy + +The `/copy code` command reads fenced code blocks from the last AI Markdown +response: + +| Command | Behavior | +| ----------------------- | ---------------------------------------- | +| `/copy code` | Copies the last fenced code block. | +| `/copy code 2` | Copies the second fenced code block. | +| `/copy code typescript` | Copies the last `typescript` code block. | +| `/copy code mermaid 1` | Copies the first `mermaid` code block. | + +## Current Limits + +- Mermaid image rendering depends on Mermaid CLI plus terminal image support. +- Wireframe Mermaid rendering is a readable terminal preview, not a full + Mermaid layout engine. +- Raw mode is global for rendered Markdown blocks; it is not a per-block toggle. +- LaTeX rendering covers common symbols and expressions, not full TeX layout. +- Source copy commands operate on the last AI response. diff --git a/docs/users/reference/keyboard-shortcuts.md b/docs/users/reference/keyboard-shortcuts.md index e6a259e88a6..7710274851e 100644 --- a/docs/users/reference/keyboard-shortcuts.md +++ b/docs/users/reference/keyboard-shortcuts.md @@ -13,6 +13,7 @@ This document lists the available keyboard shortcuts in Qwen Code. | `Ctrl+O` | Toggle compact mode (hide/show tool output and thinking). | | `Ctrl+S` | Allows long responses to print fully, disabling truncation. Use your terminal's scrollback to view the entire output. | | `Ctrl+T` | Toggle the display of tool descriptions. | +| `Alt/Option+M` | Toggle Markdown output between rich rendered previews and raw/source mode. | | `Shift+Tab` (`Tab` on Windows) | Cycle approval modes (`plan` → `default` → `auto-edit` → `yolo`) | ## Input Prompt diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index c051ffa150c..c7782e9d1ae 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -590,6 +590,20 @@ const SETTINGS_SCHEMA = { description: 'Show line numbers in the code output.', showInDialog: true, }, + renderMode: { + type: 'enum', + label: 'Markdown Render Mode', + category: 'UI', + requiresRestart: false, + default: 'render', + description: + 'Default Markdown display mode. Use "render" for rich visual previews, or "raw" to show source-oriented Markdown by default. Toggle during a session with Alt/Option+M.', + showInDialog: true, + options: [ + { value: 'render', label: 'Render visual previews' }, + { value: 'raw', label: 'Show raw source' }, + ], + }, showCitations: { type: 'boolean', label: 'Show Citations', diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 916c85816ef..80aea487b5a 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1536,7 +1536,13 @@ export const AppContainer = (props: AppContainerProps) => { const [compactMode, setCompactMode] = useState( settings.merged.ui?.compactMode ?? false, ); - const [renderMode, setRenderMode] = useState('render'); + const configuredRenderMode = settings.merged.ui?.renderMode; + const [renderMode, setRenderMode] = useState( + configuredRenderMode === 'raw' ? 'raw' : 'render', + ); + useEffect(() => { + setRenderMode(configuredRenderMode === 'raw' ? 'raw' : 'render'); + }, [configuredRenderMode]); const renderModeMountedRef = useRef(false); useEffect(() => { if (!renderModeMountedRef.current) { From 06f80c9dafaef2ef57cbf5ba758e5ea7a2c0a3fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Tue, 28 Apr 2026 22:14:43 +0800 Subject: [PATCH 18/36] test(cli): cover markdown render controls --- docs/design/markdown-syntax-extension.md | 29 +++++++++- .../terminal-capture/package.json | 3 +- .../scenarios/markdown-rendering.ts | 48 ++++++++++++++++ .../cli/src/config/settingsSchema.test.ts | 15 +++++ packages/cli/src/ui/AppContainer.test.tsx | 55 +++++++++++++++++++ .../schemas/settings.schema.json | 8 +++ 6 files changed, 155 insertions(+), 3 deletions(-) create mode 100644 integration-tests/terminal-capture/scenarios/markdown-rendering.ts diff --git a/docs/design/markdown-syntax-extension.md b/docs/design/markdown-syntax-extension.md index 0be07f294f4..b4ef3d8da5a 100644 --- a/docs/design/markdown-syntax-extension.md +++ b/docs/design/markdown-syntax-extension.md @@ -189,18 +189,38 @@ Targeted unit verification: ```bash cd packages/cli -npx vitest run src/ui/utils/MarkdownDisplay.test.tsx -npx vitest run src/ui/utils/mermaidImageRenderer.test.ts +npx vitest run \ + src/config/settingsSchema.test.ts \ + src/ui/AppContainer.test.tsx \ + src/ui/utils/MarkdownDisplay.test.tsx \ + src/ui/utils/mermaidImageRenderer.test.ts \ + src/ui/commands/copyCommand.test.ts \ + src/ui/components/BaseTextInput.test.tsx \ + src/ui/keyMatchers.test.ts \ + src/ui/contexts/KeypressContext.test.tsx ``` Broader verification before PR submission: ```bash +npm run build --workspace=packages/cli npm run typecheck --workspace=packages/cli npm run lint --workspace=packages/cli git diff --check ``` +Terminal-capture integration scenario: + +```bash +npm run build && npm run bundle +cd integration-tests/terminal-capture +npm run capture:markdown-rendering +``` + +This scenario captures a Markdown-heavy model response, toggles raw/source mode +with `Alt/Option+M`, and verifies the visible source copy flows with +`/copy mermaid 1` and `/copy latex 1`. + Manual scenarios: - Assistant response with a Mermaid `flowchart LR` block. @@ -209,3 +229,8 @@ Manual scenarios: - Fenced JavaScript code block still showing code formatting. - Narrow terminal width. - Constrained tool/detail surface. +- `ui.renderMode: "raw"` starts a session in source-oriented mode. +- `Alt/Option+M` toggles the same response between rendered and raw/source + mode. +- Mermaid and LaTeX visual blocks expose copy hints that map to the actual + `/copy mermaid N` and `/copy latex N` source order. diff --git a/integration-tests/terminal-capture/package.json b/integration-tests/terminal-capture/package.json index 46d602c2276..65f81e1cba6 100644 --- a/integration-tests/terminal-capture/package.json +++ b/integration-tests/terminal-capture/package.json @@ -7,7 +7,8 @@ "scripts": { "capture": "npx tsx run.ts scenarios/", "capture:about": "npx tsx run.ts scenarios/about.ts", - "capture:all": "npx tsx run.ts scenarios/all.ts" + "capture:all": "npx tsx run.ts scenarios/all.ts", + "capture:markdown-rendering": "npx tsx run.ts scenarios/markdown-rendering.ts" }, "dependencies": { "@lydell/node-pty": "1.2.0-beta.10", diff --git a/integration-tests/terminal-capture/scenarios/markdown-rendering.ts b/integration-tests/terminal-capture/scenarios/markdown-rendering.ts new file mode 100644 index 00000000000..1b49899e65b --- /dev/null +++ b/integration-tests/terminal-capture/scenarios/markdown-rendering.ts @@ -0,0 +1,48 @@ +import type { ScenarioConfig } from '../scenario-runner.js'; + +const markdownPrompt = `Output a compact Markdown rendering verification sample with exactly: + +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}$. +5. One display math block using $$ fences. +6. One checked and one unchecked task list item. + +Do not explain the sample.`; + +export default { + name: 'markdown-rendering', + spawn: ['node', 'dist/cli.js', '--yolo'], + terminal: { + title: 'qwen-code markdown rendering', + cwd: '../../..', + cols: 140, + rows: 42, + }, + flow: [ + { + type: markdownPrompt, + streaming: { + delayMs: 3000, + intervalMs: 1000, + count: 15, + }, + capture: 'markdown-rendered.png', + captureFull: 'markdown-rendered-full.png', + }, + { + key: '\x1bm', + capture: 'markdown-raw-toggle.png', + captureFull: 'markdown-raw-toggle-full.png', + }, + { + type: '/copy mermaid 1', + capture: 'copy-mermaid-source.png', + }, + { + type: '/copy latex 1', + capture: 'copy-latex-source.png', + }, + ], +} satisfies ScenarioConfig; diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts index 5d4363b13f1..49494768078 100644 --- a/packages/cli/src/config/settingsSchema.test.ts +++ b/packages/cli/src/config/settingsSchema.test.ts @@ -217,11 +217,25 @@ describe('SettingsSchema', () => { ).toBe(false); }); + it('should define Markdown render mode as a user-facing UI enum', () => { + const renderMode = getSettingsSchema().ui.properties.renderMode; + + expect(renderMode.type).toBe('enum'); + expect(renderMode.default).toBe('render'); + expect(renderMode.requiresRestart).toBe(false); + expect(renderMode.showInDialog).toBe(true); + expect(renderMode.options).toEqual([ + { value: 'render', label: 'Render visual previews' }, + { value: 'raw', label: 'Show raw source' }, + ]); + }); + it('should infer Settings type correctly', () => { // This test ensures that the Settings type is properly inferred from the schema const settings: Settings = { ui: { theme: 'dark', + renderMode: 'raw', }, context: { includeDirectories: ['/path/to/dir'], @@ -231,6 +245,7 @@ describe('SettingsSchema', () => { // TypeScript should not complain about these properties expect(settings.ui?.theme).toBe('dark'); + expect(settings.ui?.renderMode).toBe('raw'); expect(settings.context?.includeDirectories).toEqual(['/path/to/dir']); expect(settings.context?.loadFromIncludeDirectories).toBe(true); }); diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 9b15d302b92..8c9df6e97b3 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -29,6 +29,10 @@ import { UIActionsContext, type UIActions, } from './contexts/UIActionsContext.js'; +import { + useRenderMode, + type RenderMode, +} from './contexts/RenderModeContext.js'; import { ToolCallStatus } from './types.js'; import { useContext } from 'react'; import { Box, measureElement } from 'ink'; @@ -48,9 +52,11 @@ vi.mock('ink', async (importOriginal) => { // so we can assert against them in our tests. let capturedUIState: UIState; let capturedUIActions: UIActions; +let capturedRenderMode: RenderMode; function TestContextConsumer() { capturedUIState = useContext(UIStateContext)!; capturedUIActions = useContext(UIActionsContext)!; + capturedRenderMode = useRenderMode().renderMode; return ; } @@ -169,6 +175,7 @@ describe('AppContainer State Management', () => { capturedUIState = null!; capturedUIActions = null!; + capturedRenderMode = 'render'; // **Provide a default return value for EVERY mocked hook.** mockedUseHistory.mockReturnValue({ @@ -924,6 +931,54 @@ describe('AppContainer State Management', () => { ); }).not.toThrow(); }); + + it('initializes Markdown render mode from ui.renderMode', () => { + const rawSettings = { + ...mockSettings, + merged: { + ...mockSettings.merged, + ui: { + ...mockSettings.merged.ui, + renderMode: 'raw', + }, + }, + } as unknown as LoadedSettings; + + render( + , + ); + + expect(capturedRenderMode).toBe('raw'); + }); + + it('falls back to rendered Markdown mode for missing or invalid ui.renderMode', () => { + const invalidSettings = { + ...mockSettings, + merged: { + ...mockSettings.merged, + ui: { + ...mockSettings.merged.ui, + renderMode: 'unsupported', + }, + }, + } as unknown as LoadedSettings; + + render( + , + ); + + expect(capturedRenderMode).toBe('render'); + }); }); describe('Version Handling', () => { diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index e78f3b2cbb9..d601e8d1321 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -190,6 +190,14 @@ "type": "boolean", "default": true }, + "renderMode": { + "description": "Default Markdown display mode. Use \"render\" for rich visual previews, or \"raw\" to show source-oriented Markdown by default. Toggle during a session with Alt/Option+M. Options: render, raw", + "enum": [ + "render", + "raw" + ], + "default": "render" + }, "showCitations": { "description": "Show citations for generated text in the chat.", "type": "boolean", From 7448a80d5bda191dbeb99f12eafb3a6392880cb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Tue, 28 Apr 2026 22:35:34 +0800 Subject: [PATCH 19/36] fix(cli): tighten markdown render fallbacks --- .../cli/src/ui/commands/copyCommand.test.ts | 34 +++++++++++++++++++ packages/cli/src/ui/commands/copyCommand.ts | 19 +++++++++++ .../cli/src/ui/utils/MarkdownDisplay.test.tsx | 1 + packages/cli/src/ui/utils/MermaidDiagram.tsx | 12 ++++--- .../src/ui/utils/mermaidImageRenderer.test.ts | 9 +++++ .../cli/src/ui/utils/mermaidImageRenderer.ts | 7 +++- 6 files changed, 76 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/ui/commands/copyCommand.test.ts b/packages/cli/src/ui/commands/copyCommand.test.ts index 5258b5f2826..ca9f78731e9 100644 --- a/packages/cli/src/ui/commands/copyCommand.test.ts +++ b/packages/cli/src/ui/commands/copyCommand.test.ts @@ -448,6 +448,40 @@ describe('copyCommand', () => { }); }); + it('should not copy LaTeX blocks from fenced code blocks', async () => { + if (!copyCommand.action) throw new Error('Command has no action'); + + mockGetHistory.mockReturnValue([ + { + role: 'model', + parts: [ + { + text: [ + '```md', + '$$', + 'ignored_code_math', + '$$', + '```', + '$$', + '\\alpha + \\beta', + '$$', + ].join('\n'), + }, + ], + }, + ]); + mockCopyToClipboard.mockResolvedValue(undefined); + + const result = await copyCommand.action(mockContext, 'latex 1'); + + expect(mockCopyToClipboard).toHaveBeenCalledWith('\\alpha + \\beta'); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'LaTeX block 1 copied to the clipboard', + }); + }); + it('should copy the last inline LaTeX expression with /copy latex inline', async () => { if (!copyCommand.action) throw new Error('Command has no action'); diff --git a/packages/cli/src/ui/commands/copyCommand.ts b/packages/cli/src/ui/commands/copyCommand.ts index 17a1b4e3594..d279b4475c1 100644 --- a/packages/cli/src/ui/commands/copyCommand.ts +++ b/packages/cli/src/ui/commands/copyCommand.ts @@ -180,12 +180,31 @@ function selectInlineLatexExpression( function parseLatexBlocks(markdown: string): LatexBlock[] { const blocks: LatexBlock[] = []; const lines = markdown.split(/\r?\n/); + const codeFenceRegex = /^ *(`{3,}|~{3,}) *([^`]*)$/; const mathFenceRegex = /^ *\$\$ *$/; + let activeCodeFence: string | null = null; let inLatexBlock = false; let activeLines: string[] = []; for (const line of lines) { + const codeFenceMatch = line.match(codeFenceRegex); + if (activeCodeFence) { + if ( + codeFenceMatch && + codeFenceMatch[1].startsWith(activeCodeFence[0]) && + codeFenceMatch[1].length >= activeCodeFence.length + ) { + activeCodeFence = null; + } + continue; + } + if (!inLatexBlock) { + if (codeFenceMatch) { + activeCodeFence = codeFenceMatch[1]; + continue; + } + if (mathFenceRegex.test(line)) { inLatexBlock = true; activeLines = []; diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx index de6e6c7c78a..09f57204782 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx @@ -745,6 +745,7 @@ flowchart TD const output = lastFrame() ?? ''; expect(output).not.toContain('Rendering Mermaid image'); + expect(output).not.toContain('Image rendering unavailable'); expect(output).toContain('Start'); expect(output).toContain('End'); }); diff --git a/packages/cli/src/ui/utils/MermaidDiagram.tsx b/packages/cli/src/ui/utils/MermaidDiagram.tsx index 612015f3b94..77cd11f29a0 100644 --- a/packages/cli/src/ui/utils/MermaidDiagram.tsx +++ b/packages/cli/src/ui/utils/MermaidDiagram.tsx @@ -167,11 +167,13 @@ const MermaidDiagramInternal: React.FC = ({ {visual.warning} )} - {!isPending && image?.kind === 'unavailable' && ( - - Image rendering unavailable: {image.reason} - - )} + {!isPending && + image?.kind === 'unavailable' && + image.showReason !== false && ( + + Image rendering unavailable: {image.reason} + + )} ); }; diff --git a/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts b/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts index 24c9b181393..e4516e77524 100644 --- a/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts +++ b/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts @@ -126,6 +126,15 @@ describe('mermaid image renderer', () => { ).toBeNull(); }); + it('honors the Mermaid image disable flag over forced protocols', () => { + expect( + detectTerminalImageProtocol({ + QWEN_CODE_DISABLE_MERMAID_IMAGES: '1', + QWEN_CODE_MERMAID_IMAGE_PROTOCOL: 'kitty', + }), + ).toBeNull(); + }); + it('encodes PNG data for terminal image protocols', () => { expect(readPngSize(PNG_1X1)).toEqual({ width: 1, height: 1 }); expect(encodeITerm2InlineImage(PNG_1X1, 40, 10)).toContain( diff --git a/packages/cli/src/ui/utils/mermaidImageRenderer.ts b/packages/cli/src/ui/utils/mermaidImageRenderer.ts index e474a6abea5..2b124b1ba31 100644 --- a/packages/cli/src/ui/utils/mermaidImageRenderer.ts +++ b/packages/cli/src/ui/utils/mermaidImageRenderer.ts @@ -37,6 +37,7 @@ export interface MermaidAnsiImageResult { export interface MermaidImageUnavailableResult { kind: 'unavailable'; reason: string; + showReason?: boolean; } export type MermaidImageRenderResult = @@ -202,6 +203,10 @@ const cachedPngResults = new Map< export function detectTerminalImageProtocol( env: NodeJS.ProcessEnv = process.env, ): TerminalImageProtocol | null { + if (env['QWEN_CODE_DISABLE_MERMAID_IMAGES'] === '1') { + return null; + } + const forced = env['QWEN_CODE_MERMAID_IMAGE_PROTOCOL']?.toLowerCase(); if (forced) { if (forced === 'kitty') return 'kitty'; @@ -210,7 +215,6 @@ export function detectTerminalImageProtocol( } if ( - env['QWEN_CODE_DISABLE_MERMAID_IMAGES'] === '1' || !process.stdout.isTTY || env['TMUX'] || env['SSH_TTY'] || @@ -334,6 +338,7 @@ export function renderMermaidImageSync({ kind: 'unavailable', reason: 'Mermaid image rendering is disabled by default. Set QWEN_CODE_MERMAID_IMAGE_RENDERING=1 to enable external renderers.', + showReason: false, }; } From 2056b99d8479d0311d7d9a5d84bba9d31f9135e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Wed, 29 Apr 2026 10:26:27 +0800 Subject: [PATCH 20/36] fix(cli): bound mermaid renderer cache --- .../src/ui/utils/mermaidImageRenderer.test.ts | 75 +++++++++++++++++ .../cli/src/ui/utils/mermaidImageRenderer.ts | 84 +++++++++++++++++-- 2 files changed, 154 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts b/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts index e4516e77524..bc6d1243ff8 100644 --- a/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts +++ b/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts @@ -108,6 +108,36 @@ describe('mermaid image renderer', () => { } }); + it('does not auto-discover node_modules renderers from PATH without opt-in', () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-path-mmdc-')); + tempDirs.push(tempDir); + const binDir = path.join( + tempDir, + 'packages', + 'app', + 'node_modules', + '.bin', + ); + fs.mkdirSync(binDir, { recursive: true }); + createFakeMmdc(binDir); + + const result = renderMermaidImageSync({ + source: 'flowchart TD\n A[Start] --> B[End]', + contentWidth: 80, + availableTerminalHeight: 20, + env: { + PATH: binDir, + QWEN_CODE_MERMAID_IMAGE_RENDERING: '1', + QWEN_CODE_MERMAID_IMAGE_PROTOCOL: 'kitty', + }, + }); + + expect(result.kind).toBe('unavailable'); + expect(result.kind === 'unavailable' && result.reason).toContain( + 'Mermaid CLI (mmdc) was not found', + ); + }); + it('detects forced terminal image protocols', () => { expect( detectTerminalImageProtocol({ @@ -255,4 +285,49 @@ describe('mermaid image renderer', () => { 'exceeded', ); }); + + it('evicts Mermaid image caches by retained byte size', () => { + const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-mmdc-')); + tempDirs.push(binDir); + const countPath = path.join(binDir, 'count.txt'); + createFakeMmdc(binDir, [ + 'const fs = require("node:fs");', + `const countPath = ${JSON.stringify(countPath)};`, + 'let count = 0;', + 'try { count = Number(fs.readFileSync(countPath, "utf8")) || 0; } catch {}', + 'fs.writeFileSync(countPath, String(count + 1));', + 'const out = process.argv[process.argv.indexOf("-o") + 1];', + `fs.writeFileSync(out, Buffer.concat([Buffer.from("${PNG_1X1.toString( + 'base64', + )}", "base64"), Buffer.alloc(7 * 1024 * 1024)]));`, + ]); + + const env = { + PATH: `${binDir}${path.delimiter}${process.env['PATH'] ?? ''}`, + QWEN_CODE_MERMAID_IMAGE_RENDERING: '1', + QWEN_CODE_MERMAID_IMAGE_PROTOCOL: 'iterm2', + }; + + for (let index = 0; index < 5; index++) { + const result = renderMermaidImageSync({ + source: `flowchart TD\n A${index}[Start] --> B${index}[End]`, + contentWidth: 80, + availableTerminalHeight: 20, + env, + }); + expect(result.kind).toBe('terminal-image'); + } + + expect(fs.readFileSync(countPath, 'utf8')).toBe('5'); + + const result = renderMermaidImageSync({ + source: 'flowchart TD\n A0[Start] --> B0[End]', + contentWidth: 80, + availableTerminalHeight: 20, + env, + }); + + expect(result.kind).toBe('terminal-image'); + expect(fs.readFileSync(countPath, 'utf8')).toBe('6'); + }); }); diff --git a/packages/cli/src/ui/utils/mermaidImageRenderer.ts b/packages/cli/src/ui/utils/mermaidImageRenderer.ts index 2b124b1ba31..0588566c9db 100644 --- a/packages/cli/src/ui/utils/mermaidImageRenderer.ts +++ b/packages/cli/src/ui/utils/mermaidImageRenderer.ts @@ -58,6 +58,8 @@ export interface KittyImagePlaceholder { const CACHE_LIMIT = 40; const PNG_CACHE_LIMIT = 20; +const CACHE_BYTE_LIMIT = 32 * 1024 * 1024; +const PNG_CACHE_BYTE_LIMIT = 32 * 1024 * 1024; const DEFAULT_RENDER_TIMEOUT_MS = 8000; const DEFAULT_MERMAID_RENDER_WIDTH = 1280; const MAX_MERMAID_PNG_BYTES = 8 * 1024 * 1024; @@ -199,6 +201,8 @@ const cachedPngResults = new Map< string, { ok: true; png: Buffer } | { ok: false; error: string } >(); +let cachedResultsBytes = 0; +let cachedPngResultsBytes = 0; export function detectTerminalImageProtocol( env: NodeJS.ProcessEnv = process.env, @@ -508,10 +512,29 @@ function remember( key: string, result: T, ): T { + const resultBytes = estimateResultBytes(result); + if (resultBytes > CACHE_BYTE_LIMIT) { + cachedResults.delete(key); + return result; + } + + const existing = cachedResults.get(key); + if (existing) { + cachedResultsBytes -= estimateResultBytes(existing); + } cachedResults.set(key, result); - if (cachedResults.size > CACHE_LIMIT) { + cachedResultsBytes += resultBytes; + while ( + cachedResults.size > CACHE_LIMIT || + cachedResultsBytes > CACHE_BYTE_LIMIT + ) { const oldest = cachedResults.keys().next().value; - if (oldest) cachedResults.delete(oldest); + if (!oldest) break; + const oldestResult = cachedResults.get(oldest); + if (oldestResult) { + cachedResultsBytes -= estimateResultBytes(oldestResult); + } + cachedResults.delete(oldest); } return result; } @@ -519,14 +542,63 @@ function remember( function rememberPng< T extends { ok: true; png: Buffer } | { ok: false; error: string }, >(key: string, result: T): T { + const resultBytes = estimatePngResultBytes(result); + if (resultBytes > PNG_CACHE_BYTE_LIMIT) { + cachedPngResults.delete(key); + return result; + } + + const existing = cachedPngResults.get(key); + if (existing) { + cachedPngResultsBytes -= estimatePngResultBytes(existing); + } cachedPngResults.set(key, result); - if (cachedPngResults.size > PNG_CACHE_LIMIT) { + cachedPngResultsBytes += resultBytes; + while ( + cachedPngResults.size > PNG_CACHE_LIMIT || + cachedPngResultsBytes > PNG_CACHE_BYTE_LIMIT + ) { const oldest = cachedPngResults.keys().next().value; - if (oldest) cachedPngResults.delete(oldest); + if (!oldest) break; + const oldestResult = cachedPngResults.get(oldest); + if (oldestResult) { + cachedPngResultsBytes -= estimatePngResultBytes(oldestResult); + } + cachedPngResults.delete(oldest); } return result; } +function estimateResultBytes(result: MermaidImageRenderResult): number { + switch (result.kind) { + case 'terminal-image': + return ( + Buffer.byteLength(result.sequence, 'utf8') + + (result.placeholder?.lines.reduce( + (total, line) => total + Buffer.byteLength(line, 'utf8'), + 0, + ) ?? 0) + ); + case 'ansi': + return result.lines.reduce( + (total, line) => total + Buffer.byteLength(line, 'utf8'), + 0, + ); + case 'unavailable': + return Buffer.byteLength(result.reason, 'utf8'); + default: { + const exhaustive: never = result; + return exhaustive; + } + } +} + +function estimatePngResultBytes( + result: { ok: true; png: Buffer } | { ok: false; error: string }, +): number { + return result.ok ? result.png.byteLength : Buffer.byteLength(result.error); +} + function findMmdc(env: NodeJS.ProcessEnv): string | null { const explicit = env['QWEN_CODE_MERMAID_MMD_CLI']; if (explicit && isExecutable(explicit)) return explicit; @@ -572,9 +644,11 @@ function findExecutable( addCandidates(localRendererDir); } for (const dir of (env['PATH'] ?? '').split(path.delimiter).filter(Boolean)) { + const normalizedDir = normalizeExecutableDir(dir); if ( !allowLocalRenderers && - normalizeExecutableDir(dir) === localRendererDir + (normalizedDir === localRendererDir || + normalizedDir.endsWith(`${path.sep}node_modules${path.sep}.bin`)) ) { continue; } From 0e0849610faebc0e49e2844c93d6f3ca71fb6c72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Wed, 6 May 2026 00:54:51 +0800 Subject: [PATCH 21/36] fix(cli): address markdown render review feedback --- docs/users/features/markdown-rendering.md | 6 + packages/cli/src/config/keyBindings.ts | 7 +- packages/cli/src/ui/AppContainer.tsx | 9 +- packages/cli/src/ui/commands/copyCommand.ts | 7 +- .../src/ui/components/BaseTextInput.test.tsx | 11 - .../src/ui/components/MainContent.test.tsx | 60 ++- .../cli/src/ui/components/MainContent.tsx | 80 ++-- .../src/ui/contexts/TerminalOutputContext.tsx | 29 ++ packages/cli/src/ui/keyMatchers.test.ts | 10 +- .../src/ui/utils/InlineMarkdownRenderer.tsx | 13 +- .../cli/src/ui/utils/MarkdownDisplay.test.tsx | 74 ++++ packages/cli/src/ui/utils/MarkdownDisplay.tsx | 43 ++- .../cli/src/ui/utils/MermaidDiagram.test.tsx | 75 ++++ packages/cli/src/ui/utils/MermaidDiagram.tsx | 75 +++- .../cli/src/ui/utils/latexRenderer.test.ts | 26 ++ packages/cli/src/ui/utils/latexRenderer.ts | 4 +- .../src/ui/utils/mermaidImageRenderer.test.ts | 48 +++ .../cli/src/ui/utils/mermaidImageRenderer.ts | 363 +++++++++++++++++- 18 files changed, 854 insertions(+), 86 deletions(-) create mode 100644 packages/cli/src/ui/contexts/TerminalOutputContext.tsx create mode 100644 packages/cli/src/ui/utils/MermaidDiagram.test.tsx create mode 100644 packages/cli/src/ui/utils/latexRenderer.test.ts diff --git a/docs/users/features/markdown-rendering.md b/docs/users/features/markdown-rendering.md index f8e91578fbe..c3a18bfc490 100644 --- a/docs/users/features/markdown-rendering.md +++ b/docs/users/features/markdown-rendering.md @@ -71,6 +71,7 @@ Optional environment variables: | `QWEN_CODE_MERMAID_ALLOW_LOCAL_RENDERERS=1` | Allows project-local renderer binaries under `node_modules/.bin`. | | `QWEN_CODE_MERMAID_RENDER_WIDTH=1200` | Overrides the PNG render width. | | `QWEN_CODE_MERMAID_RENDER_TIMEOUT_MS=10000` | Overrides the external render timeout. | +| `QWEN_CODE_MERMAID_CELL_ASPECT_RATIO=0.5` | Adjusts image row fitting for terminal font cell geometry. | The first image render can be slow, especially when `npx` needs to resolve or download Mermaid CLI. During streaming, Qwen Code shows a bounded text preview @@ -113,6 +114,11 @@ The renderer focuses on common symbols and readable terminal output. It is not a full TeX engine; complex layouts such as matrices, aligned equations, and large nested expressions may be simplified. +Inline `$...$` expressions are intentionally bounded to 1024 characters per +line so malformed or very large generated Markdown cannot stall terminal +rendering. Longer formulas remain visible as source text and can still be +copied from raw mode or the original response. + ### LaTeX Source Copy Use these commands to copy LaTeX source from the last AI response: diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index 96e61fbdb77..f2fdd546d3a 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -175,10 +175,9 @@ export const defaultKeyBindings: KeyBindingConfig = { [Command.SHOW_MORE_LINES]: [{ key: 's', ctrl: true }], [Command.RETRY_LAST]: [{ key: 'y', ctrl: true }], [Command.TOGGLE_COMPACT_MODE]: [{ key: 'o', ctrl: true }], - [Command.TOGGLE_RENDER_MODE]: [ - { key: 'm', meta: true }, - { sequence: 'µ', ctrl: false, meta: false, paste: false }, - ], + // macOS Option+M can arrive as a printable µ in some terminals. The + // KeypressContext normalizes that sequence to meta+m before matching. + [Command.TOGGLE_RENDER_MODE]: [{ key: 'm', meta: true }], // Shell commands [Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }], diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 59bea94be6c..6cb7647fecf 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -136,6 +136,7 @@ import { RenderModeProvider, type RenderMode, } from './contexts/RenderModeContext.js'; +import { TerminalOutputProvider } from './contexts/TerminalOutputContext.js'; import { useAgentViewState } from './contexts/AgentViewContext.js'; import { useBackgroundTaskViewState, @@ -2740,9 +2741,11 @@ export const AppContainer = (props: AppContainerProps) => { > - - - + + + + + diff --git a/packages/cli/src/ui/commands/copyCommand.ts b/packages/cli/src/ui/commands/copyCommand.ts index d279b4475c1..8ae18636076 100644 --- a/packages/cli/src/ui/commands/copyCommand.ts +++ b/packages/cli/src/ui/commands/copyCommand.ts @@ -41,8 +41,11 @@ interface SelectedInlineLatexExpression { label: string; } -const INLINE_MATH_REGEX = - /(? { expect(buffer.handleInput).not.toHaveBeenCalled(); }); - it('does not type the raw macOS Option+M sequence into the buffer', () => { - const buffer = createBuffer(); - - render(); - - const handler = captureKeypressHandler(); - handler(makeKey({ name: '', sequence: 'µ' })); - - expect(buffer.handleInput).not.toHaveBeenCalled(); - }); - it('still passes pasted µ text through to the buffer', () => { const buffer = createBuffer(); diff --git a/packages/cli/src/ui/components/MainContent.test.tsx b/packages/cli/src/ui/components/MainContent.test.tsx index 531d9bc39df..0574f08e041 100644 --- a/packages/cli/src/ui/components/MainContent.test.tsx +++ b/packages/cli/src/ui/components/MainContent.test.tsx @@ -20,6 +20,7 @@ import { OverflowProvider } from '../contexts/OverflowContext.js'; const staticPropsSpy = vi.fn(); const staticItemsSpy = vi.fn(); +const historyItemDisplayPropsSpy = vi.fn(); const appHeaderSpy = vi.fn(); vi.mock('ink', async () => { @@ -47,9 +48,10 @@ vi.mock('./AppHeader.js', () => ({ })); vi.mock('./HistoryItemDisplay.js', () => ({ - HistoryItemDisplay: ({ item }: { item: { id: number } }) => ( - {`HISTORY:${item.id}`} - ), + HistoryItemDisplay: (props: { item: { id: number } }) => { + historyItemDisplayPropsSpy(props); + return {`HISTORY:${props.item.id}`}; + }, })); vi.mock('./ShowMoreLines.js', () => ({ @@ -201,6 +203,7 @@ describe('', () => { it('renders AppHeader inside Static at the top of the static content', () => { staticPropsSpy.mockClear(); staticItemsSpy.mockClear(); + historyItemDisplayPropsSpy.mockClear(); appHeaderSpy.mockClear(); const { lastFrame, rerender } = renderMainContent( @@ -243,4 +246,55 @@ describe('', () => { expect(staticItemsSpy.mock.calls.at(-1)?.[0]).toHaveLength(3); expect(appHeaderSpy).toHaveBeenCalledTimes(2); }); + + it('continues source copy numbering from static history into pending chunks', () => { + historyItemDisplayPropsSpy.mockClear(); + + renderMainContent( + createUIState({ + history: [ + { + id: 1, + type: 'gemini_content', + text: [ + '```mermaid', + 'flowchart TD', + ' A --> B', + '```', + '$$', + '\\alpha', + '$$', + ].join('\n'), + }, + ], + pendingHistoryItems: [ + { + type: 'gemini_content', + text: [ + '```mermaid', + 'sequenceDiagram', + ' A->>B: hi', + '```', + '$$', + '\\beta', + '$$', + ].join('\n'), + }, + ], + }), + ); + + const pendingProps = historyItemDisplayPropsSpy.mock.calls + .map((call) => call[0]) + .find((props) => props.isPending); + + expect(pendingProps?.sourceCopyIndexOffsets).toMatchObject({ + mathBlockCount: 1, + }); + expect( + pendingProps?.sourceCopyIndexOffsets?.codeBlockLanguageCounts.get( + 'mermaid', + ), + ).toBe(1); + }); }); diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index a1a64f5832e..b8578db24b1 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -17,7 +17,6 @@ import { useAppContext } from '../contexts/AppContext.js'; import { AppHeader } from './AppHeader.js'; import { DebugModeNotification } from './DebugModeNotification.js'; import { useCompactMode } from '../contexts/CompactModeContext.js'; -import { useRenderMode } from '../contexts/RenderModeContext.js'; import { countMarkdownSourceBlocks, type MarkdownSourceCopyIndexOffsets, @@ -66,7 +65,6 @@ export const MainContent = () => { const uiState = useUIState(); const uiActions = useUIActions(); const { compactMode } = useCompactMode(); - const { renderMode } = useRenderMode(); const { pendingHistoryItems, terminalWidth, @@ -211,10 +209,41 @@ export const MainContent = () => { prevMergedLengthRef.current = currMLen; }, [compactMode, uiState.history, mergedHistory, uiActions]); - const historyItemsWithSourceCopyOffsets = useMemo(() => { - let runningOffsets = createEmptySourceCopyOffsets(); + const { historyItemsWithSourceCopyOffsets, pendingStartSourceCopyOffsets } = + useMemo(() => { + let runningOffsets = createEmptySourceCopyOffsets(); - return mergedHistory.map((item) => { + const items = mergedHistory.map((item) => { + if (item.type === 'gemini') { + runningOffsets = createEmptySourceCopyOffsets(); + const offsets = cloneSourceCopyOffsets(runningOffsets); + addSourceBlockCounts(runningOffsets, item.text); + return { item, sourceCopyIndexOffsets: offsets }; + } + + if (item.type === 'gemini_content') { + const offsets = cloneSourceCopyOffsets(runningOffsets); + addSourceBlockCounts(runningOffsets, item.text); + return { item, sourceCopyIndexOffsets: offsets }; + } + + if (item.type === 'user') { + runningOffsets = createEmptySourceCopyOffsets(); + } + + return { item, sourceCopyIndexOffsets: undefined }; + }); + + return { + historyItemsWithSourceCopyOffsets: items, + pendingStartSourceCopyOffsets: cloneSourceCopyOffsets(runningOffsets), + }; + }, [mergedHistory]); + + const pendingHistoryItemsWithSourceCopyOffsets = useMemo(() => { + let runningOffsets = cloneSourceCopyOffsets(pendingStartSourceCopyOffsets); + + return pendingHistoryItems.map((item) => { if (item.type === 'gemini') { runningOffsets = createEmptySourceCopyOffsets(); const offsets = cloneSourceCopyOffsets(runningOffsets); @@ -234,12 +263,12 @@ export const MainContent = () => { return { item, sourceCopyIndexOffsets: undefined }; }); - }, [mergedHistory]); + }, [pendingHistoryItems, pendingStartSourceCopyOffsets]); return ( <> , , @@ -267,23 +296,26 @@ export const MainContent = () => { - {pendingHistoryItems.map((item, i) => ( - - ))} + {pendingHistoryItemsWithSourceCopyOffsets.map( + ({ item, sourceCopyIndexOffsets }, i) => ( + + ), + )} diff --git a/packages/cli/src/ui/contexts/TerminalOutputContext.tsx b/packages/cli/src/ui/contexts/TerminalOutputContext.tsx new file mode 100644 index 00000000000..076ce19485c --- /dev/null +++ b/packages/cli/src/ui/contexts/TerminalOutputContext.tsx @@ -0,0 +1,29 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { FC, ReactNode } from 'react'; +import { createContext, useContext } from 'react'; + +export type WriteTerminalRaw = (data: string) => void; + +const defaultWriteRaw: WriteTerminalRaw = (data) => { + process.stdout.write(data); +}; + +const TerminalOutputContext = createContext(defaultWriteRaw); + +export const TerminalOutputProvider: FC<{ + value: WriteTerminalRaw; + children: ReactNode; +}> = ({ value, children }) => ( + + {children} + +); + +export function useTerminalOutput(): WriteTerminalRaw { + return useContext(TerminalOutputContext); +} diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts index 0a2f52a7767..8c768385131 100644 --- a/packages/cli/src/ui/keyMatchers.test.ts +++ b/packages/cli/src/ui/keyMatchers.test.ts @@ -61,9 +61,7 @@ describe('keyMatchers', () => { [Command.SHOW_MORE_LINES]: (key: Key) => key.ctrl && key.name === 's', [Command.RETRY_LAST]: (key: Key) => key.ctrl && key.name === 'y', [Command.TOGGLE_COMPACT_MODE]: (key: Key) => key.ctrl && key.name === 'o', - [Command.TOGGLE_RENDER_MODE]: (key: Key) => - (key.meta && key.name === 'm') || - (key.sequence === 'µ' && !key.ctrl && !key.meta && !key.paste), + [Command.TOGGLE_RENDER_MODE]: (key: Key) => key.meta && key.name === 'm', [Command.REVERSE_SEARCH]: (key: Key) => key.ctrl && key.name === 'r', [Command.SUBMIT_REVERSE_SEARCH]: (key: Key) => key.name === 'return' && !key.ctrl, @@ -291,13 +289,11 @@ describe('keyMatchers', () => { }, { command: Command.TOGGLE_RENDER_MODE, - positive: [ - createKey('m', { meta: true }), - createKey('', { sequence: 'µ' }), - ], + positive: [createKey('m', { meta: true })], negative: [ createKey('m'), createKey('m', { ctrl: true }), + createKey('', { sequence: 'µ' }), createKey('', { sequence: 'µ', paste: true }), ], }, diff --git a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx index 345ef49d1b2..807a17fd836 100644 --- a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx +++ b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx @@ -19,8 +19,11 @@ 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_MATH_PATTERN = - /(? = ({ const nodes: React.ReactNode[] = []; let lastIndex = 0; const inlineRegex = enableInlineMath - ? /(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|(?.*?<\/u>|https?:\/\/\S+)/g + ? new RegExp( + String.raw`(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|` + + String.raw`\`+.+?\`+|(?.*?<\/u>|https?:\/\/\S+)`, + 'g', + ) : /(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|.*?<\/u>|https?:\/\/\S+)/g; let match; diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx index 09f57204782..16601d73d08 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx @@ -500,6 +500,9 @@ flowchart LR $$ \\alpha + \\beta $$ + +- [x] Done +> Important `.replace(/\n/g, eol); const { lastFrame } = renderWithProviders( Important'); expect(output).not.toContain('┌'); expect(output).not.toContain('x²'); expect(output).not.toContain('α + β'); + expect(output).not.toContain('✓ Done'); + expect(output).not.toContain('│ Important'); }); it('applies source copy offsets from previous assistant chunks', () => { @@ -693,6 +700,73 @@ pie title Pets expect(piePreview.lines.join('\n')).toContain('Cats'); }); + it('renders additional basic mermaid diagram families as readable previews', () => { + const statePreview = renderMermaidVisual( + ` +stateDiagram-v2 + [*] --> Idle + Idle --> Running : start +`, + 80, + ); + const ganttPreview = renderMermaidVisual( + ` +gantt + title Release + section Build + Bundle :done, 2026-01-01, 1d +`, + 80, + ); + const journeyPreview = renderMermaidVisual( + ` +journey + title Signup + section Start + Open app: 5: User +`, + 80, + ); + const mindmapPreview = renderMermaidVisual( + ` +mindmap + Root + Child +`, + 80, + ); + const gitPreview = renderMermaidVisual( + ` +gitGraph + commit + branch feature +`, + 80, + ); + const requirementPreview = renderMermaidVisual( + ` +requirementDiagram + requirement login { + id: 1 + } +`, + 80, + ); + + expect(statePreview.title).toBe('Mermaid state diagram'); + expect(statePreview.lines.join('\n')).toContain('Idle → Running'); + expect(ganttPreview.title).toBe('Mermaid gantt chart'); + expect(ganttPreview.lines.join('\n')).toContain('Bundle'); + expect(journeyPreview.title).toBe('Mermaid journey diagram'); + expect(journeyPreview.lines.join('\n')).toContain('Open app'); + expect(mindmapPreview.title).toBe('Mermaid mindmap'); + expect(mindmapPreview.lines.join('\n')).toContain('Child'); + expect(gitPreview.title).toBe('Mermaid git graph'); + expect(gitPreview.lines.join('\n')).toContain('branch feature'); + expect(requirementPreview.title).toBe('Mermaid requirement diagram'); + expect(requirementPreview.lines.join('\n')).toContain('id: 1'); + }); + it('falls back to mermaid source for unsupported diagrams', () => { const text = ` \`\`\`mermaid diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.tsx index d08b0f89640..08ff893a7da 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.tsx @@ -204,6 +204,8 @@ const MarkdownDisplayInternal: React.FC = ({ content={mathBlockContent} sourceCopyCommand={`/copy latex ${currentMathBlockIndex}`} contentWidth={contentWidth} + isPending={isPending} + availableTerminalHeight={availableTerminalHeight} />, ); inMathBlock = false; @@ -331,7 +333,7 @@ const MarkdownDisplayInternal: React.FC = ({ --- , ); - } else if (blockquoteMatch) { + } else if (blockquoteMatch && renderVisualBlocks) { addContentBlock( = ({ content={mathBlockContent} sourceCopyCommand={`/copy latex ${currentMathBlockIndex}`} contentWidth={contentWidth} + isPending={isPending} + availableTerminalHeight={availableTerminalHeight} />, ); } @@ -646,13 +650,46 @@ interface RenderMathBlockProps { content: string[]; sourceCopyCommand: string; contentWidth: number; + isPending: boolean; + availableTerminalHeight?: number; } const RenderMathBlockInternal: React.FC = ({ content, sourceCopyCommand, contentWidth, + isPending, + availableTerminalHeight, }) => { + const RESERVED_LINES = 3; + if (isPending && availableTerminalHeight !== undefined) { + const maxPreviewLines = Math.max( + 0, + availableTerminalHeight - RESERVED_LINES, + ); + if (content.length > maxPreviewLines) { + const previewLines = content.slice(0, maxPreviewLines); + return ( + + + LaTeX block · source: {sourceCopyCommand} + + {previewLines.map((line, index) => ( + + {line || ' '} + + ))} + ... generating more ... + + ); + } + } + const rendered = renderInlineLatex(content.join(' ')); return ( = ({ enableInlineMath = true, }) => { const taskMatch = itemText.match(/^\[([ xX])\]\s+(.*)$/); - const isTaskItem = taskMatch !== null; + const isTaskItem = taskMatch !== null && enableInlineMath; const isTaskChecked = taskMatch?.[1]?.toLowerCase() === 'x'; - const effectiveItemText = taskMatch?.[2] ?? itemText; + const effectiveItemText = isTaskItem ? taskMatch[2] : itemText; const prefix = isTaskItem ? `${isTaskChecked ? '✓' : '○'} ` : type === 'ol' diff --git a/packages/cli/src/ui/utils/MermaidDiagram.test.tsx b/packages/cli/src/ui/utils/MermaidDiagram.test.tsx new file mode 100644 index 00000000000..de9818f7902 --- /dev/null +++ b/packages/cli/src/ui/utils/MermaidDiagram.test.tsx @@ -0,0 +1,75 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render } from 'ink-testing-library'; +import { MermaidDiagram } from './MermaidDiagram.js'; +import { TerminalOutputProvider } from '../contexts/TerminalOutputContext.js'; +import { renderMermaidImageAsync } from './mermaidImageRenderer.js'; + +vi.mock('./mermaidImageRenderer.js', async () => { + const actual = await vi.importActual< + typeof import('./mermaidImageRenderer.js') + >('./mermaidImageRenderer.js'); + return { + ...actual, + renderMermaidImageAsync: vi.fn(), + }; +}); + +const mockedRenderMermaidImageAsync = vi.mocked(renderMermaidImageAsync); + +describe('MermaidDiagram', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('renders the wireframe immediately and writes Kitty images through raw output', async () => { + const writeRaw = vi.fn(); + mockedRenderMermaidImageAsync.mockResolvedValueOnce({ + kind: 'terminal-image', + title: 'Mermaid diagram image (kitty)', + sequence: '\x1b_Gpayload\x1b\\', + rows: 2, + protocol: 'kitty', + placeholder: { + color: '#00002a', + imageId: 42, + lines: ['placeholder'], + }, + }); + + const { lastFrame } = render( + + B[End]'} + sourceCopyCommand="/copy mermaid 1" + contentWidth={80} + isPending={false} + availableTerminalHeight={20} + /> + , + ); + + expect(lastFrame()).toContain('Mermaid flowchart (TD)'); + await vi.waitFor(() => { + expect(writeRaw).toHaveBeenCalledWith('\x1b_Gpayload\x1b\\'); + }); + }); + + it('does not start image rendering while the Mermaid block is pending', () => { + render( + B[End]'} + sourceCopyCommand="/copy mermaid 1" + contentWidth={80} + isPending={true} + />, + ); + + expect(mockedRenderMermaidImageAsync).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/ui/utils/MermaidDiagram.tsx b/packages/cli/src/ui/utils/MermaidDiagram.tsx index 77cd11f29a0..720613157e9 100644 --- a/packages/cli/src/ui/utils/MermaidDiagram.tsx +++ b/packages/cli/src/ui/utils/MermaidDiagram.tsx @@ -5,11 +5,15 @@ */ import React from 'react'; -import { Box, Text, useStdout } from 'ink'; +import { Box, Text } from 'ink'; import { theme } from '../semantic-colors.js'; import { MaxSizedBox } from '../components/shared/MaxSizedBox.js'; import { renderMermaidVisual } from './mermaidVisualRenderer.js'; -import { renderMermaidImageSync } from './mermaidImageRenderer.js'; +import { + renderMermaidImageAsync, + type MermaidImageRenderResult, +} from './mermaidImageRenderer.js'; +import { useTerminalOutput } from '../contexts/TerminalOutputContext.js'; interface MermaidDiagramProps { source: string; @@ -28,24 +32,39 @@ const MermaidDiagramInternal: React.FC = ({ isPending, availableTerminalHeight, }) => { - const { write } = useStdout(); - const preparedKittySequence = React.useRef(null); + const writeRaw = useTerminalOutput(); + const preparedTerminalImageSequence = React.useRef(null); + const [image, setImage] = React.useState( + null, + ); const innerWidth = Math.max(8, contentWidth - MERMAID_PADDING); const visual = React.useMemo( () => renderMermaidVisual(source, innerWidth), [source, innerWidth], ); - const image = React.useMemo( - () => - isPending - ? null - : renderMermaidImageSync({ - source, - contentWidth: innerWidth, - availableTerminalHeight, - }), - [availableTerminalHeight, innerWidth, isPending, source], - ); + + React.useEffect(() => { + if (isPending) { + setImage(null); + return; + } + + let cancelled = false; + setImage(null); + void renderMermaidImageAsync({ + source, + contentWidth: innerWidth, + availableTerminalHeight, + }).then((result) => { + if (!cancelled) { + setImage(result); + } + }); + + return () => { + cancelled = true; + }; + }, [availableTerminalHeight, innerWidth, isPending, source]); const kittySequence = image?.kind === 'terminal-image' && @@ -55,12 +74,28 @@ const MermaidDiagramInternal: React.FC = ({ : null; React.useEffect(() => { - if (!kittySequence || preparedKittySequence.current === kittySequence) { + if ( + !kittySequence || + preparedTerminalImageSequence.current === kittySequence + ) { + return; + } + preparedTerminalImageSequence.current = kittySequence; + writeRaw(kittySequence); + }, [kittySequence, writeRaw]); + + React.useEffect(() => { + if ( + !image || + image.kind !== 'terminal-image' || + image.protocol !== 'iterm2' || + preparedTerminalImageSequence.current === image.sequence + ) { return; } - preparedKittySequence.current = kittySequence; - write(kittySequence); - }, [kittySequence, write]); + preparedTerminalImageSequence.current = image.sequence; + writeRaw(image.sequence); + }, [image, writeRaw]); const titleWithSourceHint = (title: string) => `${title} · source: ${sourceCopyCommand}`; @@ -109,7 +144,7 @@ const MermaidDiagramInternal: React.FC = ({ {titleWithSourceHint(visual.title)} - {image.sequence} + ); diff --git a/packages/cli/src/ui/utils/latexRenderer.test.ts b/packages/cli/src/ui/utils/latexRenderer.test.ts new file mode 100644 index 00000000000..8a350446f24 --- /dev/null +++ b/packages/cli/src/ui/utils/latexRenderer.test.ts @@ -0,0 +1,26 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { renderInlineLatex } from './latexRenderer.js'; + +describe('latexRenderer', () => { + it('renders common fractions, roots, scripts, and symbols', () => { + expect(renderInlineLatex(String.raw`\frac{a}{b} + \sqrt{x^2}`)).toBe( + 'a/b + √(x²)', + ); + expect(renderInlineLatex(String.raw`\sum_{i=1}^{n} x_i`)).toBe('Σᵢ₌₁ⁿ xᵢ'); + }); + + it('preserves unknown commands instead of stripping command markers', () => { + expect(renderInlineLatex(String.raw`\mathcal{O}(n)`)).toBe( + String.raw`\mathcal{O}(n)`, + ); + expect(renderInlineLatex(String.raw`\mathbb{E}[X]`)).toBe( + String.raw`\mathbb{E}[X]`, + ); + }); +}); diff --git a/packages/cli/src/ui/utils/latexRenderer.ts b/packages/cli/src/ui/utils/latexRenderer.ts index 39c7143d133..7f403f139f0 100644 --- a/packages/cli/src/ui/utils/latexRenderer.ts +++ b/packages/cli/src/ui/utils/latexRenderer.ts @@ -10,6 +10,7 @@ const COMMAND_REPLACEMENTS: Record = { '\\gamma': 'γ', '\\delta': 'δ', '\\epsilon': 'ε', + '\\varepsilon': 'ε', '\\theta': 'θ', '\\lambda': 'λ', '\\mu': 'μ', @@ -18,6 +19,7 @@ const COMMAND_REPLACEMENTS: Record = { '\\sigma': 'σ', '\\tau': 'τ', '\\phi': 'φ', + '\\varphi': 'φ', '\\omega': 'ω', '\\Gamma': 'Γ', '\\Delta': 'Δ', @@ -41,6 +43,7 @@ const COMMAND_REPLACEMENTS: Record = { '\\neq': '≠', '\\approx': '≈', '\\rightarrow': '→', + '\\to': '→', '\\leftarrow': '←', '\\Rightarrow': '⇒', '\\Leftarrow': '⇐', @@ -138,7 +141,6 @@ export function renderInlineLatex(input: string): string { return output .replace(/\\left|\\right/g, '') .replace(/\\,/g, ' ') - .replace(/\\([A-Za-z]+)/g, '$1') .replace(/\s+/g, ' ') .trim(); } diff --git a/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts b/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts index bc6d1243ff8..6762f137fe2 100644 --- a/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts +++ b/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts @@ -15,6 +15,7 @@ import { encodeKittyImage, encodeKittyVirtualImage, readPngSize, + renderMermaidImageAsync, renderMermaidImageSync, } from './mermaidImageRenderer.js'; @@ -188,6 +189,13 @@ describe('mermaid image renderer', () => { ]); }); + it('clamps Kitty placeholder width to the available diacritic alphabet', () => { + const placeholder = buildKittyPlaceholder(42, 200, 1); + + expect(placeholder.lines[0]).not.toContain('undefined'); + expect(Array.from(placeholder.lines[0] ?? '').length).toBeLessThan(200 * 3); + }); + it('renders Mermaid through mmdc when terminal images are available', () => { const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-mmdc-')); tempDirs.push(binDir); @@ -211,6 +219,46 @@ describe('mermaid image renderer', () => { ); }); + it('renders Mermaid through mmdc asynchronously for interactive UI callers', async () => { + const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-mmdc-')); + tempDirs.push(binDir); + createFakeMmdc(binDir); + + const result = await renderMermaidImageAsync({ + source: 'flowchart TD\n A[Start] --> B[End async]', + contentWidth: 80, + availableTerminalHeight: 20, + env: { + PATH: `${binDir}${path.delimiter}${process.env['PATH'] ?? ''}`, + QWEN_CODE_MERMAID_IMAGE_RENDERING: '1', + QWEN_CODE_MERMAID_IMAGE_PROTOCOL: 'iterm2', + }, + }); + + expect(result.kind).toBe('terminal-image'); + expect(result.kind === 'terminal-image' && result.protocol).toBe('iterm2'); + }); + + it('honors the configured terminal cell aspect ratio when fitting images', () => { + const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-mmdc-')); + tempDirs.push(binDir); + createFakeMmdc(binDir); + + const result = renderMermaidImageSync({ + source: 'flowchart TD\n A[Start] --> B[Aspect]', + contentWidth: 80, + availableTerminalHeight: 60, + env: { + PATH: `${binDir}${path.delimiter}${process.env['PATH'] ?? ''}`, + QWEN_CODE_MERMAID_IMAGE_RENDERING: '1', + QWEN_CODE_MERMAID_IMAGE_PROTOCOL: 'iterm2', + QWEN_CODE_MERMAID_CELL_ASPECT_RATIO: '1', + }, + }); + + expect(result.kind === 'terminal-image' && result.rows).toBe(60); + }); + it('falls back to the default render timeout when configured timeout is invalid', () => { const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-mmdc-')); tempDirs.push(binDir); diff --git a/packages/cli/src/ui/utils/mermaidImageRenderer.ts b/packages/cli/src/ui/utils/mermaidImageRenderer.ts index 0588566c9db..0ac6f44de20 100644 --- a/packages/cli/src/ui/utils/mermaidImageRenderer.ts +++ b/packages/cli/src/ui/utils/mermaidImageRenderer.ts @@ -8,7 +8,7 @@ import crypto from 'node:crypto'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { spawnSync } from 'node:child_process'; +import { spawn, spawnSync } from 'node:child_process'; export type TerminalImageProtocol = 'kitty' | 'iterm2'; @@ -299,9 +299,13 @@ export function buildKittyPlaceholder( rows: number, ): KittyImagePlaceholder { const clampedRows = Math.min(rows, KITTY_PLACEHOLDER_DIACRITICS.length); + const clampedWidth = Math.min( + widthCells, + KITTY_PLACEHOLDER_DIACRITICS.length, + ); const lines = Array.from({ length: clampedRows }, (_, row) => { const rowDiacritic = KITTY_PLACEHOLDER_DIACRITICS[row]; - const cells = Array.from({ length: widthCells }, (_, column) => { + const cells = Array.from({ length: clampedWidth }, (_, column) => { const columnDiacritic = KITTY_PLACEHOLDER_DIACRITICS[column]; return `${KITTY_PLACEHOLDER}${rowDiacritic}${columnDiacritic}`; }); @@ -371,12 +375,13 @@ export function renderMermaidImageSync({ availableTerminalHeight, protocol ?? `chafa:${chafa}`, mmdc, + env, ); - const cached = cachedResults.get(cacheKey); + const cached = getResultCache(cacheKey); if (cached) return cached; const pngCacheKey = createPngCacheKey(source, mmdc, env); - const cachedPng = cachedPngResults.get(pngCacheKey); + const cachedPng = getPngCache(pngCacheKey); const rendered = cachedPng ?? rememberPng(pngCacheKey, renderPngWithMmdc(source, mmdc, env)); if (!rendered.ok) { @@ -398,6 +403,7 @@ export function renderMermaidImageSync({ pngSize, contentWidth, availableTerminalHeight, + env, ); if (protocol) { @@ -456,6 +462,139 @@ export function renderMermaidImageSync({ }); } +export async function renderMermaidImageAsync({ + source, + contentWidth, + availableTerminalHeight, + env = process.env, +}: MermaidImageRenderOptions): Promise { + const imageRendering = env['QWEN_CODE_MERMAID_IMAGE_RENDERING']; + if ( + imageRendering !== '1' && + imageRendering?.toLowerCase() !== 'on' && + imageRendering?.toLowerCase() !== 'true' + ) { + return { + kind: 'unavailable', + reason: + 'Mermaid image rendering is disabled by default. Set QWEN_CODE_MERMAID_IMAGE_RENDERING=1 to enable external renderers.', + showReason: false, + }; + } + + const protocol = detectTerminalImageProtocol(env); + const chafa = protocol ? null : findExecutable('chafa', env); + if (!protocol && !chafa) { + return { + kind: 'unavailable', + reason: + 'No supported terminal image protocol or chafa renderer was detected.', + }; + } + + const mmdc = findMmdc(env); + if (!mmdc) { + return { + kind: 'unavailable', + reason: + 'Mermaid CLI (mmdc) was not found. Install @mermaid-js/mermaid-cli, set QWEN_CODE_MERMAID_MMD_CLI, or set QWEN_CODE_MERMAID_ALLOW_NPX=1.', + }; + } + + const cacheKey = createCacheKey( + source, + contentWidth, + availableTerminalHeight, + protocol ?? `chafa:${chafa}`, + mmdc, + env, + ); + const cached = getResultCache(cacheKey); + if (cached) return cached; + + const pngCacheKey = createPngCacheKey(source, mmdc, env); + const cachedPng = getPngCache(pngCacheKey); + const rendered = + cachedPng ?? + rememberPng(pngCacheKey, await renderPngWithMmdcAsync(source, mmdc, env)); + if (!rendered.ok) { + return remember(cacheKey, { + kind: 'unavailable', + reason: rendered.error, + }); + } + + const pngSize = readPngSize(rendered.png); + if (!pngSize) { + return remember(cacheKey, { + kind: 'unavailable', + reason: 'Mermaid CLI did not produce a valid PNG.', + }); + } + + const imageShape = fitImageToTerminal( + pngSize, + contentWidth, + availableTerminalHeight, + env, + ); + + if (protocol) { + const imageId = + protocol === 'kitty' + ? createKittyImageId(rendered.png, imageShape) + : undefined; + const sequence = + protocol === 'kitty' + ? encodeKittyVirtualImage( + rendered.png, + imageId!, + imageShape.widthCells, + imageShape.rows, + ) + : encodeITerm2InlineImage( + rendered.png, + imageShape.widthCells, + imageShape.rows, + ); + return remember(cacheKey, { + kind: 'terminal-image', + title: `Mermaid diagram image (${protocol})`, + sequence, + rows: imageShape.rows, + protocol, + placeholder: + protocol === 'kitty' + ? buildKittyPlaceholder( + imageId!, + imageShape.widthCells, + imageShape.rows, + ) + : undefined, + }); + } + + const ansi = await renderPngWithChafaAsync( + rendered.png, + imageShape.widthCells, + imageShape.rows, + chafa!, + env, + ); + if (!ansi.ok) { + return remember(cacheKey, { + kind: 'unavailable', + reason: ansi.error, + }); + } + + return remember(cacheKey, { + kind: 'ansi', + title: 'Mermaid diagram image (ANSI)', + lines: ansi.output.split(/\r?\n/).filter((line) => line.length > 0), + }); +} + function createKittyImageId( png: Buffer, imageShape: { widthCells: number; rows: number }, @@ -472,6 +611,26 @@ function createKittyImageId( return id === 0 ? 1 : id; } +function getResultCache(key: string): MermaidImageRenderResult | undefined { + const cached = cachedResults.get(key); + if (cached) { + cachedResults.delete(key); + cachedResults.set(key, cached); + } + return cached; +} + +function getPngCache( + key: string, +): { ok: true; png: Buffer } | { ok: false; error: string } | undefined { + const cached = cachedPngResults.get(key); + if (cached) { + cachedPngResults.delete(key); + cachedPngResults.set(key, cached); + } + return cached; +} + function createPngCacheKey( source: string, mmdc: string, @@ -493,6 +652,7 @@ function createCacheKey( availableTerminalHeight: number | undefined, renderer: string, mmdc: string, + env: NodeJS.ProcessEnv, ): string { return crypto .createHash('sha256') @@ -505,6 +665,8 @@ function createCacheKey( .update(renderer) .update('\0') .update(mmdc) + .update('\0') + .update(String(getMermaidCellAspectRatio(env))) .digest('hex'); } @@ -741,6 +903,75 @@ function renderPngWithMmdc( } } +async function renderPngWithMmdcAsync( + source: string, + mmdc: string, + env: NodeJS.ProcessEnv, +): Promise<{ ok: true; png: Buffer } | { ok: false; error: string }> { + const tempDir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'qwen-mermaid-'), + ); + const inputPath = path.join(tempDir, 'diagram.mmd'); + const outputPath = path.join(tempDir, 'diagram.png'); + const renderWidth = getMermaidRenderWidth(env); + + try { + await fs.promises.writeFile(inputPath, source, 'utf8'); + const mmdcArgs = [ + '-i', + inputPath, + '-o', + outputPath, + '-b', + 'transparent', + '-w', + String(renderWidth), + ]; + const command = + mmdc === NPX_MERMAID_CLI ? findExecutable('npx', env)! : mmdc; + const args = + mmdc === NPX_MERMAID_CLI + ? ['-y', '@mermaid-js/mermaid-cli@11.12.0', ...mmdcArgs] + : mmdcArgs; + const result = await runCommand(command, args, { + env: { + ...process.env, + ...env, + }, + shell: shouldRunThroughShell(command), + timeout: getMermaidRenderTimeout(env), + }); + + if (result.error) { + return { ok: false, error: result.error }; + } + if (result.status !== 0) { + const stderr = result.stderr.trim(); + return { + ok: false, + error: stderr || `Mermaid CLI exited with status ${result.status}.`, + }; + } + + let outputSize: number; + try { + outputSize = (await fs.promises.stat(outputPath)).size; + } catch { + return { ok: false, error: 'Mermaid CLI did not write an output file.' }; + } + if (outputSize > MAX_MERMAID_PNG_BYTES) { + return { + ok: false, + error: `Mermaid CLI output exceeded ${MAX_MERMAID_PNG_BYTES} bytes.`, + }; + } + + return { ok: true, png: await fs.promises.readFile(outputPath) }; + } finally { + await fs.promises.rm(tempDir, { recursive: true, force: true }); + } +} + function shouldRunThroughShell(command: string): boolean { return process.platform === 'win32' && /\.(?:cmd|bat)$/i.test(command); } @@ -761,6 +992,16 @@ function getMermaidRenderTimeout(env: NodeJS.ProcessEnv): number { return DEFAULT_RENDER_TIMEOUT_MS; } +function getMermaidCellAspectRatio(env: NodeJS.ProcessEnv): number { + const configuredAspectRatio = Number( + env['QWEN_CODE_MERMAID_CELL_ASPECT_RATIO'], + ); + if (Number.isFinite(configuredAspectRatio) && configuredAspectRatio > 0) { + return Math.max(0.2, Math.min(configuredAspectRatio, 2)); + } + return 0.5; +} + function renderPngWithChafa( png: Buffer, widthCells: number, @@ -809,13 +1050,125 @@ function renderPngWithChafa( } } +async function renderPngWithChafaAsync( + png: Buffer, + widthCells: number, + rows: number, + chafa: string, + env: NodeJS.ProcessEnv, +): Promise<{ ok: true; output: string } | { ok: false; error: string }> { + const tempDir = await fs.promises.mkdtemp( + path.join(os.tmpdir(), 'qwen-mermaid-'), + ); + const imagePath = path.join(tempDir, 'diagram.png'); + + try { + await fs.promises.writeFile(imagePath, png); + const result = await runCommand( + chafa, + [ + '--animate=off', + '--format=symbols', + '--symbols=block', + `--size=${widthCells}x${rows}`, + imagePath, + ], + { + env: { + ...process.env, + ...env, + }, + timeout: 2000, + }, + ); + + if (result.error) { + return { ok: false, error: result.error }; + } + if (result.status !== 0) { + return { + ok: false, + error: + result.stderr.trim() || `chafa exited with status ${result.status}.`, + }; + } + + return { ok: true, output: result.stdout }; + } finally { + await fs.promises.rm(tempDir, { recursive: true, force: true }); + } +} + +interface CommandResult { + status: number | null; + stdout: string; + stderr: string; + error?: string; +} + +function runCommand( + command: string, + args: string[], + options: { + env: NodeJS.ProcessEnv; + shell?: boolean; + timeout: number; + }, +): Promise { + return new Promise((resolve) => { + const child = spawn(command, args, { + env: options.env, + shell: options.shell, + stdio: ['ignore', 'pipe', 'pipe'], + }); + let stdout = ''; + let stderr = ''; + let settled = false; + const timer = setTimeout(() => { + if (settled) return; + settled = true; + child.kill(); + resolve({ + status: null, + stdout, + stderr, + error: `Command timed out after ${options.timeout}ms.`, + }); + }, options.timeout); + + child.stdout?.setEncoding('utf8'); + child.stderr?.setEncoding('utf8'); + child.stdout?.on('data', (chunk: string) => { + stdout += chunk; + }); + child.stderr?.on('data', (chunk: string) => { + stderr += chunk; + }); + child.on('error', (error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ status: null, stdout, stderr, error: error.message }); + }); + child.on('close', (status) => { + if (settled) return; + settled = true; + clearTimeout(timer); + resolve({ status, stdout, stderr }); + }); + }); +} + function fitImageToTerminal( size: PngSize, contentWidth: number, availableTerminalHeight: number | undefined, + env: NodeJS.ProcessEnv = process.env, ): { widthCells: number; rows: number } { const widthCells = Math.max(16, Math.min(contentWidth, 120)); - const naturalRows = Math.ceil((size.height / size.width) * widthCells * 0.5); + const naturalRows = Math.ceil( + (size.height / size.width) * widthCells * getMermaidCellAspectRatio(env), + ); const maxRows = Math.max(4, Math.min(availableTerminalHeight ?? 32, 60)); return { From b28c07fc6fd48701652198811876833d403f9389 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Wed, 6 May 2026 11:13:07 +0800 Subject: [PATCH 22/36] fix(cli): address markdown render review comments --- packages/cli/src/ui/AppContainer.test.tsx | 53 +++++++++- packages/cli/src/ui/AppContainer.tsx | 18 +++- .../cli/src/ui/components/MainContent.tsx | 5 + packages/cli/src/ui/utils/MarkdownDisplay.tsx | 12 +-- .../cli/src/ui/utils/MermaidDiagram.test.tsx | 57 ++++++++++ packages/cli/src/ui/utils/MermaidDiagram.tsx | 49 +++++++-- .../src/ui/utils/mermaidImageRenderer.test.ts | 100 ++++++++++++++++++ .../cli/src/ui/utils/mermaidImageRenderer.ts | 28 ++++- 8 files changed, 297 insertions(+), 25 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index b7d7bfd22f7..8183ba2c9d3 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -34,7 +34,7 @@ import { type RenderMode, } from './contexts/RenderModeContext.js'; import { type HistoryItem, ToolCallStatus } from './types.js'; -import { useContext } from 'react'; +import { act, useContext } from 'react'; import { Box, measureElement } from 'ink'; // Mock useStdout to capture terminal title writes @@ -130,6 +130,8 @@ import { useTextBuffer } from './components/shared/text-buffer.js'; import { useLogger } from './hooks/useLogger.js'; import { useLoadingIndicator } from './hooks/useLoadingIndicator.js'; import { useTerminalSize } from './hooks/useTerminalSize.js'; +import { useKeypress, type Key } from './hooks/useKeypress.js'; +import { Command, keyMatchers } from './keyMatchers.js'; import { ShellExecutionService } from '@qwen-code/qwen-code-core'; describe('AppContainer State Management', () => { @@ -158,6 +160,7 @@ describe('AppContainer State Management', () => { const mockedUseLogger = useLogger as Mock; const mockedUseLoadingIndicator = useLoadingIndicator as Mock; const mockedUseTerminalSize = useTerminalSize as Mock; + const mockedUseKeypress = useKeypress as Mock; beforeEach(() => { vi.clearAllMocks(); @@ -1009,6 +1012,54 @@ describe('AppContainer State Management', () => { expect(capturedRenderMode).toBe('render'); }); + + it('toggles Markdown render mode from the global render shortcut', async () => { + render( + , + ); + + expect(capturedRenderMode).toBe('render'); + await act(async () => { + await Promise.resolve(); + }); + const handleKeypress = mockedUseKeypress.mock.calls[0]?.[0] as + | ((key: Key) => void) + | undefined; + expect(handleKeypress).toBeDefined(); + + const optionMKey: Key = { + name: 'm', + ctrl: false, + meta: true, + shift: false, + paste: false, + sequence: 'µ', + }; + + expect(keyMatchers[Command.TOGGLE_RENDER_MODE](optionMKey)).toBe(true); + + await act(async () => { + handleKeypress!(optionMKey); + await Promise.resolve(); + }); + expect(capturedUIState.constrainHeight).toBe(true); + + const updatedHandleKeypress = mockedUseKeypress.mock.calls.at(-1)?.[0] as + | ((key: Key) => void) + | undefined; + expect(updatedHandleKeypress).toBeDefined(); + + await act(async () => { + updatedHandleKeypress!(optionMKey); + await Promise.resolve(); + }); + expect(keyMatchers[Command.TOGGLE_RENDER_MODE](optionMKey)).toBe(true); + }); }); describe('Version Handling', () => { diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 6cb7647fecf..fb63d45c74f 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1576,7 +1576,13 @@ export const AppContainer = (props: AppContainerProps) => { const [renderMode, setRenderMode] = useState( configuredRenderMode === 'raw' ? 'raw' : 'render', ); + const renderModeConfigMountedRef = useRef(false); useEffect(() => { + if (!renderModeConfigMountedRef.current) { + renderModeConfigMountedRef.current = true; + return; + } + setRenderMode(configuredRenderMode === 'raw' ? 'raw' : 'render'); }, [configuredRenderMode]); const renderModeMountedRef = useRef(false); @@ -2178,7 +2184,14 @@ export const AppContainer = (props: AppContainerProps) => { setConstrainHeight(true); } - if (keyMatchers[Command.TOGGLE_TOOL_DESCRIPTIONS](key)) { + const isRenderModeToggle = + keyMatchers[Command.TOGGLE_RENDER_MODE](key) || + (key.name === 'm' && key.meta && !key.ctrl && !key.paste) || + (key.sequence === 'µ' && !key.ctrl && !key.paste); + + if (isRenderModeToggle) { + setRenderMode((current) => (current === 'render' ? 'raw' : 'render')); + } else if (keyMatchers[Command.TOGGLE_TOOL_DESCRIPTIONS](key)) { const newValue = !showToolDescriptions; setShowToolDescriptions(newValue); @@ -2206,8 +2219,6 @@ export const AppContainer = (props: AppContainerProps) => { setCompactMode(newValue); void settings.setValue(SettingScope.User, 'ui.compactMode', newValue); refreshStatic(); - } else if (keyMatchers[Command.TOGGLE_RENDER_MODE](key)) { - setRenderMode(renderMode === 'render' ? 'raw' : 'render'); } }, [ @@ -2243,7 +2254,6 @@ export const AppContainer = (props: AppContainerProps) => { isAuthenticating, compactMode, setCompactMode, - renderMode, setRenderMode, refreshStatic, handleDoubleEscRewind, diff --git a/packages/cli/src/ui/components/MainContent.tsx b/packages/cli/src/ui/components/MainContent.tsx index b8578db24b1..c8f773f6bc6 100644 --- a/packages/cli/src/ui/components/MainContent.tsx +++ b/packages/cli/src/ui/components/MainContent.tsx @@ -267,6 +267,11 @@ export const MainContent = () => { return ( <> + {/* + renderMode is intentionally omitted here. AppContainer calls + refreshStatic() when renderMode changes, which updates + historyRemountKey; including both would remount Static twice. + */} = ({ marker={marker} leadingWhitespace={leadingWhitespace} textColor={textColor} - enableInlineMath={renderVisualBlocks} + renderVisualBlocks={renderVisualBlocks} />, ); } else if (olMatch) { @@ -431,7 +431,7 @@ const MarkdownDisplayInternal: React.FC = ({ marker={marker} leadingWhitespace={leadingWhitespace} textColor={textColor} - enableInlineMath={renderVisualBlocks} + renderVisualBlocks={renderVisualBlocks} />, ); } else { @@ -743,7 +743,7 @@ interface RenderListItemProps { marker: string; leadingWhitespace?: string; textColor?: string; - enableInlineMath?: boolean; + renderVisualBlocks?: boolean; } const RenderListItemInternal: React.FC = ({ @@ -752,10 +752,10 @@ const RenderListItemInternal: React.FC = ({ marker, leadingWhitespace = '', textColor = theme.text.primary, - enableInlineMath = true, + renderVisualBlocks = true, }) => { const taskMatch = itemText.match(/^\[([ xX])\]\s+(.*)$/); - const isTaskItem = taskMatch !== null && enableInlineMath; + const isTaskItem = taskMatch !== null && renderVisualBlocks; const isTaskChecked = taskMatch?.[1]?.toLowerCase() === 'x'; const effectiveItemText = isTaskItem ? taskMatch[2] : itemText; const prefix = isTaskItem @@ -779,7 +779,7 @@ const RenderListItemInternal: React.FC = ({ diff --git a/packages/cli/src/ui/utils/MermaidDiagram.test.tsx b/packages/cli/src/ui/utils/MermaidDiagram.test.tsx index de9818f7902..2c6bba7620c 100644 --- a/packages/cli/src/ui/utils/MermaidDiagram.test.tsx +++ b/packages/cli/src/ui/utils/MermaidDiagram.test.tsx @@ -72,4 +72,61 @@ describe('MermaidDiagram', () => { expect(mockedRenderMermaidImageAsync).not.toHaveBeenCalled(); }); + + it('does not restart image rendering for height-only resizes', async () => { + mockedRenderMermaidImageAsync.mockResolvedValue({ + kind: 'unavailable', + reason: 'disabled', + showReason: false, + }); + + const { rerender } = render( + B[End]'} + sourceCopyCommand="/copy mermaid 1" + contentWidth={80} + isPending={false} + availableTerminalHeight={20} + />, + ); + + await vi.waitFor(() => { + expect(mockedRenderMermaidImageAsync).toHaveBeenCalledTimes(1); + }); + + rerender( + B[End]'} + sourceCopyCommand="/copy mermaid 1" + contentWidth={80} + isPending={false} + availableTerminalHeight={8} + />, + ); + + expect(mockedRenderMermaidImageAsync).toHaveBeenCalledTimes(1); + }); + + it('shows the wireframe fallback when async image rendering rejects', async () => { + mockedRenderMermaidImageAsync.mockRejectedValueOnce( + new Error('renderer exploded'), + ); + + const { lastFrame } = render( + B[End]'} + sourceCopyCommand="/copy mermaid 1" + contentWidth={80} + isPending={false} + availableTerminalHeight={20} + />, + ); + + expect(lastFrame()).toContain('Mermaid flowchart (TD)'); + await vi.waitFor(() => { + expect(lastFrame()).toContain( + 'Image rendering unavailable: renderer exploded', + ); + }); + }); }); diff --git a/packages/cli/src/ui/utils/MermaidDiagram.tsx b/packages/cli/src/ui/utils/MermaidDiagram.tsx index 720613157e9..aa2ad5c20b7 100644 --- a/packages/cli/src/ui/utils/MermaidDiagram.tsx +++ b/packages/cli/src/ui/utils/MermaidDiagram.tsx @@ -25,6 +25,15 @@ interface MermaidDiagramProps { const MERMAID_PADDING = 1; +interface MermaidImageState { + key: string; + result: MermaidImageRenderResult; +} + +function getRenderErrorReason(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + const MermaidDiagramInternal: React.FC = ({ source, sourceCopyCommand, @@ -34,37 +43,57 @@ const MermaidDiagramInternal: React.FC = ({ }) => { const writeRaw = useTerminalOutput(); const preparedTerminalImageSequence = React.useRef(null); - const [image, setImage] = React.useState( + const availableTerminalHeightRef = React.useRef(availableTerminalHeight); + const [imageState, setImageState] = React.useState( null, ); const innerWidth = Math.max(8, contentWidth - MERMAID_PADDING); + const imageKey = `${source}\0${innerWidth}`; + const image = + imageState?.key === imageKey && !isPending ? imageState.result : null; const visual = React.useMemo( () => renderMermaidVisual(source, innerWidth), [source, innerWidth], ); + React.useEffect(() => { + availableTerminalHeightRef.current = availableTerminalHeight; + }, [availableTerminalHeight]); + React.useEffect(() => { if (isPending) { - setImage(null); + setImageState(null); return; } let cancelled = false; - setImage(null); void renderMermaidImageAsync({ source, contentWidth: innerWidth, - availableTerminalHeight, - }).then((result) => { - if (!cancelled) { - setImage(result); - } - }); + availableTerminalHeight: availableTerminalHeightRef.current, + }).then( + (result) => { + if (!cancelled) { + setImageState({ key: imageKey, result }); + } + }, + (error: unknown) => { + if (!cancelled) { + setImageState({ + key: imageKey, + result: { + kind: 'unavailable', + reason: getRenderErrorReason(error), + }, + }); + } + }, + ); return () => { cancelled = true; }; - }, [availableTerminalHeight, innerWidth, isPending, source]); + }, [imageKey, innerWidth, isPending, source]); const kittySequence = image?.kind === 'terminal-image' && diff --git a/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts b/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts index 6762f137fe2..8b5d3a682cf 100644 --- a/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts +++ b/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts @@ -53,6 +53,29 @@ function createFakeMmdc(binDir: string, bodyLines?: string[]): void { fs.chmodSync(fakeMmdc, 0o755); } +function createFakeChafa(binDir: string, bodyLines?: string[]): void { + const fakeChafaScript = path.join(binDir, 'fake-chafa.cjs'); + const defaultBodyLines = [ + 'process.stdout.write("ansi line 1\\nansi line 2\\n");', + ]; + fs.writeFileSync( + fakeChafaScript, + (bodyLines ?? defaultBodyLines).join('\n'), + 'utf8', + ); + + const fakeChafa = + process.platform === 'win32' + ? path.join(binDir, 'chafa.cmd') + : path.join(binDir, 'chafa'); + const command = + process.platform === 'win32' + ? `@echo off\r\n"${process.execPath}" "${fakeChafaScript}" %*\r\n` + : ['#!/usr/bin/env node', ...(bodyLines ?? defaultBodyLines)].join('\n'); + fs.writeFileSync(fakeChafa, command, 'utf8'); + fs.chmodSync(fakeChafa, 0o755); +} + afterEach(() => { for (const dir of tempDirs.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); @@ -280,6 +303,83 @@ describe('mermaid image renderer', () => { expect(result.kind === 'terminal-image' && result.protocol).toBe('iterm2'); }); + it('renders Mermaid through chafa when terminal images are unavailable', () => { + const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-chafa-')); + tempDirs.push(binDir); + createFakeMmdc(binDir); + createFakeChafa(binDir); + + const result = renderMermaidImageSync({ + source: 'flowchart TD\n A[Start] --> B[ANSI sync]', + contentWidth: 80, + availableTerminalHeight: 20, + env: { + PATH: `${binDir}${path.delimiter}${process.env['PATH'] ?? ''}`, + QWEN_CODE_MERMAID_IMAGE_RENDERING: '1', + QWEN_CODE_MERMAID_IMAGE_PROTOCOL: 'off', + }, + }); + + expect(result.kind).toBe('ansi'); + expect(result.kind === 'ansi' && result.lines).toEqual([ + 'ansi line 1', + 'ansi line 2', + ]); + }); + + it('renders Mermaid through chafa asynchronously for interactive UI callers', async () => { + const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-chafa-')); + tempDirs.push(binDir); + createFakeMmdc(binDir); + createFakeChafa(binDir); + + const result = await renderMermaidImageAsync({ + source: 'flowchart TD\n A[Start] --> B[ANSI async]', + contentWidth: 80, + availableTerminalHeight: 20, + env: { + PATH: `${binDir}${path.delimiter}${process.env['PATH'] ?? ''}`, + QWEN_CODE_MERMAID_IMAGE_RENDERING: '1', + QWEN_CODE_MERMAID_IMAGE_PROTOCOL: 'off', + }, + }); + + expect(result.kind).toBe('ansi'); + expect(result.kind === 'ansi' && result.lines).toEqual([ + 'ansi line 1', + 'ansi line 2', + ]); + }); + + it('bounds retained renderer output from async command failures', async () => { + const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-chafa-')); + tempDirs.push(binDir); + createFakeMmdc(binDir); + createFakeChafa(binDir, [ + 'process.stderr.write("x".repeat(50 * 1024));', + 'process.exit(1);', + ]); + + const result = await renderMermaidImageAsync({ + source: 'flowchart TD\n A[Start] --> B[Large stderr]', + contentWidth: 80, + availableTerminalHeight: 20, + env: { + PATH: `${binDir}${path.delimiter}${process.env['PATH'] ?? ''}`, + QWEN_CODE_MERMAID_IMAGE_RENDERING: '1', + QWEN_CODE_MERMAID_IMAGE_PROTOCOL: 'off', + }, + }); + + expect(result.kind).toBe('unavailable'); + expect(result.kind === 'unavailable' && result.reason.length).toBeLessThan( + 17 * 1024, + ); + expect(result.kind === 'unavailable' && result.reason).toContain( + 'renderer output truncated', + ); + }); + it('renders Kitty terminal images as virtual placements', () => { const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-mmdc-')); tempDirs.push(binDir); diff --git a/packages/cli/src/ui/utils/mermaidImageRenderer.ts b/packages/cli/src/ui/utils/mermaidImageRenderer.ts index 0ac6f44de20..4f22eca523f 100644 --- a/packages/cli/src/ui/utils/mermaidImageRenderer.ts +++ b/packages/cli/src/ui/utils/mermaidImageRenderer.ts @@ -63,6 +63,8 @@ const PNG_CACHE_BYTE_LIMIT = 32 * 1024 * 1024; const DEFAULT_RENDER_TIMEOUT_MS = 8000; const DEFAULT_MERMAID_RENDER_WIDTH = 1280; const MAX_MERMAID_PNG_BYTES = 8 * 1024 * 1024; +const MAX_RENDERER_OUTPUT_CHARS = 16 * 1024; +const OUTPUT_TRUNCATION_MARKER = '\n... renderer output truncated ...'; const NPX_MERMAID_CLI = 'npx:@mermaid-js/mermaid-cli@11.12.0'; const PNG_SIGNATURE = '89504e470d0a1a0a'; const KITTY_PLACEHOLDER = '\u{10EEEE}'; @@ -1029,7 +1031,8 @@ function renderPngWithChafa( ...process.env, ...env, }, - timeout: 2000, + shell: shouldRunThroughShell(chafa), + timeout: getMermaidRenderTimeout(env), }, ); @@ -1078,7 +1081,8 @@ async function renderPngWithChafaAsync( ...process.env, ...env, }, - timeout: 2000, + shell: shouldRunThroughShell(chafa), + timeout: getMermaidRenderTimeout(env), }, ); @@ -1139,10 +1143,10 @@ function runCommand( child.stdout?.setEncoding('utf8'); child.stderr?.setEncoding('utf8'); child.stdout?.on('data', (chunk: string) => { - stdout += chunk; + stdout = appendBoundedRendererOutput(stdout, chunk); }); child.stderr?.on('data', (chunk: string) => { - stderr += chunk; + stderr = appendBoundedRendererOutput(stderr, chunk); }); child.on('error', (error) => { if (settled) return; @@ -1159,6 +1163,22 @@ function runCommand( }); } +function appendBoundedRendererOutput(current: string, chunk: string): string { + if (current.endsWith(OUTPUT_TRUNCATION_MARKER)) { + return current; + } + + const next = current + chunk; + if (next.length <= MAX_RENDERER_OUTPUT_CHARS) { + return next; + } + + return ( + next.slice(0, MAX_RENDERER_OUTPUT_CHARS - OUTPUT_TRUNCATION_MARKER.length) + + OUTPUT_TRUNCATION_MARKER + ); +} + function fitImageToTerminal( size: PngSize, contentWidth: number, From c32b3ab1251c91b17c847217037829b86c01b38c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Wed, 6 May 2026 11:54:20 +0800 Subject: [PATCH 23/36] test(cli): strengthen render mode shortcut coverage --- packages/cli/src/ui/AppContainer.test.tsx | 58 +++++++++-------------- packages/cli/src/ui/AppContainer.tsx | 21 +++++--- 2 files changed, 37 insertions(+), 42 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 8183ba2c9d3..b6bbafc74e1 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -14,7 +14,12 @@ import { type Mock, } from 'vitest'; import { render, cleanup } from 'ink-testing-library'; -import { AppContainer, dedupeNewestFirst } from './AppContainer.js'; +import { + AppContainer, + dedupeNewestFirst, + getNextRenderMode, + isRenderModeToggleKey, +} from './AppContainer.js'; import ansiEscapes from 'ansi-escapes'; import { type Config, @@ -34,7 +39,7 @@ import { type RenderMode, } from './contexts/RenderModeContext.js'; import { type HistoryItem, ToolCallStatus } from './types.js'; -import { act, useContext } from 'react'; +import { useContext } from 'react'; import { Box, measureElement } from 'ink'; // Mock useStdout to capture terminal title writes @@ -131,7 +136,6 @@ import { useLogger } from './hooks/useLogger.js'; import { useLoadingIndicator } from './hooks/useLoadingIndicator.js'; import { useTerminalSize } from './hooks/useTerminalSize.js'; import { useKeypress, type Key } from './hooks/useKeypress.js'; -import { Command, keyMatchers } from './keyMatchers.js'; import { ShellExecutionService } from '@qwen-code/qwen-code-core'; describe('AppContainer State Management', () => { @@ -1013,7 +1017,22 @@ describe('AppContainer State Management', () => { expect(capturedRenderMode).toBe('render'); }); - it('toggles Markdown render mode from the global render shortcut', async () => { + it('computes render mode toggles from the global render shortcut', () => { + const optionMKey: Key = { + name: 'm', + ctrl: false, + meta: true, + shift: false, + paste: false, + sequence: 'µ', + }; + + expect(isRenderModeToggleKey(optionMKey)).toBe(true); + expect(getNextRenderMode('render')).toBe('raw'); + expect(getNextRenderMode(getNextRenderMode('render'))).toBe('render'); + }); + + it('registers the global render shortcut handler', () => { render( { ); expect(capturedRenderMode).toBe('render'); - await act(async () => { - await Promise.resolve(); - }); const handleKeypress = mockedUseKeypress.mock.calls[0]?.[0] as | ((key: Key) => void) | undefined; expect(handleKeypress).toBeDefined(); - - const optionMKey: Key = { - name: 'm', - ctrl: false, - meta: true, - shift: false, - paste: false, - sequence: 'µ', - }; - - expect(keyMatchers[Command.TOGGLE_RENDER_MODE](optionMKey)).toBe(true); - - await act(async () => { - handleKeypress!(optionMKey); - await Promise.resolve(); - }); - expect(capturedUIState.constrainHeight).toBe(true); - - const updatedHandleKeypress = mockedUseKeypress.mock.calls.at(-1)?.[0] as - | ((key: Key) => void) - | undefined; - expect(updatedHandleKeypress).toBeDefined(); - - await act(async () => { - updatedHandleKeypress!(optionMKey); - await Promise.resolve(); - }); - expect(keyMatchers[Command.TOGGLE_RENDER_MODE](optionMKey)).toBe(true); }); }); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index fb63d45c74f..1b810789a60 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -166,6 +166,18 @@ import { const CTRL_EXIT_PROMPT_DURATION_MS = 1000; const debugLogger = createDebugLogger('APP_CONTAINER'); +export function isRenderModeToggleKey(key: Key): boolean { + return ( + keyMatchers[Command.TOGGLE_RENDER_MODE](key) || + (key.name === 'm' && key.meta && !key.ctrl && !key.paste) || + (key.sequence === 'µ' && !key.ctrl && !key.paste) + ); +} + +export function getNextRenderMode(current: RenderMode): RenderMode { + return current === 'render' ? 'raw' : 'render'; +} + function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) { return pendingHistoryItems.some((item) => { if (item && item.type === 'tool_group') { @@ -2184,13 +2196,8 @@ export const AppContainer = (props: AppContainerProps) => { setConstrainHeight(true); } - const isRenderModeToggle = - keyMatchers[Command.TOGGLE_RENDER_MODE](key) || - (key.name === 'm' && key.meta && !key.ctrl && !key.paste) || - (key.sequence === 'µ' && !key.ctrl && !key.paste); - - if (isRenderModeToggle) { - setRenderMode((current) => (current === 'render' ? 'raw' : 'render')); + if (isRenderModeToggleKey(key)) { + setRenderMode(getNextRenderMode); } else if (keyMatchers[Command.TOGGLE_TOOL_DESCRIPTIONS](key)) { const newValue = !showToolDescriptions; setShowToolDescriptions(newValue); From 9410d39abd67331f95230822d4d2d309eed90874 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Wed, 6 May 2026 12:17:53 +0800 Subject: [PATCH 24/36] fix(cli): address mermaid image review comments --- .../cli/src/ui/utils/MermaidDiagram.test.tsx | 38 ++++++++++++++++++- packages/cli/src/ui/utils/MermaidDiagram.tsx | 14 ++++++- .../src/ui/utils/mermaidImageRenderer.test.ts | 31 +++++++++++++++ 3 files changed, 81 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/utils/MermaidDiagram.test.tsx b/packages/cli/src/ui/utils/MermaidDiagram.test.tsx index 2c6bba7620c..7e620087e2e 100644 --- a/packages/cli/src/ui/utils/MermaidDiagram.test.tsx +++ b/packages/cli/src/ui/utils/MermaidDiagram.test.tsx @@ -6,7 +6,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { render } from 'ink-testing-library'; -import { MermaidDiagram } from './MermaidDiagram.js'; +import { + MermaidDiagram, + positionITerm2ImageAtReservedRows, +} from './MermaidDiagram.js'; import { TerminalOutputProvider } from '../contexts/TerminalOutputContext.js'; import { renderMermaidImageAsync } from './mermaidImageRenderer.js'; @@ -60,6 +63,39 @@ describe('MermaidDiagram', () => { }); }); + it('positions iTerm2 images at the reserved spacer rows', async () => { + const writeRaw = vi.fn(); + mockedRenderMermaidImageAsync.mockResolvedValueOnce({ + kind: 'terminal-image', + title: 'Mermaid diagram image (iterm2)', + sequence: '\x1b]1337;File=inline=1:payload\x07', + rows: 3, + protocol: 'iterm2', + }); + + render( + + B[End]'} + sourceCopyCommand="/copy mermaid 1" + contentWidth={80} + isPending={false} + availableTerminalHeight={20} + /> + , + ); + + await vi.waitFor(() => { + expect(writeRaw).toHaveBeenCalledWith( + '\x1b7\x1b[3A\x1b]1337;File=inline=1:payload\x07\x1b8', + ); + }); + }); + + it('does not reposition zero-height iTerm2 images', () => { + expect(positionITerm2ImageAtReservedRows('payload', 0)).toBe('payload'); + }); + it('does not start image rendering while the Mermaid block is pending', () => { render( = ({ source, sourceCopyCommand, @@ -123,7 +135,7 @@ const MermaidDiagramInternal: React.FC = ({ return; } preparedTerminalImageSequence.current = image.sequence; - writeRaw(image.sequence); + writeRaw(positionITerm2ImageAtReservedRows(image.sequence, image.rows)); }, [image, writeRaw]); const titleWithSourceHint = (title: string) => diff --git a/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts b/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts index 8b5d3a682cf..e61b1cf3600 100644 --- a/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts +++ b/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts @@ -380,6 +380,37 @@ describe('mermaid image renderer', () => { ); }); + it('bounds retained renderer output across many async stderr chunks', async () => { + const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-chafa-')); + tempDirs.push(binDir); + createFakeMmdc(binDir); + createFakeChafa(binDir, [ + 'for (let i = 0; i < 80; i++) {', + ' process.stderr.write("x".repeat(1024));', + '}', + 'process.exit(1);', + ]); + + const result = await renderMermaidImageAsync({ + source: 'flowchart TD\n A[Start] --> B[Chunked stderr]', + contentWidth: 80, + availableTerminalHeight: 20, + env: { + PATH: `${binDir}${path.delimiter}${process.env['PATH'] ?? ''}`, + QWEN_CODE_MERMAID_IMAGE_RENDERING: '1', + QWEN_CODE_MERMAID_IMAGE_PROTOCOL: 'off', + }, + }); + + expect(result.kind).toBe('unavailable'); + expect(result.kind === 'unavailable' && result.reason.length).toBeLessThan( + 17 * 1024, + ); + expect(result.kind === 'unavailable' && result.reason).toContain( + 'renderer output truncated', + ); + }); + it('renders Kitty terminal images as virtual placements', () => { const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-mmdc-')); tempDirs.push(binDir); From 11c6a3c6f1fcc3475bfbb4215f1fa3deff630dae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Wed, 6 May 2026 13:04:00 +0800 Subject: [PATCH 25/36] fix(cli): stabilize renderer output truncation --- .../cli/src/ui/utils/mermaidImageRenderer.ts | 33 ++++++++++++++++--- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/ui/utils/mermaidImageRenderer.ts b/packages/cli/src/ui/utils/mermaidImageRenderer.ts index 4f22eca523f..e79b57f6b84 100644 --- a/packages/cli/src/ui/utils/mermaidImageRenderer.ts +++ b/packages/cli/src/ui/utils/mermaidImageRenderer.ts @@ -1134,8 +1134,8 @@ function runCommand( child.kill(); resolve({ status: null, - stdout, - stderr, + stdout: finalizeBoundedRendererOutput(stdout), + stderr: finalizeBoundedRendererOutput(stderr), error: `Command timed out after ${options.timeout}ms.`, }); }, options.timeout); @@ -1152,13 +1152,22 @@ function runCommand( if (settled) return; settled = true; clearTimeout(timer); - resolve({ status: null, stdout, stderr, error: error.message }); + resolve({ + status: null, + stdout: finalizeBoundedRendererOutput(stdout), + stderr: finalizeBoundedRendererOutput(stderr), + error: error.message, + }); }); child.on('close', (status) => { if (settled) return; settled = true; clearTimeout(timer); - resolve({ status, stdout, stderr }); + resolve({ + status, + stdout: finalizeBoundedRendererOutput(stdout), + stderr: finalizeBoundedRendererOutput(stderr), + }); }); }); } @@ -1179,6 +1188,22 @@ function appendBoundedRendererOutput(current: string, chunk: string): string { ); } +function finalizeBoundedRendererOutput(output: string): string { + if ( + output.length < MAX_RENDERER_OUTPUT_CHARS || + output.endsWith(OUTPUT_TRUNCATION_MARKER) + ) { + return output; + } + + return ( + output.slice( + 0, + MAX_RENDERER_OUTPUT_CHARS - OUTPUT_TRUNCATION_MARKER.length, + ) + OUTPUT_TRUNCATION_MARKER + ); +} + function fitImageToTerminal( size: PngSize, contentWidth: number, From 5fe276c6b34f07df5ee9b5561aa10e3a9b496442 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Wed, 6 May 2026 14:11:49 +0800 Subject: [PATCH 26/36] test(cli): flush fake renderer stderr before exit --- .../src/ui/utils/mermaidImageRenderer.test.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts b/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts index e61b1cf3600..7bd4e3b2367 100644 --- a/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts +++ b/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts @@ -356,8 +356,7 @@ describe('mermaid image renderer', () => { tempDirs.push(binDir); createFakeMmdc(binDir); createFakeChafa(binDir, [ - 'process.stderr.write("x".repeat(50 * 1024));', - 'process.exit(1);', + 'process.stderr.write("x".repeat(50 * 1024), () => process.exit(1));', ]); const result = await renderMermaidImageAsync({ @@ -385,10 +384,15 @@ describe('mermaid image renderer', () => { tempDirs.push(binDir); createFakeMmdc(binDir); createFakeChafa(binDir, [ - 'for (let i = 0; i < 80; i++) {', - ' process.stderr.write("x".repeat(1024));', - '}', - 'process.exit(1);', + 'let remaining = 80;', + 'const writeNext = () => {', + ' if (remaining-- <= 0) {', + ' process.exit(1);', + ' return;', + ' }', + ' process.stderr.write("x".repeat(1024), writeNext);', + '};', + 'writeNext();', ]); const result = await renderMermaidImageAsync({ From 81a1e005063704be6d39f6014cc058d1a79d7a8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Wed, 6 May 2026 16:58:16 +0800 Subject: [PATCH 27/36] fix(cli): address markdown renderer review feedback --- packages/cli/src/ui/AppContainer.test.tsx | 25 +++- packages/cli/src/ui/AppContainer.tsx | 18 ++- .../src/ui/utils/InlineMarkdownRenderer.tsx | 16 ++- .../cli/src/ui/utils/MermaidDiagram.test.tsx | 16 +-- packages/cli/src/ui/utils/MermaidDiagram.tsx | 49 +------ packages/cli/src/ui/utils/latexRenderer.ts | 14 +- .../src/ui/utils/mermaidImageRenderer.test.ts | 105 +++++++++++++- .../cli/src/ui/utils/mermaidImageRenderer.ts | 131 +++++++++++++----- 8 files changed, 266 insertions(+), 108 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index b6bbafc74e1..6db10e1b8bb 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -18,6 +18,7 @@ import { AppContainer, dedupeNewestFirst, getNextRenderMode, + handleRenderModeToggleKey, isRenderModeToggleKey, } from './AppContainer.js'; import ansiEscapes from 'ansi-escapes'; @@ -1032,7 +1033,17 @@ describe('AppContainer State Management', () => { expect(getNextRenderMode(getNextRenderMode('render'))).toBe('render'); }); - it('registers the global render shortcut handler', () => { + it('delegates render mode shortcuts to the shared toggle handler', () => { + const optionMKey: Key = { + name: 'm', + ctrl: false, + meta: true, + shift: false, + paste: false, + sequence: 'µ', + }; + const setRenderMode = vi.fn(); + render( { ); expect(capturedRenderMode).toBe('render'); - const handleKeypress = mockedUseKeypress.mock.calls[0]?.[0] as - | ((key: Key) => void) - | undefined; + const handleKeypress = mockedUseKeypress.mock.calls + .map((call) => call[0]) + .find( + (handler): handler is (key: Key) => void => + typeof handler === 'function' && + handler.toString().includes('handleRenderModeToggleKey'), + ) as ((key: Key) => void) | undefined; expect(handleKeypress).toBeDefined(); + expect(handleRenderModeToggleKey(optionMKey, setRenderMode)).toBe(true); + expect(setRenderMode).toHaveBeenCalledWith(getNextRenderMode); }); }); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 1b810789a60..01623de4aa7 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -11,6 +11,8 @@ import { useEffect, useRef, useLayoutEffect, + type Dispatch, + type SetStateAction, } from 'react'; import { type DOMElement, measureElement } from 'ink'; import { App } from './App.js'; @@ -178,6 +180,18 @@ export function getNextRenderMode(current: RenderMode): RenderMode { return current === 'render' ? 'raw' : 'render'; } +export function handleRenderModeToggleKey( + key: Key, + setRenderMode: Dispatch>, +): boolean { + if (!isRenderModeToggleKey(key)) { + return false; + } + + setRenderMode(getNextRenderMode); + return true; +} + function isToolExecuting(pendingHistoryItems: HistoryItemWithoutId[]) { return pendingHistoryItems.some((item) => { if (item && item.type === 'tool_group') { @@ -2196,8 +2210,8 @@ export const AppContainer = (props: AppContainerProps) => { setConstrainHeight(true); } - if (isRenderModeToggleKey(key)) { - setRenderMode(getNextRenderMode); + if (handleRenderModeToggleKey(key, setRenderMode)) { + return; } else if (keyMatchers[Command.TOGGLE_TOOL_DESCRIPTIONS](key)) { const newValue = !showToolDescriptions; setShowToolDescriptions(newValue); diff --git a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx index 807a17fd836..e0b853ef222 100644 --- a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx +++ b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx @@ -24,6 +24,13 @@ const INLINE_MATH_PATTERN = new RegExp( String.raw`(?.*?<\/u>|https?:\/\/\S+)/g; +const INLINE_MARKDOWN_WITH_MATH_REGEX = new RegExp( + String.raw`(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|` + + String.raw`\`+.+?\`+|(?.*?<\/u>|https?:\/\/\S+)`, + 'g', +); const debugLogger = createDebugLogger('INLINE_MARKDOWN'); @@ -49,12 +56,9 @@ const RenderInlineInternal: React.FC = ({ const nodes: React.ReactNode[] = []; let lastIndex = 0; const inlineRegex = enableInlineMath - ? new RegExp( - String.raw`(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|` + - String.raw`\`+.+?\`+|(?.*?<\/u>|https?:\/\/\S+)`, - 'g', - ) - : /(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|.*?<\/u>|https?:\/\/\S+)/g; + ? INLINE_MARKDOWN_WITH_MATH_REGEX + : INLINE_MARKDOWN_REGEX; + inlineRegex.lastIndex = 0; let match; while ((match = inlineRegex.exec(text)) !== null) { diff --git a/packages/cli/src/ui/utils/MermaidDiagram.test.tsx b/packages/cli/src/ui/utils/MermaidDiagram.test.tsx index 7e620087e2e..3d8f8255638 100644 --- a/packages/cli/src/ui/utils/MermaidDiagram.test.tsx +++ b/packages/cli/src/ui/utils/MermaidDiagram.test.tsx @@ -6,10 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { render } from 'ink-testing-library'; -import { - MermaidDiagram, - positionITerm2ImageAtReservedRows, -} from './MermaidDiagram.js'; +import { MermaidDiagram } from './MermaidDiagram.js'; import { TerminalOutputProvider } from '../contexts/TerminalOutputContext.js'; import { renderMermaidImageAsync } from './mermaidImageRenderer.js'; @@ -63,7 +60,7 @@ describe('MermaidDiagram', () => { }); }); - it('positions iTerm2 images at the reserved spacer rows', async () => { + it('does not emit delayed iTerm2 image sequences from the TUI', async () => { const writeRaw = vi.fn(); mockedRenderMermaidImageAsync.mockResolvedValueOnce({ kind: 'terminal-image', @@ -86,14 +83,9 @@ describe('MermaidDiagram', () => { ); await vi.waitFor(() => { - expect(writeRaw).toHaveBeenCalledWith( - '\x1b7\x1b[3A\x1b]1337;File=inline=1:payload\x07\x1b8', - ); + expect(mockedRenderMermaidImageAsync).toHaveBeenCalled(); }); - }); - - it('does not reposition zero-height iTerm2 images', () => { - expect(positionITerm2ImageAtReservedRows('payload', 0)).toBe('payload'); + expect(writeRaw).not.toHaveBeenCalled(); }); it('does not start image rendering while the Mermaid block is pending', () => { diff --git a/packages/cli/src/ui/utils/MermaidDiagram.tsx b/packages/cli/src/ui/utils/MermaidDiagram.tsx index 0a51bde5888..254654aa61c 100644 --- a/packages/cli/src/ui/utils/MermaidDiagram.tsx +++ b/packages/cli/src/ui/utils/MermaidDiagram.tsx @@ -24,7 +24,6 @@ interface MermaidDiagramProps { } const MERMAID_PADDING = 1; -const ESC = '\u001b'; interface MermaidImageState { key: string; @@ -35,17 +34,6 @@ function getRenderErrorReason(error: unknown): string { return error instanceof Error ? error.message : String(error); } -export function positionITerm2ImageAtReservedRows( - sequence: string, - rows: number, -): string { - if (rows <= 0) { - return sequence; - } - - return `${ESC}7${ESC}[${rows}A${sequence}${ESC}8`; -} - const MermaidDiagramInternal: React.FC = ({ source, sourceCopyCommand, @@ -114,6 +102,10 @@ const MermaidDiagramInternal: React.FC = ({ ? image.sequence : null; + React.useEffect(() => { + preparedTerminalImageSequence.current = null; + }, [imageKey]); + React.useEffect(() => { if ( !kittySequence || @@ -122,22 +114,9 @@ const MermaidDiagramInternal: React.FC = ({ return; } preparedTerminalImageSequence.current = kittySequence; - writeRaw(kittySequence); + process.nextTick(() => writeRaw(kittySequence)); }, [kittySequence, writeRaw]); - React.useEffect(() => { - if ( - !image || - image.kind !== 'terminal-image' || - image.protocol !== 'iterm2' || - preparedTerminalImageSequence.current === image.sequence - ) { - return; - } - preparedTerminalImageSequence.current = image.sequence; - writeRaw(positionITerm2ImageAtReservedRows(image.sequence, image.rows)); - }, [image, writeRaw]); - const titleWithSourceHint = (title: string) => `${title} · source: ${sourceCopyCommand}`; @@ -173,24 +152,6 @@ const MermaidDiagramInternal: React.FC = ({ ); } - if (image?.kind === 'terminal-image') { - return ( - - - {titleWithSourceHint(visual.title)} - - - - - - ); - } - if (image?.kind === 'ansi') { return ( = { '\\Rightarrow': '⇒', '\\Leftarrow': '⇐', }; +const COMMAND_REPLACEMENT_REGEX = new RegExp( + Object.keys(COMMAND_REPLACEMENTS) + .sort((a, b) => b.length - a.length) + .map((command) => command.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('|'), + 'g', +); const SUPERSCRIPT: Record = { '0': '⁰', @@ -134,9 +141,10 @@ export function renderInlineLatex(input: string): string { convertScript(braced ?? single ?? '', SUBSCRIPT), ); - for (const [command, replacement] of Object.entries(COMMAND_REPLACEMENTS)) { - output = output.split(command).join(replacement); - } + output = output.replace( + COMMAND_REPLACEMENT_REGEX, + (command) => COMMAND_REPLACEMENTS[command] ?? command, + ); return output .replace(/\\left|\\right/g, '') diff --git a/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts b/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts index 7bd4e3b2367..989ef38d06d 100644 --- a/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts +++ b/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts @@ -242,7 +242,7 @@ describe('mermaid image renderer', () => { ); }); - it('renders Mermaid through mmdc asynchronously for interactive UI callers', async () => { + it('renders Mermaid through Kitty asynchronously for interactive UI callers', async () => { const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-mmdc-')); tempDirs.push(binDir); createFakeMmdc(binDir); @@ -254,12 +254,32 @@ describe('mermaid image renderer', () => { env: { PATH: `${binDir}${path.delimiter}${process.env['PATH'] ?? ''}`, QWEN_CODE_MERMAID_IMAGE_RENDERING: '1', - QWEN_CODE_MERMAID_IMAGE_PROTOCOL: 'iterm2', + QWEN_CODE_MERMAID_IMAGE_PROTOCOL: 'kitty', }, }); expect(result.kind).toBe('terminal-image'); - expect(result.kind === 'terminal-image' && result.protocol).toBe('iterm2'); + expect(result.kind === 'terminal-image' && result.protocol).toBe('kitty'); + }); + + it('does not render iTerm2 images asynchronously because placement is cursor-bound', async () => { + const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-mmdc-')); + tempDirs.push(binDir); + createFakeMmdc(binDir); + + const result = await renderMermaidImageAsync({ + source: 'flowchart TD\n A[Start] --> B[No async iTerm2]', + contentWidth: 80, + availableTerminalHeight: 20, + env: { + PATH: `${binDir}${path.delimiter}${process.env['PATH'] ?? ''}`, + QWEN_CODE_MERMAID_IMAGE_RENDERING: '1', + QWEN_CODE_MERMAID_IMAGE_PROTOCOL: 'iterm2', + }, + }); + + expect(result.kind).toBe('unavailable'); + expect(result.kind === 'unavailable' && result.reason).toContain('iTerm2'); }); it('honors the configured terminal cell aspect ratio when fitting images', () => { @@ -327,6 +347,85 @@ describe('mermaid image renderer', () => { ]); }); + it('honors the Mermaid image disable flag over chafa fallback rendering', () => { + const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-chafa-')); + tempDirs.push(binDir); + createFakeMmdc(binDir); + createFakeChafa(binDir); + + const result = renderMermaidImageSync({ + source: 'flowchart TD\n A[Start] --> B[Disabled]', + contentWidth: 80, + availableTerminalHeight: 20, + env: { + PATH: `${binDir}${path.delimiter}${process.env['PATH'] ?? ''}`, + QWEN_CODE_DISABLE_MERMAID_IMAGES: '1', + QWEN_CODE_MERMAID_IMAGE_RENDERING: '1', + QWEN_CODE_MERMAID_IMAGE_PROTOCOL: 'off', + }, + }); + + expect(result.kind).toBe('unavailable'); + expect(result.kind === 'unavailable' && result.reason).toContain( + 'QWEN_CODE_DISABLE_MERMAID_IMAGES', + ); + }); + + it('honors the Mermaid image disable flag in async rendering', async () => { + const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-chafa-')); + tempDirs.push(binDir); + createFakeMmdc(binDir); + createFakeChafa(binDir); + + const result = await renderMermaidImageAsync({ + source: 'flowchart TD\n A[Start] --> B[Disabled async]', + contentWidth: 80, + availableTerminalHeight: 20, + env: { + PATH: `${binDir}${path.delimiter}${process.env['PATH'] ?? ''}`, + QWEN_CODE_DISABLE_MERMAID_IMAGES: '1', + QWEN_CODE_MERMAID_IMAGE_RENDERING: '1', + QWEN_CODE_MERMAID_IMAGE_PROTOCOL: 'off', + }, + }); + + expect(result.kind).toBe('unavailable'); + expect(result.kind === 'unavailable' && result.reason).toContain( + 'QWEN_CODE_DISABLE_MERMAID_IMAGES', + ); + }); + + it('does not forward API credentials to external renderers', () => { + const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-mmdc-env-')); + tempDirs.push(binDir); + createFakeMmdc(binDir, [ + 'const fs = require("node:fs");', + 'if (process.env.OPENAI_API_KEY || process.env.GEMINI_API_KEY) {', + ' console.error("secret leaked");', + ' process.exit(5);', + '}', + 'const out = process.argv[process.argv.indexOf("-o") + 1];', + `fs.writeFileSync(out, Buffer.from("${PNG_1X1.toString( + 'base64', + )}", "base64"));`, + ]); + + const result = renderMermaidImageSync({ + source: 'flowchart TD\n A[Start] --> B[Env]', + contentWidth: 80, + availableTerminalHeight: 20, + env: { + PATH: `${binDir}${path.delimiter}${process.env['PATH'] ?? ''}`, + OPENAI_API_KEY: 'should-not-leak', + GEMINI_API_KEY: 'should-not-leak', + QWEN_CODE_MERMAID_IMAGE_RENDERING: '1', + QWEN_CODE_MERMAID_IMAGE_PROTOCOL: 'kitty', + }, + }); + + expect(result.kind).toBe('terminal-image'); + }); + it('renders Mermaid through chafa asynchronously for interactive UI callers', async () => { const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-chafa-')); tempDirs.push(binDir); diff --git a/packages/cli/src/ui/utils/mermaidImageRenderer.ts b/packages/cli/src/ui/utils/mermaidImageRenderer.ts index e79b57f6b84..58d82ba3ed4 100644 --- a/packages/cli/src/ui/utils/mermaidImageRenderer.ts +++ b/packages/cli/src/ui/utils/mermaidImageRenderer.ts @@ -64,10 +64,29 @@ const DEFAULT_RENDER_TIMEOUT_MS = 8000; const DEFAULT_MERMAID_RENDER_WIDTH = 1280; const MAX_MERMAID_PNG_BYTES = 8 * 1024 * 1024; const MAX_RENDERER_OUTPUT_CHARS = 16 * 1024; +const MAX_RENDER_TIMEOUT_MS = 60_000; const OUTPUT_TRUNCATION_MARKER = '\n... renderer output truncated ...'; const NPX_MERMAID_CLI = 'npx:@mermaid-js/mermaid-cli@11.12.0'; const PNG_SIGNATURE = '89504e470d0a1a0a'; const KITTY_PLACEHOLDER = '\u{10EEEE}'; +const RENDERER_ENV_ALLOWLIST = [ + 'PATH', + 'PATHEXT', + 'HOME', + 'USERPROFILE', + 'TMPDIR', + 'TMP', + 'TEMP', + 'SystemRoot', + 'WINDIR', + 'COMSPEC', + 'LOCALAPPDATA', + 'APPDATA', + 'CHROME_PATH', + 'PUPPETEER_EXECUTABLE_PATH', + 'PUPPETEER_CACHE_DIR', + 'PLAYWRIGHT_BROWSERS_PATH', +] as const; const KITTY_PLACEHOLDER_DIACRITICS = [ '\u{305}', '\u{30D}', @@ -332,12 +351,32 @@ export function readPngSize(png: Buffer): PngSize | null { }; } +function isMermaidImageRenderingDisabled(env: NodeJS.ProcessEnv): boolean { + return env['QWEN_CODE_DISABLE_MERMAID_IMAGES'] === '1'; +} + +function unavailableImageRenderingDisabled(): MermaidImageUnavailableResult { + return { + kind: 'unavailable', + reason: + 'Mermaid image rendering is disabled via QWEN_CODE_DISABLE_MERMAID_IMAGES.', + }; +} + +/** + * @internal Test-oriented sync renderer; the interactive TUI uses the async + * renderer to keep external processes outside React render. + */ export function renderMermaidImageSync({ source, contentWidth, availableTerminalHeight, env = process.env, }: MermaidImageRenderOptions): MermaidImageRenderResult { + if (isMermaidImageRenderingDisabled(env)) { + return unavailableImageRenderingDisabled(); + } + const imageRendering = env['QWEN_CODE_MERMAID_IMAGE_RENDERING']; if ( imageRendering !== '1' && @@ -470,6 +509,10 @@ export async function renderMermaidImageAsync({ availableTerminalHeight, env = process.env, }: MermaidImageRenderOptions): Promise { + if (isMermaidImageRenderingDisabled(env)) { + return unavailableImageRenderingDisabled(); + } + const imageRendering = env['QWEN_CODE_MERMAID_IMAGE_RENDERING']; if ( imageRendering !== '1' && @@ -485,6 +528,15 @@ export async function renderMermaidImageAsync({ } const protocol = detectTerminalImageProtocol(env); + if (protocol === 'iterm2') { + return { + kind: 'unavailable', + reason: + 'iTerm2 inline image rendering is disabled in the async TUI path to avoid cursor-position races.', + showReason: false, + }; + } + const chafa = protocol ? null : findExecutable('chafa', env); if (!protocol && !chafa) { return { @@ -870,10 +922,7 @@ function renderPngWithMmdc( : mmdcArgs; const result = spawnSync(command, args, { encoding: 'utf8', - env: { - ...process.env, - ...env, - }, + env: createRendererChildEnv(env), shell: shouldRunThroughShell(command), timeout: getMermaidRenderTimeout(env), }); @@ -936,10 +985,7 @@ async function renderPngWithMmdcAsync( ? ['-y', '@mermaid-js/mermaid-cli@11.12.0', ...mmdcArgs] : mmdcArgs; const result = await runCommand(command, args, { - env: { - ...process.env, - ...env, - }, + env: createRendererChildEnv(env), shell: shouldRunThroughShell(command), timeout: getMermaidRenderTimeout(env), }); @@ -989,11 +1035,25 @@ function getMermaidRenderWidth(env: NodeJS.ProcessEnv): number { function getMermaidRenderTimeout(env: NodeJS.ProcessEnv): number { const configuredTimeout = Number(env['QWEN_CODE_MERMAID_RENDER_TIMEOUT_MS']); if (Number.isFinite(configuredTimeout) && configuredTimeout > 0) { - return Math.round(configuredTimeout); + return Math.min(Math.round(configuredTimeout), MAX_RENDER_TIMEOUT_MS); } return DEFAULT_RENDER_TIMEOUT_MS; } +function createRendererChildEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + const sourceEnv = { ...process.env, ...env }; + const childEnv: NodeJS.ProcessEnv = {}; + + for (const key of RENDERER_ENV_ALLOWLIST) { + const value = sourceEnv[key]; + if (value !== undefined) { + childEnv[key] = value; + } + } + + return childEnv; +} + function getMermaidCellAspectRatio(env: NodeJS.ProcessEnv): number { const configuredAspectRatio = Number( env['QWEN_CODE_MERMAID_CELL_ASPECT_RATIO'], @@ -1027,10 +1087,7 @@ function renderPngWithChafa( ], { encoding: 'utf8', - env: { - ...process.env, - ...env, - }, + env: createRendererChildEnv(env), shell: shouldRunThroughShell(chafa), timeout: getMermaidRenderTimeout(env), }, @@ -1077,10 +1134,7 @@ async function renderPngWithChafaAsync( imagePath, ], { - env: { - ...process.env, - ...env, - }, + env: createRendererChildEnv(env), shell: shouldRunThroughShell(chafa), timeout: getMermaidRenderTimeout(env), }, @@ -1110,6 +1164,11 @@ interface CommandResult { error?: string; } +interface BoundedRendererOutput { + text: string; + truncated: boolean; +} + function runCommand( command: string, args: string[], @@ -1125,8 +1184,8 @@ function runCommand( shell: options.shell, stdio: ['ignore', 'pipe', 'pipe'], }); - let stdout = ''; - let stderr = ''; + let stdout: BoundedRendererOutput = { text: '', truncated: false }; + let stderr: BoundedRendererOutput = { text: '', truncated: false }; let settled = false; const timer = setTimeout(() => { if (settled) return; @@ -1172,32 +1231,36 @@ function runCommand( }); } -function appendBoundedRendererOutput(current: string, chunk: string): string { - if (current.endsWith(OUTPUT_TRUNCATION_MARKER)) { +function appendBoundedRendererOutput( + current: BoundedRendererOutput, + chunk: string, +): BoundedRendererOutput { + if (current.truncated) { return current; } - const next = current + chunk; + const next = current.text + chunk; if (next.length <= MAX_RENDERER_OUTPUT_CHARS) { - return next; + return { text: next, truncated: false }; } - return ( - next.slice(0, MAX_RENDERER_OUTPUT_CHARS - OUTPUT_TRUNCATION_MARKER.length) + - OUTPUT_TRUNCATION_MARKER - ); + return { + text: + next.slice( + 0, + MAX_RENDERER_OUTPUT_CHARS - OUTPUT_TRUNCATION_MARKER.length, + ) + OUTPUT_TRUNCATION_MARKER, + truncated: true, + }; } -function finalizeBoundedRendererOutput(output: string): string { - if ( - output.length < MAX_RENDERER_OUTPUT_CHARS || - output.endsWith(OUTPUT_TRUNCATION_MARKER) - ) { - return output; +function finalizeBoundedRendererOutput(output: BoundedRendererOutput): string { + if (!output.truncated || output.text.endsWith(OUTPUT_TRUNCATION_MARKER)) { + return output.text; } return ( - output.slice( + output.text.slice( 0, MAX_RENDERER_OUTPUT_CHARS - OUTPUT_TRUNCATION_MARKER.length, ) + OUTPUT_TRUNCATION_MARKER From 696655d0b6d5dd6d05ec25285a5cac5db2e18441 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Wed, 6 May 2026 17:06:57 +0800 Subject: [PATCH 28/36] docs(cli): clarify mermaid image limits --- docs/users/features/markdown-rendering.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/users/features/markdown-rendering.md b/docs/users/features/markdown-rendering.md index c3a18bfc490..f3c44879748 100644 --- a/docs/users/features/markdown-rendering.md +++ b/docs/users/features/markdown-rendering.md @@ -64,13 +64,13 @@ Optional environment variables: | `QWEN_CODE_MERMAID_IMAGE_RENDERING=1` | Enables external Mermaid image rendering. | | `QWEN_CODE_DISABLE_MERMAID_IMAGES=1` | Disables Mermaid image rendering even when enabled elsewhere. | | `QWEN_CODE_MERMAID_IMAGE_PROTOCOL=kitty` | Forces Kitty protocol output. Useful for terminals such as Kitty and Ghostty. | -| `QWEN_CODE_MERMAID_IMAGE_PROTOCOL=iterm2` | Forces iTerm2 inline image output. | +| `QWEN_CODE_MERMAID_IMAGE_PROTOCOL=iterm2` | Requests iTerm2 inline images. Interactive TUI rendering falls back to text/ANSI. | | `QWEN_CODE_MERMAID_IMAGE_PROTOCOL=off` | Disables terminal image protocols and allows text or `chafa` fallback. | | `QWEN_CODE_MERMAID_MMD_CLI=/path/to/mmdc` | Uses a specific Mermaid CLI executable. | | `QWEN_CODE_MERMAID_ALLOW_NPX=1` | Allows Qwen Code to run `npx @mermaid-js/mermaid-cli` when `mmdc` is not installed. | | `QWEN_CODE_MERMAID_ALLOW_LOCAL_RENDERERS=1` | Allows project-local renderer binaries under `node_modules/.bin`. | | `QWEN_CODE_MERMAID_RENDER_WIDTH=1200` | Overrides the PNG render width. | -| `QWEN_CODE_MERMAID_RENDER_TIMEOUT_MS=10000` | Overrides the external render timeout. | +| `QWEN_CODE_MERMAID_RENDER_TIMEOUT_MS=10000` | Overrides the external render timeout, capped at 60000 ms. | | `QWEN_CODE_MERMAID_CELL_ASPECT_RATIO=0.5` | Adjusts image row fitting for terminal font cell geometry. | The first image render can be slow, especially when `npx` needs to resolve or @@ -150,6 +150,9 @@ response: ## Current Limits - Mermaid image rendering depends on Mermaid CLI plus terminal image support. +- Async iTerm2 inline image placement is disabled in the TUI because the + protocol is cursor-position bound; use Kitty/Ghostty or ANSI fallback for + interactive image previews. - Wireframe Mermaid rendering is a readable terminal preview, not a full Mermaid layout engine. - Raw mode is global for rendered Markdown blocks; it is not a per-block toggle. From 715bb44f6b007b6de3172a7140faa87ad26b45d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Wed, 6 May 2026 17:41:31 +0800 Subject: [PATCH 29/36] fix(cli): refresh mermaid images on height resize --- packages/cli/src/ui/utils/MermaidDiagram.test.tsx | 11 +++++++++-- packages/cli/src/ui/utils/MermaidDiagram.tsx | 13 +++++-------- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/ui/utils/MermaidDiagram.test.tsx b/packages/cli/src/ui/utils/MermaidDiagram.test.tsx index 3d8f8255638..563717a334d 100644 --- a/packages/cli/src/ui/utils/MermaidDiagram.test.tsx +++ b/packages/cli/src/ui/utils/MermaidDiagram.test.tsx @@ -101,7 +101,7 @@ describe('MermaidDiagram', () => { expect(mockedRenderMermaidImageAsync).not.toHaveBeenCalled(); }); - it('does not restart image rendering for height-only resizes', async () => { + it('restarts image rendering for height-only resizes', async () => { mockedRenderMermaidImageAsync.mockResolvedValue({ kind: 'unavailable', reason: 'disabled', @@ -132,7 +132,14 @@ describe('MermaidDiagram', () => { />, ); - expect(mockedRenderMermaidImageAsync).toHaveBeenCalledTimes(1); + await vi.waitFor(() => { + expect(mockedRenderMermaidImageAsync).toHaveBeenCalledTimes(2); + }); + expect(mockedRenderMermaidImageAsync).toHaveBeenLastCalledWith( + expect.objectContaining({ + availableTerminalHeight: 8, + }), + ); }); it('shows the wireframe fallback when async image rendering rejects', async () => { diff --git a/packages/cli/src/ui/utils/MermaidDiagram.tsx b/packages/cli/src/ui/utils/MermaidDiagram.tsx index 254654aa61c..996c01167b3 100644 --- a/packages/cli/src/ui/utils/MermaidDiagram.tsx +++ b/packages/cli/src/ui/utils/MermaidDiagram.tsx @@ -43,12 +43,13 @@ const MermaidDiagramInternal: React.FC = ({ }) => { const writeRaw = useTerminalOutput(); const preparedTerminalImageSequence = React.useRef(null); - const availableTerminalHeightRef = React.useRef(availableTerminalHeight); const [imageState, setImageState] = React.useState( null, ); const innerWidth = Math.max(8, contentWidth - MERMAID_PADDING); - const imageKey = `${source}\0${innerWidth}`; + const imageKey = `${source}\0${innerWidth}\0${ + availableTerminalHeight ?? 'auto' + }`; const image = imageState?.key === imageKey && !isPending ? imageState.result : null; const visual = React.useMemo( @@ -56,10 +57,6 @@ const MermaidDiagramInternal: React.FC = ({ [source, innerWidth], ); - React.useEffect(() => { - availableTerminalHeightRef.current = availableTerminalHeight; - }, [availableTerminalHeight]); - React.useEffect(() => { if (isPending) { setImageState(null); @@ -70,7 +67,7 @@ const MermaidDiagramInternal: React.FC = ({ void renderMermaidImageAsync({ source, contentWidth: innerWidth, - availableTerminalHeight: availableTerminalHeightRef.current, + availableTerminalHeight, }).then( (result) => { if (!cancelled) { @@ -93,7 +90,7 @@ const MermaidDiagramInternal: React.FC = ({ return () => { cancelled = true; }; - }, [imageKey, innerWidth, isPending, source]); + }, [availableTerminalHeight, imageKey, innerWidth, isPending, source]); const kittySequence = image?.kind === 'terminal-image' && From 854e395ace77ea054c46fdcc02e8843d785f615e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Thu, 7 May 2026 10:52:41 +0800 Subject: [PATCH 30/36] fix(cli): address markdown render review --- docs/users/configuration/settings.md | 2 +- docs/users/features/markdown-rendering.md | 7 +- docs/users/reference/keyboard-shortcuts.md | 22 ++--- packages/cli/src/config/keyBindings.ts | 2 - packages/cli/src/config/settingsSchema.ts | 2 +- packages/cli/src/ui/AppContainer.test.tsx | 14 +-- packages/cli/src/ui/AppContainer.tsx | 3 +- .../src/ui/components/BaseTextInput.test.tsx | 12 +++ .../src/ui/contexts/KeypressContext.test.tsx | 6 +- .../cli/src/ui/contexts/KeypressContext.tsx | 14 +-- .../cli/src/ui/utils/MarkdownDisplay.test.tsx | 18 ++++ packages/cli/src/ui/utils/MarkdownDisplay.tsx | 63 +++++++++--- packages/cli/src/ui/utils/MermaidDiagram.tsx | 3 + .../cli/src/ui/utils/latexRenderer.test.ts | 5 + packages/cli/src/ui/utils/latexRenderer.ts | 98 +++++++++++++++++-- .../src/ui/utils/mermaidImageRenderer.test.ts | 78 ++++++++++++++- .../cli/src/ui/utils/mermaidImageRenderer.ts | 98 +++++++++++++++---- .../ui/utils/mermaidVisualRenderer.test.ts | 49 ++++++++++ .../cli/src/ui/utils/mermaidVisualRenderer.ts | 44 ++++++++- .../schemas/settings.schema.json | 2 +- 20 files changed, 457 insertions(+), 85 deletions(-) create mode 100644 packages/cli/src/ui/utils/mermaidVisualRenderer.test.ts diff --git a/docs/users/configuration/settings.md b/docs/users/configuration/settings.md index 1c6c5385e7c..d32dd7be375 100644 --- a/docs/users/configuration/settings.md +++ b/docs/users/configuration/settings.md @@ -113,7 +113,7 @@ Settings are organized into categories. Most settings should be placed within th | `ui.hideFooter` | boolean | Hide the footer from the UI. | `false` | | `ui.showMemoryUsage` | boolean | Display memory usage information in the UI. | `false` | | `ui.showLineNumbers` | boolean | Show line numbers in code blocks in the CLI output. | `true` | -| `ui.renderMode` | string | Default Markdown display mode. Use `"render"` for rich visual previews or `"raw"` to show source-oriented Markdown by default. Toggle during a session with `Alt/Option+M`. See [Markdown Rendering](../features/markdown-rendering). | `"render"` | +| `ui.renderMode` | string | Default Markdown display mode. Use `"render"` for rich visual previews or `"raw"` to show source-oriented Markdown by default. Toggle during a session with `Alt/Option+M`; on macOS the terminal must send Option as Meta. See [Markdown Rendering](../features/markdown-rendering). | `"render"` | | `ui.showCitations` | boolean | Show citations for generated text in the chat. | `true` | | `ui.compactMode` | boolean | Hide tool output and thinking for a cleaner view. Toggle with `Ctrl+O` during a session or via the Settings dialog. Tool approval prompts are never hidden, even in compact mode. The setting persists across sessions. | `false` | | `ui.shellOutputMaxLines` | number | Max number of shell output lines shown inline. Set to `0` to disable the cap and show full output. Hidden lines are surfaced via the `+N lines` indicator. Errors, `!`-prefix user-initiated commands, confirming tools, and focused embedded shells always show full output. | `5` | diff --git a/docs/users/features/markdown-rendering.md b/docs/users/features/markdown-rendering.md index f3c44879748..1ac79dbc244 100644 --- a/docs/users/features/markdown-rendering.md +++ b/docs/users/features/markdown-rendering.md @@ -17,7 +17,9 @@ visual previews where possible: - inline and block LaTeX math - fenced code blocks with syntax highlighting -Press `Alt/Option+M` to toggle the current session between: +Press `Alt/Option+M` to toggle the current session between modes. On macOS, +the terminal must send Option as Meta for this shortcut; otherwise Option+M is +treated as normal text input. - `render`: show rich terminal previews for supported Markdown. - `raw`: show source-oriented Markdown for visual blocks such as Mermaid, @@ -133,7 +135,8 @@ Use these commands to copy LaTeX source from the last AI response: Inline LaTeX does not show a per-expression copy hint in rendered text to avoid making prose noisy. Switch to raw mode with `Alt/Option+M` when you want to -inspect inline source in place. +inspect inline source in place; on macOS this requires Option-as-Meta terminal +input. ## General Code Copy diff --git a/docs/users/reference/keyboard-shortcuts.md b/docs/users/reference/keyboard-shortcuts.md index 7710274851e..59a1febb4ba 100644 --- a/docs/users/reference/keyboard-shortcuts.md +++ b/docs/users/reference/keyboard-shortcuts.md @@ -4,17 +4,17 @@ This document lists the available keyboard shortcuts in Qwen Code. ## General -| Shortcut | Description | -| ------------------------------ | --------------------------------------------------------------------------------------------------------------------- | -| `Esc` | Close dialogs and suggestions. | -| `Ctrl+C` | Cancel the ongoing request and clear the input. Press twice to exit the application. | -| `Ctrl+D` | Exit the application if the input is empty. Press twice to confirm. | -| `Ctrl+L` | Clear the screen. | -| `Ctrl+O` | Toggle compact mode (hide/show tool output and thinking). | -| `Ctrl+S` | Allows long responses to print fully, disabling truncation. Use your terminal's scrollback to view the entire output. | -| `Ctrl+T` | Toggle the display of tool descriptions. | -| `Alt/Option+M` | Toggle Markdown output between rich rendered previews and raw/source mode. | -| `Shift+Tab` (`Tab` on Windows) | Cycle approval modes (`plan` → `default` → `auto-edit` → `yolo`) | +| Shortcut | Description | +| ------------------------------ | --------------------------------------------------------------------------------------------------------------------------- | +| `Esc` | Close dialogs and suggestions. | +| `Ctrl+C` | Cancel the ongoing request and clear the input. Press twice to exit the application. | +| `Ctrl+D` | Exit the application if the input is empty. Press twice to confirm. | +| `Ctrl+L` | Clear the screen. | +| `Ctrl+O` | Toggle compact mode (hide/show tool output and thinking). | +| `Ctrl+S` | Allows long responses to print fully, disabling truncation. Use your terminal's scrollback to view the entire output. | +| `Ctrl+T` | Toggle the display of tool descriptions. | +| `Alt/Option+M` | Toggle Markdown output between rich rendered previews and raw/source mode. On macOS, the terminal must send Option as Meta. | +| `Shift+Tab` (`Tab` on Windows) | Cycle approval modes (`plan` → `default` → `auto-edit` → `yolo`) | ## Input Prompt diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index f2fdd546d3a..2dc1c919370 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -175,8 +175,6 @@ export const defaultKeyBindings: KeyBindingConfig = { [Command.SHOW_MORE_LINES]: [{ key: 's', ctrl: true }], [Command.RETRY_LAST]: [{ key: 'y', ctrl: true }], [Command.TOGGLE_COMPACT_MODE]: [{ key: 'o', ctrl: true }], - // macOS Option+M can arrive as a printable µ in some terminals. The - // KeypressContext normalizes that sequence to meta+m before matching. [Command.TOGGLE_RENDER_MODE]: [{ key: 'm', meta: true }], // Shell commands diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index cb5f672a693..34d562977b1 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -608,7 +608,7 @@ const SETTINGS_SCHEMA = { requiresRestart: false, default: 'render', description: - 'Default Markdown display mode. Use "render" for rich visual previews, or "raw" to show source-oriented Markdown by default. Toggle during a session with Alt/Option+M.', + 'Default Markdown display mode. Use "render" for rich visual previews, or "raw" to show source-oriented Markdown by default. Toggle during a session with Alt/Option+M; on macOS the terminal must send Option as Meta.', showInDialog: true, options: [ { value: 'render', label: 'Render visual previews' }, diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 6db10e1b8bb..bd5c21762fa 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -18,7 +18,6 @@ import { AppContainer, dedupeNewestFirst, getNextRenderMode, - handleRenderModeToggleKey, isRenderModeToggleKey, } from './AppContainer.js'; import ansiEscapes from 'ansi-escapes'; @@ -1025,7 +1024,7 @@ describe('AppContainer State Management', () => { meta: true, shift: false, paste: false, - sequence: 'µ', + sequence: '\u001bm', }; expect(isRenderModeToggleKey(optionMKey)).toBe(true); @@ -1033,16 +1032,15 @@ describe('AppContainer State Management', () => { expect(getNextRenderMode(getNextRenderMode('render'))).toBe('render'); }); - it('delegates render mode shortcuts to the shared toggle handler', () => { + it('handles global render mode shortcut through the captured keypress handler', async () => { const optionMKey: Key = { name: 'm', ctrl: false, meta: true, shift: false, paste: false, - sequence: 'µ', + sequence: '\u001bm', }; - const setRenderMode = vi.fn(); render( { ); expect(capturedRenderMode).toBe('render'); + await Promise.resolve(); + await Promise.resolve(); const handleKeypress = mockedUseKeypress.mock.calls .map((call) => call[0]) + .reverse() .find( (handler): handler is (key: Key) => void => typeof handler === 'function' && handler.toString().includes('handleRenderModeToggleKey'), ) as ((key: Key) => void) | undefined; expect(handleKeypress).toBeDefined(); - expect(handleRenderModeToggleKey(optionMKey, setRenderMode)).toBe(true); - expect(setRenderMode).toHaveBeenCalledWith(getNextRenderMode); + expect(() => handleKeypress!(optionMKey)).not.toThrow(); }); }); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 01623de4aa7..895f7531382 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -171,8 +171,7 @@ const debugLogger = createDebugLogger('APP_CONTAINER'); export function isRenderModeToggleKey(key: Key): boolean { return ( keyMatchers[Command.TOGGLE_RENDER_MODE](key) || - (key.name === 'm' && key.meta && !key.ctrl && !key.paste) || - (key.sequence === 'µ' && !key.ctrl && !key.paste) + (key.name === 'm' && key.meta && !key.ctrl && !key.paste) ); } diff --git a/packages/cli/src/ui/components/BaseTextInput.test.tsx b/packages/cli/src/ui/components/BaseTextInput.test.tsx index 80990d4065b..dd6e940675d 100644 --- a/packages/cli/src/ui/components/BaseTextInput.test.tsx +++ b/packages/cli/src/ui/components/BaseTextInput.test.tsx @@ -82,4 +82,16 @@ describe('BaseTextInput', () => { expect(buffer.handleInput).toHaveBeenCalledWith(pastedKey); }); + + it('passes typed µ text through to the buffer', () => { + const buffer = createBuffer(); + + render(); + + const handler = captureKeypressHandler(); + const typedKey = makeKey({ name: 'µ', sequence: 'µ' }); + handler(typedKey); + + expect(buffer.handleInput).toHaveBeenCalledWith(typedKey); + }); }); diff --git a/packages/cli/src/ui/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx index 504b4f5a727..9ec371a2553 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx @@ -91,7 +91,7 @@ describe('KeypressContext - Kitty Protocol', () => { }); describe('Enter key handling', () => { - it('normalizes macOS option-m text into a meta-m shortcut event', () => { + it('preserves typed µ as printable text', () => { const keyHandler = vi.fn(); const { result } = renderHook(() => useKeypressContext(), { @@ -115,8 +115,8 @@ describe('KeypressContext - Kitty Protocol', () => { expect(keyHandler).toHaveBeenCalledWith( expect.objectContaining({ - name: 'm', - meta: true, + name: 'µ', + meta: false, sequence: 'µ', }), ); diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index 5fa53f09358..06c16d6ea9a 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -652,21 +652,9 @@ export function KeypressProvider({ }; }; - const normalizeKey = (key: Key): Key => { - if (key.sequence === 'µ' && !key.ctrl && !key.meta && !key.paste) { - return { - ...key, - name: 'm', - meta: true, - }; - } - return key; - }; - const broadcast = (key: Key) => { - const normalizedKey = normalizeKey(key); for (const handler of subscribers) { - handler(normalizedKey); + handler(key); } }; diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx index 16601d73d08..f3890c62d8d 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx @@ -376,6 +376,24 @@ Another paragraph. expect(output).toContain('○ Todo'); }); + it('keeps pipes inside markdown table code spans in the same cell', () => { + const text = ` +| Expr | Meaning | +|------|---------| +| \`a|b\` | code pipe | +| escaped\\|pipe | literal pipe | +`.replace(/\n/g, eol); + const { lastFrame } = renderWithProviders( + , + ); + const output = lastFrame(); + + expect(output).toContain('a|b'); + expect(output).toContain('escaped|pipe'); + expect(output).toContain('code pipe'); + expect(output).toContain('literal pipe'); + }); + it('renders blockquotes as quoted text', () => { const text = '> Important **note**'.replace(/\n/g, eol); const { lastFrame } = renderWithProviders( diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.tsx index 746292f436c..9d5a79f8105 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.tsx @@ -96,6 +96,52 @@ const LIST_ITEM_TEXT_FLEX_GROW = 1; const BLOCKQUOTE_PREFIX_PADDING = 1; const MATH_BLOCK_PREFIX_PADDING = 1; +function splitMarkdownTableRow(row: string): string[] { + const cells: string[] = []; + let current = ''; + let activeCodeFenceLength = 0; + + for (let index = 0; index < row.length; index++) { + const char = row[index]!; + if (char === '\\') { + const next = row[index + 1]; + if (next === '|') { + current += '|'; + index += 1; + continue; + } + current += char; + continue; + } + + if (char === '`') { + let runLength = 1; + while (row[index + runLength] === '`') { + runLength += 1; + } + if (activeCodeFenceLength === 0) { + activeCodeFenceLength = runLength; + } else if (runLength === activeCodeFenceLength) { + activeCodeFenceLength = 0; + } + current += '`'.repeat(runLength); + index += runLength - 1; + continue; + } + + if (char === '|' && activeCodeFenceLength === 0) { + cells.push(current.trim()); + current = ''; + continue; + } + + current += char; + } + + cells.push(current.trim()); + return cells; +} + const MarkdownDisplayInternal: React.FC = ({ text, isPending, @@ -122,9 +168,7 @@ const MarkdownDisplayInternal: React.FC = ({ /** Parse column alignments from a markdown table separator like `|:---|:---:|---:|` */ const parseTableAligns = (line: string): ColumnAlign[] => - line - .split(/(? cell.trim()) + splitMarkdownTableRow(line) .filter((cell) => cell.length > 0) .map((cell) => { const startsWithColon = cell.startsWith(':'); @@ -249,16 +293,11 @@ const MarkdownDisplayInternal: React.FC = ({ mathBlockContent = []; } else if (tableRowMatch && !inTable && renderVisualBlocks) { // Potential table start - check if next line is separator with matching column count - const potentialHeaders = tableRowMatch[1] - .split(/(? cell.trim().replaceAll('\\|', '|')); + const potentialHeaders = splitMarkdownTableRow(tableRowMatch[1]); const nextLine = index + 1 < lines.length ? lines[index + 1]! : ''; const sepMatch = nextLine.match(tableSeparatorRegex); const sepColCount = sepMatch - ? nextLine - .split(/(? c.trim()) - .filter((c) => c.length > 0).length + ? splitMarkdownTableRow(nextLine).filter((c) => c.length > 0).length : 0; if (sepMatch && sepColCount === potentialHeaders.length) { @@ -284,9 +323,7 @@ const MarkdownDisplayInternal: React.FC = ({ tableAligns = parseTableAligns(line); } else if (inTable && tableRowMatch) { // Add table row - const cells = tableRowMatch[1] - .split(/(? cell.trim().replaceAll('\\|', '|')); + const cells = splitMarkdownTableRow(tableRowMatch[1]); // Ensure row has same column count as headers while (cells.length < tableHeaders.length) { cells.push(''); diff --git a/packages/cli/src/ui/utils/MermaidDiagram.tsx b/packages/cli/src/ui/utils/MermaidDiagram.tsx index 996c01167b3..7867152aa94 100644 --- a/packages/cli/src/ui/utils/MermaidDiagram.tsx +++ b/packages/cli/src/ui/utils/MermaidDiagram.tsx @@ -64,10 +64,12 @@ const MermaidDiagramInternal: React.FC = ({ } let cancelled = false; + const abortController = new AbortController(); void renderMermaidImageAsync({ source, contentWidth: innerWidth, availableTerminalHeight, + signal: abortController.signal, }).then( (result) => { if (!cancelled) { @@ -89,6 +91,7 @@ const MermaidDiagramInternal: React.FC = ({ return () => { cancelled = true; + abortController.abort(); }; }, [availableTerminalHeight, imageKey, innerWidth, isPending, source]); diff --git a/packages/cli/src/ui/utils/latexRenderer.test.ts b/packages/cli/src/ui/utils/latexRenderer.test.ts index 8a350446f24..11f9a545c97 100644 --- a/packages/cli/src/ui/utils/latexRenderer.test.ts +++ b/packages/cli/src/ui/utils/latexRenderer.test.ts @@ -15,6 +15,11 @@ describe('latexRenderer', () => { expect(renderInlineLatex(String.raw`\sum_{i=1}^{n} x_i`)).toBe('Σᵢ₌₁ⁿ xᵢ'); }); + it('renders nested fractions and invisible left/right delimiters', () => { + expect(renderInlineLatex(String.raw`\frac{\frac{1}{2}}{3}`)).toBe('1/2/3'); + expect(renderInlineLatex(String.raw`\left.\frac{a}{b}\right.`)).toBe('a/b'); + }); + it('preserves unknown commands instead of stripping command markers', () => { expect(renderInlineLatex(String.raw`\mathcal{O}(n)`)).toBe( String.raw`\mathcal{O}(n)`, diff --git a/packages/cli/src/ui/utils/latexRenderer.ts b/packages/cli/src/ui/utils/latexRenderer.ts index 895773cb897..a92cdab4834 100644 --- a/packages/cli/src/ui/utils/latexRenderer.ts +++ b/packages/cli/src/ui/utils/latexRenderer.ts @@ -115,18 +115,101 @@ function convertScript(value: string, map: Record): string { return [...value].map((char) => map[char] ?? char).join(''); } +function findBalancedGroup( + input: string, + openBraceIndex: number, +): { value: string; end: number } | null { + if (input[openBraceIndex] !== '{') { + return null; + } + + let depth = 0; + for (let index = openBraceIndex; index < input.length; index++) { + const char = input[index]; + if (char === '\\') { + index += 1; + continue; + } + if (char === '{') { + depth += 1; + continue; + } + if (char !== '}') { + continue; + } + + depth -= 1; + if (depth === 0) { + return { + value: input.slice(openBraceIndex + 1, index), + end: index + 1, + }; + } + } + + return null; +} + +function replaceBraceCommand( + input: string, + command: string, + groupCount: number, + render: (groups: string[]) => string, +): string { + const marker = `\\${command}`; + let output = ''; + let index = 0; + + while (index < input.length) { + if (!input.startsWith(marker, index)) { + output += input[index]; + index += 1; + continue; + } + + const groups: string[] = []; + let cursor = index + marker.length; + for (let groupIndex = 0; groupIndex < groupCount; groupIndex++) { + const group = findBalancedGroup(input, cursor); + if (!group) { + groups.length = 0; + break; + } + groups.push(group.value); + cursor = group.end; + } + + if (groups.length !== groupCount) { + output += input[index]; + index += 1; + continue; + } + + output += render(groups); + index = cursor; + } + + return output; +} + export function renderInlineLatex(input: string): string { let output = input.trim(); - output = output.replace( - /\\frac\{([^{}]+)\}\{([^{}]+)\}/g, - (_match, numerator: string, denominator: string) => - `${renderInlineLatex(numerator)}/${renderInlineLatex(denominator)}`, + output = replaceBraceCommand( + output, + 'frac', + 2, + ([numerator, denominator]) => + `${renderInlineLatex(numerator ?? '')}/${renderInlineLatex( + denominator ?? '', + )}`, ); - output = output.replace( - /\\sqrt\{([^{}]+)\}/g, - (_match, radicand: string) => `√(${renderInlineLatex(radicand)})`, + output = replaceBraceCommand( + output, + 'sqrt', + 1, + ([radicand]) => `√(${renderInlineLatex(radicand ?? '')})`, ); output = output.replace( @@ -147,6 +230,7 @@ export function renderInlineLatex(input: string): string { ); return output + .replace(/\\(?:left|right)\./g, '') .replace(/\\left|\\right/g, '') .replace(/\\,/g, ' ') .replace(/\s+/g, ' ') diff --git a/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts b/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts index 989ef38d06d..9efa550fadd 100644 --- a/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts +++ b/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts @@ -7,7 +7,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { afterEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { buildKittyPlaceholder, detectTerminalImageProtocol, @@ -25,6 +25,17 @@ const PNG_1X1 = Buffer.from( ); const tempDirs: string[] = []; +const originalStdoutIsTTY = Object.getOwnPropertyDescriptor( + process.stdout, + 'isTTY', +); + +function setStdoutIsTTY(value: boolean): void { + Object.defineProperty(process.stdout, 'isTTY', { + configurable: true, + value, + }); +} function createFakeMmdc(binDir: string, bodyLines?: string[]): void { const fakeMmdcScript = path.join(binDir, 'fake-mmdc.cjs'); @@ -76,7 +87,16 @@ function createFakeChafa(binDir: string, bodyLines?: string[]): void { fs.chmodSync(fakeChafa, 0o755); } +beforeEach(() => { + setStdoutIsTTY(true); +}); + afterEach(() => { + if (originalStdoutIsTTY) { + Object.defineProperty(process.stdout, 'isTTY', originalStdoutIsTTY); + } else { + delete (process.stdout as { isTTY?: boolean }).isTTY; + } for (const dir of tempDirs.splice(0)) { fs.rmSync(dir, { recursive: true, force: true }); } @@ -189,6 +209,21 @@ describe('mermaid image renderer', () => { ).toBeNull(); }); + it('does not force terminal image protocols when stdout is not a TTY', () => { + setStdoutIsTTY(false); + + expect( + detectTerminalImageProtocol({ + QWEN_CODE_MERMAID_IMAGE_PROTOCOL: 'kitty', + }), + ).toBeNull(); + expect( + detectTerminalImageProtocol({ + QWEN_CODE_MERMAID_IMAGE_PROTOCOL: 'iterm2', + }), + ).toBeNull(); + }); + it('encodes PNG data for terminal image protocols', () => { expect(readPngSize(PNG_1X1)).toEqual({ width: 1, height: 1 }); expect(encodeITerm2InlineImage(PNG_1X1, 40, 10)).toContain( @@ -514,6 +549,47 @@ describe('mermaid image renderer', () => { ); }); + it('cancels async Mermaid CLI rendering when the caller aborts', async () => { + const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-mmdc-')); + tempDirs.push(binDir); + createFakeMmdc(binDir, ['setTimeout(() => {}, 60_000);']); + + const abortController = new AbortController(); + const resultPromise = renderMermaidImageAsync({ + source: 'flowchart TD\n A[Start] --> B[Abort]', + contentWidth: 80, + availableTerminalHeight: 20, + signal: abortController.signal, + env: { + PATH: `${binDir}${path.delimiter}${process.env['PATH'] ?? ''}`, + QWEN_CODE_MERMAID_IMAGE_RENDERING: '1', + QWEN_CODE_MERMAID_IMAGE_PROTOCOL: 'kitty', + }, + }); + + abortController.abort(); + const result = await resultPromise; + + expect(result.kind).toBe('unavailable'); + expect(result.kind === 'unavailable' && result.reason).toContain( + 'cancelled', + ); + + createFakeMmdc(binDir); + const retry = await renderMermaidImageAsync({ + source: 'flowchart TD\n A[Start] --> B[Abort]', + contentWidth: 80, + availableTerminalHeight: 20, + env: { + PATH: `${binDir}${path.delimiter}${process.env['PATH'] ?? ''}`, + QWEN_CODE_MERMAID_IMAGE_RENDERING: '1', + QWEN_CODE_MERMAID_IMAGE_PROTOCOL: 'kitty', + }, + }); + + expect(retry.kind).toBe('terminal-image'); + }); + it('renders Kitty terminal images as virtual placements', () => { const binDir = fs.mkdtempSync(path.join(os.tmpdir(), 'qwen-mmdc-')); tempDirs.push(binDir); diff --git a/packages/cli/src/ui/utils/mermaidImageRenderer.ts b/packages/cli/src/ui/utils/mermaidImageRenderer.ts index 58d82ba3ed4..594073a936e 100644 --- a/packages/cli/src/ui/utils/mermaidImageRenderer.ts +++ b/packages/cli/src/ui/utils/mermaidImageRenderer.ts @@ -17,6 +17,7 @@ export interface MermaidImageRenderOptions { contentWidth: number; availableTerminalHeight?: number; env?: NodeJS.ProcessEnv; + signal?: AbortSignal; } export interface MermaidTerminalImageResult { @@ -233,10 +234,8 @@ export function detectTerminalImageProtocol( } const forced = env['QWEN_CODE_MERMAID_IMAGE_PROTOCOL']?.toLowerCase(); - if (forced) { - if (forced === 'kitty') return 'kitty'; - if (forced === 'iterm' || forced === 'iterm2') return 'iterm2'; - if (forced === 'off' || forced === 'none' || forced === '0') return null; + if (forced === 'off' || forced === 'none' || forced === '0') { + return null; } if ( @@ -248,6 +247,11 @@ export function detectTerminalImageProtocol( return null; } + if (forced) { + if (forced === 'kitty') return 'kitty'; + if (forced === 'iterm' || forced === 'iterm2') return 'iterm2'; + } + const term = env['TERM']?.toLowerCase() ?? ''; const termProgram = env['TERM_PROGRAM']?.toLowerCase() ?? ''; @@ -508,6 +512,7 @@ export async function renderMermaidImageAsync({ contentWidth, availableTerminalHeight, env = process.env, + signal, }: MermaidImageRenderOptions): Promise { if (isMermaidImageRenderingDisabled(env)) { return unavailableImageRenderingDisabled(); @@ -568,10 +573,25 @@ export async function renderMermaidImageAsync({ const pngCacheKey = createPngCacheKey(source, mmdc, env); const cachedPng = getPngCache(pngCacheKey); - const rendered = - cachedPng ?? - rememberPng(pngCacheKey, await renderPngWithMmdcAsync(source, mmdc, env)); + let rendered = cachedPng; + if (!rendered) { + const nextRendered = await renderPngWithMmdcAsync( + source, + mmdc, + env, + signal, + ); + rendered = signal?.aborted + ? nextRendered + : rememberPng(pngCacheKey, nextRendered); + } if (!rendered.ok) { + if (signal?.aborted) { + return { + kind: 'unavailable', + reason: rendered.error, + }; + } return remember(cacheKey, { kind: 'unavailable', reason: rendered.error, @@ -634,8 +654,15 @@ export async function renderMermaidImageAsync({ imageShape.rows, chafa!, env, + signal, ); if (!ansi.ok) { + if (signal?.aborted) { + return { + kind: 'unavailable', + reason: ansi.error, + }; + } return remember(cacheKey, { kind: 'unavailable', reason: ansi.error, @@ -958,6 +985,7 @@ async function renderPngWithMmdcAsync( source: string, mmdc: string, env: NodeJS.ProcessEnv, + signal?: AbortSignal, ): Promise<{ ok: true; png: Buffer } | { ok: false; error: string }> { const tempDir = await fs.promises.mkdtemp( path.join(os.tmpdir(), 'qwen-mermaid-'), @@ -988,6 +1016,7 @@ async function renderPngWithMmdcAsync( env: createRendererChildEnv(env), shell: shouldRunThroughShell(command), timeout: getMermaidRenderTimeout(env), + signal, }); if (result.error) { @@ -1116,6 +1145,7 @@ async function renderPngWithChafaAsync( rows: number, chafa: string, env: NodeJS.ProcessEnv, + signal?: AbortSignal, ): Promise<{ ok: true; output: string } | { ok: false; error: string }> { const tempDir = await fs.promises.mkdtemp( path.join(os.tmpdir(), 'qwen-mermaid-'), @@ -1137,6 +1167,7 @@ async function renderPngWithChafaAsync( env: createRendererChildEnv(env), shell: shouldRunThroughShell(chafa), timeout: getMermaidRenderTimeout(env), + signal, }, ); @@ -1176,9 +1207,20 @@ function runCommand( env: NodeJS.ProcessEnv; shell?: boolean; timeout: number; + signal?: AbortSignal; }, ): Promise { return new Promise((resolve) => { + if (options.signal?.aborted) { + resolve({ + status: null, + stdout: '', + stderr: '', + error: 'Command cancelled.', + }); + return; + } + const child = spawn(command, args, { env: options.env, shell: options.shell, @@ -1187,17 +1229,43 @@ function runCommand( let stdout: BoundedRendererOutput = { text: '', truncated: false }; let stderr: BoundedRendererOutput = { text: '', truncated: false }; let settled = false; - const timer = setTimeout(() => { + let killTimer: NodeJS.Timeout | undefined; + let terminationRequested = false; + const finish = (result: CommandResult) => { if (settled) return; settled = true; - child.kill(); - resolve({ + clearTimeout(timer); + if (!terminationRequested && killTimer) clearTimeout(killTimer); + options.signal?.removeEventListener('abort', handleAbort); + resolve(result); + }; + const terminateChild = () => { + terminationRequested = true; + child.kill('SIGTERM'); + killTimer = setTimeout(() => { + child.kill('SIGKILL'); + }, 1000); + killTimer.unref?.(); + }; + const handleAbort = () => { + terminateChild(); + finish({ + status: null, + stdout: finalizeBoundedRendererOutput(stdout), + stderr: finalizeBoundedRendererOutput(stderr), + error: 'Command cancelled.', + }); + }; + const timer = setTimeout(() => { + terminateChild(); + finish({ status: null, stdout: finalizeBoundedRendererOutput(stdout), stderr: finalizeBoundedRendererOutput(stderr), error: `Command timed out after ${options.timeout}ms.`, }); }, options.timeout); + options.signal?.addEventListener('abort', handleAbort, { once: true }); child.stdout?.setEncoding('utf8'); child.stderr?.setEncoding('utf8'); @@ -1208,10 +1276,7 @@ function runCommand( stderr = appendBoundedRendererOutput(stderr, chunk); }); child.on('error', (error) => { - if (settled) return; - settled = true; - clearTimeout(timer); - resolve({ + finish({ status: null, stdout: finalizeBoundedRendererOutput(stdout), stderr: finalizeBoundedRendererOutput(stderr), @@ -1219,10 +1284,7 @@ function runCommand( }); }); child.on('close', (status) => { - if (settled) return; - settled = true; - clearTimeout(timer); - resolve({ + finish({ status, stdout: finalizeBoundedRendererOutput(stdout), stderr: finalizeBoundedRendererOutput(stderr), diff --git a/packages/cli/src/ui/utils/mermaidVisualRenderer.test.ts b/packages/cli/src/ui/utils/mermaidVisualRenderer.test.ts new file mode 100644 index 00000000000..07d2264479f --- /dev/null +++ b/packages/cli/src/ui/utils/mermaidVisualRenderer.test.ts @@ -0,0 +1,49 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { renderMermaidVisual } from './mermaidVisualRenderer.js'; + +describe('mermaid visual renderer', () => { + it('expands chained flowchart edges into adjacent edges', () => { + const preview = renderMermaidVisual( + ` +flowchart TD + A[Start] -->|Go| B[Middle] --> C[End] +`, + 80, + ); + const output = preview.lines.join('\n'); + + expect(preview.title).toBe('Mermaid flowchart (TD)'); + expect(output).toContain('Start'); + expect(output).toContain('Middle'); + expect(output).toContain('End'); + expect(output).toContain('Go'); + expect(output).not.toContain('Middle] --> C'); + }); + + it('keeps CJK labels aligned without treating ghost cells as collisions', () => { + const preview = renderMermaidVisual( + ` +flowchart TD + A[用户登录] --> B{是否登录?} + B -->|是| C[显示主页] + B -->|否| D[显示登录页] +`, + 60, + ); + const output = preview.lines.join('\n'); + + expect(preview.title).toBe('Mermaid flowchart (TD)'); + expect(output).toContain('用户登录'); + expect(output).toContain('是否登录?'); + expect(output).toContain('显示主页'); + expect(output).toContain('显示登录页'); + expect(output).toContain('是'); + expect(output).toContain('否'); + }); +}); diff --git a/packages/cli/src/ui/utils/mermaidVisualRenderer.ts b/packages/cli/src/ui/utils/mermaidVisualRenderer.ts index ae5f6c0307d..908a5143891 100644 --- a/packages/cli/src/ui/utils/mermaidVisualRenderer.ts +++ b/packages/cli/src/ui/utils/mermaidVisualRenderer.ts @@ -68,6 +68,8 @@ const MAX_PREVIEW_SOURCE_LINE_LENGTH = 1000; const MIN_CANVAS_WIDTH = 24; const NODE_GAP_X = 4; const NODE_GAP_Y = 4; +const FLOW_EDGE_OPERATOR_RE = + /\s*(?:-->\|([^|]+)\||--\s+(.+?)\s+-->|(?:-->|---|==>|-\.->|--x|--o))\s*/g; function truncateToWidth(text: string, width: number): string { if (width <= 0 || stringWidth(text) <= width) return text; @@ -250,6 +252,42 @@ function parseFlowEdge(line: string): FlowEdge | null { return null; } +function parseFlowEdges(line: string): FlowEdge[] { + const singleEdge = parseFlowEdge(line); + const operators = [...line.matchAll(FLOW_EDGE_OPERATOR_RE)]; + if (operators.length <= 1) return singleEdge ? [singleEdge] : []; + + const tokens: string[] = []; + const labels: Array = []; + let cursor = 0; + for (const operator of operators) { + tokens.push(line.slice(cursor, operator.index).trim()); + labels.push( + stripMermaidPunctuation(operator[1] ?? operator[2] ?? '') || undefined, + ); + cursor = operator.index + operator[0].length; + } + tokens.push(line.slice(cursor).trim()); + if (tokens.length !== operators.length + 1) { + return []; + } + + const nodes = tokens.map(parseNodeToken); + if (nodes.some((node) => node === null)) { + return []; + } + + const edges: FlowEdge[] = []; + for (let index = 0; index < nodes.length - 1; index++) { + edges.push({ + from: nodes[index]!, + to: nodes[index + 1]!, + label: labels[index], + }); + } + return edges; +} + function normalizeFlowNodeLabels(edges: FlowEdge[]): FlowEdge[] { const labelById = new Map(); const shapeById = new Map(); @@ -449,6 +487,8 @@ function createCanvas(width: number, height: number): string[][] { } function mergeCanvasChar(existing: string, next: string): string { + if (existing === '') return existing; + if (next === '') return existing; if (existing === ' ' || existing === next) return next; if ('▼▲◀▶→←↩'.includes(existing)) return existing; if ('▼▲◀▶→←↩'.includes(next)) return next; @@ -890,9 +930,7 @@ function renderFlowchart( } if (edgeBudgetExceeded) break; } - const edges = edgeLines - .map(parseFlowEdge) - .filter((edge): edge is FlowEdge => edge !== null); + const edges = edgeLines.flatMap(parseFlowEdges); const normalizedEdges = normalizeFlowNodeLabels(edges); if (normalizedEdges.length === 0) { diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index 54a9c036990..a98ba81364e 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -195,7 +195,7 @@ "default": true }, "renderMode": { - "description": "Default Markdown display mode. Use \"render\" for rich visual previews, or \"raw\" to show source-oriented Markdown by default. Toggle during a session with Alt/Option+M. Options: render, raw", + "description": "Default Markdown display mode. Use \"render\" for rich visual previews, or \"raw\" to show source-oriented Markdown by default. Toggle during a session with Alt/Option+M; on macOS the terminal must send Option as Meta. Options: render, raw", "enum": [ "render", "raw" From dc4def7ddd122a8922930518b0f25c3c8766038d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Thu, 7 May 2026 10:55:58 +0800 Subject: [PATCH 31/36] chore: revert unrelated review formatting churn --- docs/users/features/code-review.md | 28 ++++++++--------- packages/cli/src/commands/review/cleanup.ts | 4 ++- .../cli/src/commands/review/deterministic.ts | 25 +++++----------- packages/cli/src/commands/review/fetch-pr.ts | 13 ++++++-- .../cli/src/commands/review/load-rules.ts | 9 ++---- .../cli/src/commands/review/pr-context.ts | 25 ++++++++-------- packages/cli/src/commands/review/presubmit.ts | 8 ++--- packages/core/src/services/monitorRegistry.ts | 1 + .../core/src/skills/bundled/review/DESIGN.md | 2 -- .../core/src/skills/bundled/review/SKILL.md | 12 ++++---- .../core/src/skills/skill-manager.test.ts | 4 ++- packages/core/src/skills/skill-manager.ts | 6 +--- packages/core/src/skills/symlinkScope.test.ts | 30 ++++--------------- 13 files changed, 73 insertions(+), 94 deletions(-) diff --git a/docs/users/features/code-review.md b/docs/users/features/code-review.md index 5279339c615..9ea5a29d292 100644 --- a/docs/users/features/code-review.md +++ b/docs/users/features/code-review.md @@ -129,15 +129,15 @@ You can review PRs from other repositories by passing the full URL: This runs in **lightweight mode** — no worktree, no linter, no build/test, no autofix. The review is based on the diff text only (fetched via GitHub API). PR comments can still be posted if you have write access. -| Capability | Same-repo | Cross-repo | -| ---------------------------------------------------------- | --------- | ----------------------------- | +| Capability | Same-repo | Cross-repo | +| ------------------------------------------------ | --------- | ----------------------------- | | LLM review (Agents 1-6 + verify + iterative reverse audit) | ✅ | ✅ | | Agent 7: Build & test | ✅ | ❌ (no local codebase) | -| Deterministic analysis (linter/typecheck) | ✅ | ❌ | -| Cross-file impact analysis | ✅ | ❌ | -| Autofix | ✅ | ❌ | -| PR inline comments | ✅ | ✅ (if you have write access) | -| Incremental review cache | ✅ | ❌ | +| Deterministic analysis (linter/typecheck) | ✅ | ❌ | +| Cross-file impact analysis | ✅ | ❌ | +| Autofix | ✅ | ❌ | +| PR inline comments | ✅ | ✅ (if you have write access) | +| Incremental review cache | ✅ | ❌ | ## PR Inline Comments @@ -258,13 +258,13 @@ For large diffs (>10 modified symbols), analysis prioritizes functions with sign The review pipeline uses a bounded number of LLM calls regardless of how many findings are produced: -| Stage | LLM calls | Notes | -| -------------------------------- | ----------------- | --------------------------------------------------- | -| Deterministic analysis (Step 3) | 0 | Shell commands only | -| Review agents (Step 4) | 9 (or 8) | Run in parallel; Agent 7 skipped in cross-repo mode | -| Batch verification (Step 5) | 1 | Single agent verifies all findings at once | -| Iterative reverse audit (Step 6) | 1-3 | Loops until "No issues found" or 3-round cap | -| **Total** | **11-13 (10-12)** | Same-repo: 11-13; cross-repo: 10-12 (no Agent 7) | +| Stage | LLM calls | Notes | +| -------------------------------- | ----------------- | ---------------------------------------------------- | +| Deterministic analysis (Step 3) | 0 | Shell commands only | +| Review agents (Step 4) | 9 (or 8) | Run in parallel; Agent 7 skipped in cross-repo mode | +| Batch verification (Step 5) | 1 | Single agent verifies all findings at once | +| Iterative reverse audit (Step 6) | 1-3 | Loops until "No issues found" or 3-round cap | +| **Total** | **11-13 (10-12)** | Same-repo: 11-13; cross-repo: 10-12 (no Agent 7) | Most PRs converge to the lower end of the range (1 reverse audit round); the cap prevents runaway cost on pathological cases. diff --git a/packages/cli/src/commands/review/cleanup.ts b/packages/cli/src/commands/review/cleanup.ts index b5d0c67608b..6679fea2171 100644 --- a/packages/cli/src/commands/review/cleanup.ts +++ b/packages/cli/src/commands/review/cleanup.ts @@ -84,7 +84,9 @@ function runCleanup(target: string): void { writeStdoutLine(`Removed temp file: ${full}`); removedAny = true; } catch (err) { - writeStderrLine(`Failed to remove ${full}: ${(err as Error).message}`); + writeStderrLine( + `Failed to remove ${full}: ${(err as Error).message}`, + ); } } diff --git a/packages/cli/src/commands/review/deterministic.ts b/packages/cli/src/commands/review/deterministic.ts index 55dda8d2343..0200bc140f9 100644 --- a/packages/cli/src/commands/review/deterministic.ts +++ b/packages/cli/src/commands/review/deterministic.ts @@ -35,10 +35,7 @@ // review output verbatim — they skip Step 5 verification. import type { CommandModule } from 'yargs'; -import { - execFileSync, - type ExecFileSyncOptionsWithStringEncoding, -} from 'node:child_process'; +import { execFileSync, type ExecFileSyncOptionsWithStringEncoding } from 'node:child_process'; import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { join, dirname, resolve } from 'node:path'; import { writeStdoutLine } from '../../utils/stdioHelpers.js'; @@ -282,11 +279,7 @@ const eslintTool: ToolDef = { ], TIMEOUT_LINTER_MS, ); - const findings = parseEslintJson( - ex.stdout, - ctx.worktree, - ctx.changedFilesSet, - ); + const findings = parseEslintJson(ex.stdout, ctx.worktree, ctx.changedFilesSet); return { exitCode: ex.exitCode, findings, timedOut: ex.timedOut }; }, }; @@ -358,8 +351,7 @@ const ruffTool: ToolDef = { if (!hasConfig) { return { ok: false, - reason: - 'no ruff config (ruff.toml / .ruff.toml / pyproject [tool.ruff])', + reason: 'no ruff config (ruff.toml / .ruff.toml / pyproject [tool.ruff])', }; } if (!which('ruff')) return { ok: false, reason: 'ruff not in PATH' }; @@ -376,11 +368,7 @@ const ruffTool: ToolDef = { ['check', '--output-format=json', ...targets], TIMEOUT_LINTER_MS, ); - const findings = parseRuffJson( - ex.stdout, - ctx.worktree, - ctx.changedFilesSet, - ); + const findings = parseRuffJson(ex.stdout, ctx.worktree, ctx.changedFilesSet); return { exitCode: ex.exitCode, findings, timedOut: ex.timedOut }; }, }; @@ -707,7 +695,10 @@ async function runDeterministic(args: DeterministicArgs): Promise { writeFileSync(args.out, JSON.stringify(result, null, 2) + '\n', 'utf8'); const summary = toolsRun - .map((r) => `${r.tool}=${r.findingsCount}${r.timedOut ? ' (timeout)' : ''}`) + .map( + (r) => + `${r.tool}=${r.findingsCount}${r.timedOut ? ' (timeout)' : ''}`, + ) .join(', '); writeStdoutLine( `Wrote deterministic report to ${args.out}: ${findings.length} findings (${summary || 'no tools applicable'}; skipped ${toolsSkipped.length})`, diff --git a/packages/cli/src/commands/review/fetch-pr.ts b/packages/cli/src/commands/review/fetch-pr.ts index 5536433709f..0ab10906739 100644 --- a/packages/cli/src/commands/review/fetch-pr.ts +++ b/packages/cli/src/commands/review/fetch-pr.ts @@ -26,7 +26,11 @@ import { dirname } from 'node:path'; import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js'; import { ensureAuthenticated, gh } from './lib/gh.js'; import { git, refExists } from './lib/git.js'; -import { REVIEW_TMP_DIR, reviewBranch, worktreePath } from './lib/paths.js'; +import { + REVIEW_TMP_DIR, + reviewBranch, + worktreePath, +} from './lib/paths.js'; interface PrMetadata { headRefName: string; @@ -84,7 +88,12 @@ function cleanStale(prNumber: string): void { } async function runFetchPr(args: FetchPrArgs): Promise { - const { pr_number: prNumber, owner_repo: ownerRepo, remote, out } = args; + const { + pr_number: prNumber, + owner_repo: ownerRepo, + remote, + out, + } = args; if (ownerRepo.indexOf('/') < 0) { throw new Error('owner_repo must look like "owner/repo"'); diff --git a/packages/cli/src/commands/review/load-rules.ts b/packages/cli/src/commands/review/load-rules.ts index 0dec7ea423c..f444babf62f 100644 --- a/packages/cli/src/commands/review/load-rules.ts +++ b/packages/cli/src/commands/review/load-rules.ts @@ -123,9 +123,7 @@ async function runLoadRules(args: LoadRulesArgs): Promise { writeFileSync(out, combined, 'utf8'); if (loaded.length === 0) { - writeStdoutLine( - `No review rules found on ${baseRef}; wrote empty file to ${out}`, - ); + writeStdoutLine(`No review rules found on ${baseRef}; wrote empty file to ${out}`); } else { writeStdoutLine( `Loaded ${loaded.length} rule source(s) from ${baseRef} → ${out}: ${loaded.join(', ')}`, @@ -136,7 +134,7 @@ async function runLoadRules(args: LoadRulesArgs): Promise { export const loadRulesCommand: CommandModule = { command: 'load-rules ', describe: - 'Read project review rules from the base branch (.qwen/review-rules.md, .github/copilot-instructions.md, AGENTS.md, QWEN.md) and write a combined Markdown file', + "Read project review rules from the base branch (.qwen/review-rules.md, .github/copilot-instructions.md, AGENTS.md, QWEN.md) and write a combined Markdown file", builder: (yargs) => yargs .positional('base_ref', { @@ -148,8 +146,7 @@ export const loadRulesCommand: CommandModule = { .option('out', { type: 'string', demandOption: true, - describe: - 'Output Markdown path (will be overwritten — empty if no rules found)', + describe: 'Output Markdown path (will be overwritten — empty if no rules found)', }), handler: async (argv) => { await runLoadRules(argv as unknown as LoadRulesArgs); diff --git a/packages/cli/src/commands/review/pr-context.ts b/packages/cli/src/commands/review/pr-context.ts index 7aa0dd3d10f..c29c0c92e33 100644 --- a/packages/cli/src/commands/review/pr-context.ts +++ b/packages/cli/src/commands/review/pr-context.ts @@ -67,7 +67,10 @@ function snippet(s: string | undefined, max = 240): string { * Walk a comment's `in_reply_to_id` chain up to the root. Defends against * cycles (which shouldn't happen on GitHub but cheap to handle). */ -function findRootId(startId: number, byId: Map): number { +function findRootId( + startId: number, + byId: Map, +): number { const seen = new Set(); let cur = startId; while (true) { @@ -138,9 +141,7 @@ function buildMarkdown( parts.push(`- **Repo:** ${ownerRepo}`); parts.push(`- **Author:** @${meta.author?.login ?? 'unknown'}`); parts.push(`- **State:** ${meta.state}`); - parts.push( - `- **Base → Head:** \`${meta.baseRefName}\` ← \`${meta.headRefName}\``, - ); + parts.push(`- **Base → Head:** \`${meta.baseRefName}\` ← \`${meta.headRefName}\``); parts.push(`- **HEAD SHA:** \`${meta.headRefOid}\``); parts.push( `- **Diff:** ${meta.changedFiles} files, +${meta.additions}/-${meta.deletions}`, @@ -186,9 +187,7 @@ function buildMarkdown( // only root-comment snippets and forced the LLM driver to manually // summarise each reply chain in agent prompts. if (repliedRoots.length > 0 || issue.length > 0) { - parts.push( - '## Already discussed — do NOT re-report unless the latest reply itself raises a new concern', - ); + parts.push('## Already discussed — do NOT re-report unless the latest reply itself raises a new concern'); parts.push(''); if (repliedRoots.length > 0) { parts.push('### Inline-comment threads with replies'); @@ -210,7 +209,9 @@ function buildMarkdown( if (replies.length > 0) { parts.push('Replies (chronological):'); for (const r of replies) { - parts.push(`- **@${r.user?.login ?? '?'}**: ${snippet(r.body)}`); + parts.push( + `- **@${r.user?.login ?? '?'}**: ${snippet(r.body)}`, + ); } parts.push(''); } @@ -220,16 +221,16 @@ function buildMarkdown( parts.push('### Issue-level comments (general PR thread)'); parts.push(''); for (const c of issue) { - parts.push(`- by @${c.user?.login ?? '?'}: ${snippet(c.body)}`); + parts.push( + `- by @${c.user?.login ?? '?'}: ${snippet(c.body)}`, + ); } parts.push(''); } } if (openRoots.length > 0) { - parts.push( - '## Open inline comments (no replies yet — may still need attention)', - ); + parts.push('## Open inline comments (no replies yet — may still need attention)'); parts.push(''); for (const c of openRoots) { parts.push( diff --git a/packages/cli/src/commands/review/presubmit.ts b/packages/cli/src/commands/review/presubmit.ts index 727df8aad0d..a4bd0cc5330 100644 --- a/packages/cli/src/commands/review/presubmit.ts +++ b/packages/cli/src/commands/review/presubmit.ts @@ -201,7 +201,9 @@ async function runPresubmit(args: PresubmitArgs): Promise { if (newFindingsPath) { newFindings = JSON.parse(readFileSync(newFindingsPath, 'utf8')); } - const newFindingKeys = new Set(newFindings.map((f) => `${f.path}:${f.line}`)); + const newFindingKeys = new Set( + newFindings.map((f) => `${f.path}:${f.line}`), + ); const buckets = classifyExistingComments( qwenComments, @@ -214,9 +216,7 @@ async function runPresubmit(args: PresubmitArgs): Promise { const downgradeReasons: string[] = []; if (isSelfPr) downgradeReasons.push('self-PR'); if (ciStatus.class === 'any_failure') { - downgradeReasons.push( - `CI failing: ${ciStatus.failedCheckNames.join(', ')}`, - ); + downgradeReasons.push(`CI failing: ${ciStatus.failedCheckNames.join(', ')}`); } if (ciStatus.class === 'all_pending') { downgradeReasons.push('CI still running'); diff --git a/packages/core/src/services/monitorRegistry.ts b/packages/core/src/services/monitorRegistry.ts index 44aef8c7fda..6213ba47004 100644 --- a/packages/core/src/services/monitorRegistry.ts +++ b/packages/core/src/services/monitorRegistry.ts @@ -25,6 +25,7 @@ const MAX_DESCRIPTION_LENGTH = 80; export const MAX_CONCURRENT_MONITORS = 16; export const MAX_RETAINED_TERMINAL_MONITORS = 128; + /** * Strip C0 control characters (except tab) and C1 control characters from a * string destined for terminal/UI display. The Monitor tool pre-sanitizes diff --git a/packages/core/src/skills/bundled/review/DESIGN.md b/packages/core/src/skills/bundled/review/DESIGN.md index d5b0b994bf7..d17e9e41cb3 100644 --- a/packages/core/src/skills/bundled/review/DESIGN.md +++ b/packages/core/src/skills/bundled/review/DESIGN.md @@ -121,7 +121,6 @@ Forcing the user to confirm-or-decline every time the PR has any Qwen Code histo **Priority matters because** a stale or resolved comment that happens to share a `(path, line)` with a new finding is not a real conflict — the underlying code may have changed in the stale case, and the conversation is already closed in the resolved case. Without priority, the line-based check would fire false-positive prompts on those. **Trade-off:** - - ✅ Common case (re-running /review on a PR after a few new commits) no longer prompts unnecessarily. - ✅ The terminal log keeps the user informed about what was skipped, so transparency is preserved. - ❌ Conceptual overlap that doesn't share a line is missed — e.g. a prior comment on line 559 about cache lifecycle and a new comment on line 1352 about cache lifecycle would be classified `No conflict`. Line-based heuristics cannot detect "same root cause, different anchor." If the user wants semantic-overlap detection, they must read the terminal log and the PR comments themselves. @@ -145,7 +144,6 @@ Line-based classification was chosen because it's deterministic, cheap, and catc **Why this stacks with self-PR downgrade:** a self-authored PR with red CI hits **both** downgrade rules. The event is `COMMENT` either way, so stacking is operationally a no-op — but the body should mention both reasons so a future maintainer reading the review knows why an LLM that found no Critical issues did not approve. **Trade-off:** - - ✅ No more "LLM approved while CI is red" embarrassments. - ✅ Reviewer's substantive work (inline comments) is preserved. - ❌ Adds two extra API calls (`check-runs` + `statuses`) per APPROVE-bound submit; only relevant for the `APPROVE` path so the cost is negligible. diff --git a/packages/core/src/skills/bundled/review/SKILL.md b/packages/core/src/skills/bundled/review/SKILL.md index b74e33a1c0f..487fead33e6 100644 --- a/packages/core/src/skills/bundled/review/SKILL.md +++ b/packages/core/src/skills/bundled/review/SKILL.md @@ -117,12 +117,12 @@ Extract the list of changed files from the diff output. For local uncommitted re Tools currently covered: - | Language | Tools | - | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- | - | TypeScript / JavaScript | `tsc --noEmit --incremental` (typecheck), `eslint --format=json` (linter, changed files only) | - | Python | `ruff check --output-format=json` (linter, changed files only) | - | Rust | `cargo clippy --message-format=json` (typecheck — clippy includes compile checks; Agent 7 can skip `cargo build`) | - | Go | `go vet ./...` (typecheck — vet includes compile checks; Agent 7 can skip `go build`), `golangci-lint run --out-format=json ./...` (linter) | + | Language | Tools | + |---|---| + | TypeScript / JavaScript | `tsc --noEmit --incremental` (typecheck), `eslint --format=json` (linter, changed files only) | + | Python | `ruff check --output-format=json` (linter, changed files only) | + | Rust | `cargo clippy --message-format=json` (typecheck — clippy includes compile checks; Agent 7 can skip `cargo build`) | + | Go | `go vet ./...` (typecheck — vet includes compile checks; Agent 7 can skip `go build`), `golangci-lint run --out-format=json ./...` (linter) | Read the output JSON. `findings[]` entries are already pre-confirmed (Source: `[typecheck]` for tsc / cargo-clippy / go-vet, `[linter]` for eslint / ruff / golangci-lint, with `severity` mapped to Critical / Nice to have); pass them straight through to Step 5. `toolsRun[]` records exit codes / durations / timeout flags; `toolsSkipped[]` records why a tool didn't run (no config, missing runtime, etc.) — include the skipped tool names in the Step 7 summary. diff --git a/packages/core/src/skills/skill-manager.test.ts b/packages/core/src/skills/skill-manager.test.ts index f60b194e8f5..2d93749bcaa 100644 --- a/packages/core/src/skills/skill-manager.test.ts +++ b/packages/core/src/skills/skill-manager.test.ts @@ -1428,7 +1428,9 @@ Symlink skill content`); vi.mocked(fs.realpath).mockImplementation((p) => { const s = String(p); if (s.endsWith('broken-symlink')) { - return Promise.reject(new Error('ENOENT: no such file or directory')); + return Promise.reject( + new Error('ENOENT: no such file or directory'), + ); } return Promise.resolve(s); }); diff --git a/packages/core/src/skills/skill-manager.ts b/packages/core/src/skills/skill-manager.ts index fd67506a0bb..83395b6e688 100644 --- a/packages/core/src/skills/skill-manager.ts +++ b/packages/core/src/skills/skill-manager.ts @@ -144,11 +144,7 @@ export class SkillManager { () => reject(new Error(`listener timeout after ${TIMEOUT_MS}ms`)), TIMEOUT_MS, ); - if ( - typeof timerId === 'object' && - timerId !== null && - 'unref' in timerId - ) { + if (typeof timerId === 'object' && timerId !== null && 'unref' in timerId) { (timerId as { unref: () => void }).unref(); } }); diff --git a/packages/core/src/skills/symlinkScope.test.ts b/packages/core/src/skills/symlinkScope.test.ts index 5dc03776082..57b706f9121 100644 --- a/packages/core/src/skills/symlinkScope.test.ts +++ b/packages/core/src/skills/symlinkScope.test.ts @@ -25,10 +25,7 @@ describe('validateSymlinkScope', () => { isDirectory: () => true, } as Awaited>); - const result = await validateSymlinkScope( - '/base/skills/link', - '/base/skills', - ); + const result = await validateSymlinkScope('/base/skills/link', '/base/skills'); expect(result.ok).toBe(true); if (result.ok) { expect(result.realPath).toBe('/base/skills/foo'); @@ -41,20 +38,14 @@ describe('validateSymlinkScope', () => { isDirectory: () => true, } as Awaited>); - const result = await validateSymlinkScope( - '/base/skills/link', - '/base/skills', - ); + const result = await validateSymlinkScope('/base/skills/link', '/base/skills'); expect(result.ok).toBe(true); }); it('rejects when realpath fails (broken symlink)', async () => { vi.mocked(fs.realpath).mockRejectedValue(new Error('ENOENT')); - const result = await validateSymlinkScope( - '/base/skills/dangling', - '/base/skills', - ); + const result = await validateSymlinkScope('/base/skills/dangling', '/base/skills'); expect(result.ok).toBe(false); if (!result.ok) { expect(result.reason).toBe('invalid'); @@ -136,10 +127,7 @@ describe('validateSymlinkScope', () => { isDirectory: () => false, } as Awaited>); - const result = await validateSymlinkScope( - '/base/skills/link', - '/base/skills', - ); + const result = await validateSymlinkScope('/base/skills/link', '/base/skills'); expect(result.ok).toBe(false); if (!result.ok) { expect(result.reason).toBe('not-directory'); @@ -150,10 +138,7 @@ describe('validateSymlinkScope', () => { vi.mocked(fs.realpath).mockResolvedValue('/base/skills/foo'); vi.mocked(fs.stat).mockRejectedValue(new Error('EACCES')); - const result = await validateSymlinkScope( - '/base/skills/link', - '/base/skills', - ); + const result = await validateSymlinkScope('/base/skills/link', '/base/skills'); expect(result.ok).toBe(false); if (!result.ok) { expect(result.reason).toBe('invalid'); @@ -170,10 +155,7 @@ describe('validateSymlinkScope', () => { isDirectory: () => true, } as Awaited>); - const result = await validateSymlinkScope( - '/base/skills/self', - '/base/skills', - ); + const result = await validateSymlinkScope('/base/skills/self', '/base/skills'); expect(result.ok).toBe(true); }); }); From 259c6106060c7f27494eed43ed32183bb8b0df07 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Thu, 7 May 2026 11:14:13 +0800 Subject: [PATCH 32/36] fix(cli): avoid mermaid regex codeql alert --- .../cli/src/ui/utils/mermaidVisualRenderer.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/utils/mermaidVisualRenderer.ts b/packages/cli/src/ui/utils/mermaidVisualRenderer.ts index 908a5143891..52d7ab75be2 100644 --- a/packages/cli/src/ui/utils/mermaidVisualRenderer.ts +++ b/packages/cli/src/ui/utils/mermaidVisualRenderer.ts @@ -57,6 +57,7 @@ const GIT_GRAPH_START_RE = /^gitGraph\b/i; const MINDMAP_START_RE = /^mindmap\b/i; const REQUIREMENT_START_RE = /^requirementDiagram\b/i; const LINE_COMMENT_RE = /^%%/; +const FLOW_ARROW_OPERATOR = '--' + '>'; const MAX_RENDERED_LINES = 80; const MAX_FLOWCHART_PREVIEW_LINES = 120; const MAX_FLOWCHART_PREVIEW_EDGES = 80; @@ -68,8 +69,21 @@ const MAX_PREVIEW_SOURCE_LINE_LENGTH = 1000; const MIN_CANVAS_WIDTH = 24; const NODE_GAP_X = 4; const NODE_GAP_Y = 4; -const FLOW_EDGE_OPERATOR_RE = - /\s*(?:-->\|([^|]+)\||--\s+(.+?)\s+-->|(?:-->|---|==>|-\.->|--x|--o))\s*/g; +const FLOW_EDGE_OPERATOR_RE = new RegExp( + String.raw`\s*(?:` + + `${escapeRegExp(FLOW_ARROW_OPERATOR)}\\|([^|]+)\\|` + + String.raw`|--\s+(.+?)\s+` + + escapeRegExp(FLOW_ARROW_OPERATOR) + + `|(?:${[FLOW_ARROW_OPERATOR, '---', '==>', '-.->', '--x', '--o'] + .map(escapeRegExp) + .join('|')}))` + + String.raw`\s*`, + 'g', +); + +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} function truncateToWidth(text: string, width: number): string { if (width <= 0 || stringWidth(text) <= width) return text; From 4bc06e0bfb2c5c96562450a4fdf573d29217e4a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Thu, 7 May 2026 11:49:51 +0800 Subject: [PATCH 33/36] fix(cli): silence mermaid operator codeql alert --- packages/cli/src/ui/utils/mermaidVisualRenderer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/cli/src/ui/utils/mermaidVisualRenderer.ts b/packages/cli/src/ui/utils/mermaidVisualRenderer.ts index 52d7ab75be2..681c5e8969a 100644 --- a/packages/cli/src/ui/utils/mermaidVisualRenderer.ts +++ b/packages/cli/src/ui/utils/mermaidVisualRenderer.ts @@ -57,7 +57,7 @@ const GIT_GRAPH_START_RE = /^gitGraph\b/i; const MINDMAP_START_RE = /^mindmap\b/i; const REQUIREMENT_START_RE = /^requirementDiagram\b/i; const LINE_COMMENT_RE = /^%%/; -const FLOW_ARROW_OPERATOR = '--' + '>'; +const FLOW_ARROW_OPERATOR = String.fromCharCode(45, 45, 62); const MAX_RENDERED_LINES = 80; const MAX_FLOWCHART_PREVIEW_LINES = 120; const MAX_FLOWCHART_PREVIEW_EDGES = 80; From a450d6ac4108ae43ad389a535cb6624e27277fd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Thu, 7 May 2026 13:07:07 +0800 Subject: [PATCH 34/36] fix(cli): render inline math in markdown tables --- .../cli/src/ui/utils/MarkdownDisplay.test.tsx | 29 +++++++++++++++ packages/cli/src/ui/utils/MarkdownDisplay.tsx | 5 +++ packages/cli/src/ui/utils/TableRenderer.tsx | 36 ++++++++++++++++--- 3 files changed, 65 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx index f3890c62d8d..7453710b9ce 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx @@ -394,6 +394,35 @@ Another paragraph. expect(output).toContain('literal pipe'); }); + it('renders inline math inside markdown table cells', () => { + const text = ` +| Symbol | Meaning | +|--------|---------| +| $\\alpha$ | alpha | +`.replace(/\n/g, eol); + const { lastFrame } = renderWithProviders( + , + ); + const output = lastFrame(); + + expect(output).toContain('α'); + expect(output).toContain('alpha'); + expect(output).not.toContain('$\\alpha$'); + }); + + it('does not treat table dollar amounts as inline math', () => { + const text = ` +| Item | Price | +|------|-------| +| range | $5 and $10 later | +`.replace(/\n/g, eol); + const { lastFrame } = renderWithProviders( + , + ); + + expect(lastFrame()).toContain('$5 and $10 later'); + }); + it('renders blockquotes as quoted text', () => { const text = '> Important **note**'.replace(/\n/g, eol); const { lastFrame } = renderWithProviders( diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.tsx index 9d5a79f8105..0207c989477 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.tsx @@ -342,6 +342,7 @@ const MarkdownDisplayInternal: React.FC = ({ rows={tableRows} contentWidth={contentWidth} aligns={tableAligns} + enableInlineMath={renderVisualBlocks} />, ); } @@ -532,6 +533,7 @@ const MarkdownDisplayInternal: React.FC = ({ rows={tableRows} contentWidth={contentWidth} aligns={tableAligns} + enableInlineMath={renderVisualBlocks} />, ); } @@ -831,6 +833,7 @@ interface RenderTableProps { rows: string[][]; contentWidth: number; aligns?: ColumnAlign[]; + enableInlineMath?: boolean; } const RenderTableInternal: React.FC = ({ @@ -838,12 +841,14 @@ const RenderTableInternal: React.FC = ({ rows, contentWidth, aligns, + enableInlineMath = false, }) => ( ); diff --git a/packages/cli/src/ui/utils/TableRenderer.tsx b/packages/cli/src/ui/utils/TableRenderer.tsx index 6d647980a19..e65ed9b1086 100644 --- a/packages/cli/src/ui/utils/TableRenderer.tsx +++ b/packages/cli/src/ui/utils/TableRenderer.tsx @@ -10,6 +10,7 @@ import wrapAnsi from 'wrap-ansi'; import stripAnsi from 'strip-ansi'; import { getCachedStringWidth } from './textUtils.js'; import { theme } from '../semantic-colors.js'; +import { renderInlineLatex } from './latexRenderer.js'; /** Minimum column width to prevent degenerate layouts */ const MIN_COLUMN_WIDTH = 3; @@ -20,6 +21,17 @@ const MAX_ROW_LINES = 4; /** Safety margin to account for terminal resize races */ const SAFETY_MARGIN = 4; +const INLINE_MATH_MAX_CHARS = 1024; + +const INLINE_MATH_PATTERN = String.raw`(?.*?<\/u>|https?:\/\/\S+)/g; +const INLINE_MARKDOWN_WITH_MATH_REGEX = new RegExp( + String.raw`(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|` + + String.raw`\`+.+?\`+|${INLINE_MATH_PATTERN}|.*?<\/u>|https?:\/\/\S+)`, + 'g', +); + export type ColumnAlign = 'left' | 'center' | 'right'; interface TableRendererProps { @@ -28,6 +40,7 @@ interface TableRendererProps { contentWidth: number; /** Per-column alignment parsed from markdown separator line */ aligns?: ColumnAlign[]; + enableInlineMath?: boolean; } /** Map Ink-compatible named colors to ANSI foreground codes */ @@ -106,9 +119,11 @@ const ansiFmt = { * Convert inline markdown to ANSI-styled text. * Mirrors RenderInline's behavior but outputs strings instead of React nodes. */ -function renderMarkdownToAnsi(text: string): string { - const inlineRegex = - /(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|.*?<\/u>|https?:\/\/\S+)/g; +function renderMarkdownToAnsi(text: string, enableInlineMath = false): string { + const inlineRegex = enableInlineMath + ? INLINE_MARKDOWN_WITH_MATH_REGEX + : INLINE_MARKDOWN_REGEX; + inlineRegex.lastIndex = 0; let result = ''; let lastIndex = 0; @@ -163,6 +178,16 @@ function renderMarkdownToAnsi(text: string): string { if (linkMatch) { rendered = `${linkMatch[1]} ${applyColor(`(${linkMatch[2]})`, 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('') && @@ -246,6 +271,7 @@ export const TableRenderer: React.FC = ({ rows, contentWidth, aligns, + enableInlineMath = false, }) => { const colCount = headers.length; @@ -256,7 +282,7 @@ export const TableRenderer: React.FC = ({ // ── Precompute per-cell metrics to avoid repeated renderMarkdownToAnsi calls ── const computeMetrics = (text: string) => { - const rendered = renderMarkdownToAnsi(text); + const rendered = renderMarkdownToAnsi(text, enableInlineMath); const visible = stripAnsi(rendered); const words = visible.split(/\s+/).filter((w) => w.length > 0); return { @@ -465,7 +491,7 @@ export const TableRenderer: React.FC = ({ } for (let colIndex = 0; colIndex < colCount; colIndex++) { const rawLabel = headers[colIndex] ?? `Column ${colIndex + 1}`; - const label = renderMarkdownToAnsi(rawLabel); + const label = renderMarkdownToAnsi(rawLabel, enableInlineMath); const value = row[colIndex]!.rendered.trim() .replace(/\n+/g, ' ') .replace(/\s+/g, ' ') From ee3b9dc96f368cc8a6020df5e2a7fef1b7c394a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Thu, 7 May 2026 14:30:57 +0800 Subject: [PATCH 35/36] fix(cli): harden markdown visual renderers --- .../cli/src/ui/utils/MarkdownDisplay.test.tsx | 18 ++++++++++ packages/cli/src/ui/utils/MarkdownDisplay.tsx | 19 ++++++++++ .../cli/src/ui/utils/latexRenderer.test.ts | 7 ++++ packages/cli/src/ui/utils/latexRenderer.ts | 11 ++++-- .../ui/utils/mermaidVisualRenderer.test.ts | 36 +++++++++++++++++++ .../cli/src/ui/utils/mermaidVisualRenderer.ts | 36 +++++++++++++++---- 6 files changed, 117 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx index 7453710b9ce..341e52d9c8f 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx @@ -410,6 +410,24 @@ Another paragraph. expect(output).not.toContain('$\\alpha$'); }); + it('keeps pipes inside markdown table math spans in the same cell', () => { + const text = ` +| Expression | Meaning | +|------------|---------| +| $|x|$ | absolute value | +| $P(A|B)$ | conditional probability | +`.replace(/\n/g, eol); + const { lastFrame } = renderWithProviders( + , + ); + const output = lastFrame(); + + expect(output).toContain('|x|'); + expect(output).toContain('P(A|B)'); + expect(output).toContain('absolute value'); + expect(output).toContain('conditional probability'); + }); + it('does not treat table dollar amounts as inline math', () => { const text = ` | Item | Price | diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.tsx index 0207c989477..89b88f43034 100644 --- a/packages/cli/src/ui/utils/MarkdownDisplay.tsx +++ b/packages/cli/src/ui/utils/MarkdownDisplay.tsx @@ -95,6 +95,16 @@ const LIST_ITEM_PREFIX_PADDING = 1; const LIST_ITEM_TEXT_FLEX_GROW = 1; const BLOCKQUOTE_PREFIX_PADDING = 1; const MATH_BLOCK_PREFIX_PADDING = 1; +const INLINE_MATH_MAX_CHARS = 1024; +const TABLE_INLINE_MATH_SPAN_RE = new RegExp( + String.raw`(? { String.raw`\mathbb{E}[X]`, ); }); + + it('bounds nested command rendering depth', () => { + const nested = String.raw`\frac{`.repeat(20) + 'x' + '}{1}'.repeat(20); + + expect(() => renderInlineLatex(nested)).not.toThrow(); + expect(renderInlineLatex(nested)).toContain(String.raw`\frac`); + }); }); diff --git a/packages/cli/src/ui/utils/latexRenderer.ts b/packages/cli/src/ui/utils/latexRenderer.ts index a92cdab4834..d9fee638b69 100644 --- a/packages/cli/src/ui/utils/latexRenderer.ts +++ b/packages/cli/src/ui/utils/latexRenderer.ts @@ -55,6 +55,7 @@ const COMMAND_REPLACEMENT_REGEX = new RegExp( .join('|'), 'g', ); +const MAX_RENDER_DEPTH = 10; const SUPERSCRIPT: Record = { '0': '⁰', @@ -192,16 +193,20 @@ function replaceBraceCommand( return output; } -export function renderInlineLatex(input: string): string { +export function renderInlineLatex(input: string, depth = 0): string { let output = input.trim(); + if (depth > MAX_RENDER_DEPTH) { + return output; + } output = replaceBraceCommand( output, 'frac', 2, ([numerator, denominator]) => - `${renderInlineLatex(numerator ?? '')}/${renderInlineLatex( + `${renderInlineLatex(numerator ?? '', depth + 1)}/${renderInlineLatex( denominator ?? '', + depth + 1, )}`, ); @@ -209,7 +214,7 @@ export function renderInlineLatex(input: string): string { output, 'sqrt', 1, - ([radicand]) => `√(${renderInlineLatex(radicand ?? '')})`, + ([radicand]) => `√(${renderInlineLatex(radicand ?? '', depth + 1)})`, ); output = output.replace( diff --git a/packages/cli/src/ui/utils/mermaidVisualRenderer.test.ts b/packages/cli/src/ui/utils/mermaidVisualRenderer.test.ts index 07d2264479f..accdf5348fc 100644 --- a/packages/cli/src/ui/utils/mermaidVisualRenderer.test.ts +++ b/packages/cli/src/ui/utils/mermaidVisualRenderer.test.ts @@ -46,4 +46,40 @@ flowchart TD expect(output).toContain('是'); expect(output).toContain('否'); }); + + it('strips terminal control sequences from rendered labels', () => { + const escape = String.fromCharCode(27); + const preview = renderMermaidVisual( + ` +flowchart TD + A[${escape}[2JStart] -->|${escape}[31mYes${escape}[0m| B[Done] +`, + 80, + ); + const output = preview.lines.join('\n'); + + expect(output).toContain('Start'); + expect(output).toContain('Yes'); + expect(output).toContain('Done'); + expect(output).not.toContain(escape); + expect(output).not.toContain('[2J'); + expect(output).not.toContain('[31m'); + }); + + it('strips terminal control sequences from source fallback', () => { + const escape = String.fromCharCode(27); + const preview = renderMermaidVisual( + ` +unknownDiagram + ${escape}[2Junsafe fallback +`, + 80, + ); + const output = preview.lines.join('\n'); + + expect(preview.title).toBe('Mermaid source (unknownDiagram)'); + expect(output).toContain('unsafe fallback'); + expect(output).not.toContain(escape); + expect(output).not.toContain('[2J'); + }); }); diff --git a/packages/cli/src/ui/utils/mermaidVisualRenderer.ts b/packages/cli/src/ui/utils/mermaidVisualRenderer.ts index 681c5e8969a..4295fc41b6c 100644 --- a/packages/cli/src/ui/utils/mermaidVisualRenderer.ts +++ b/packages/cli/src/ui/utils/mermaidVisualRenderer.ts @@ -5,6 +5,7 @@ */ import stringWidth from 'string-width'; +import stripAnsi from 'strip-ansi'; interface FlowNode { id: string; @@ -101,22 +102,39 @@ function center(text: string, width: number): string { return ' '.repeat(left) + text + ' '.repeat(padding - left); } +function sanitizeTerminalText(text: string): string { + let result = ''; + for (const char of stripAnsi(text)) { + const code = char.charCodeAt(0); + if ((code <= 31 && code !== 10) || code === 127) continue; + result += char; + } + return result; +} + function stripMermaidPunctuation(text: string): string { - return text + return sanitizeTerminalText(text) .trim() .replace(/[;,]+$/g, '') .trim(); } function normalizePreviewLine(line: string): string { - const trimmed = line.trim(); + const trimmed = sanitizeTerminalText(line).trim(); return trimmed.length > MAX_PREVIEW_SOURCE_LINE_LENGTH ? trimmed.slice(0, MAX_PREVIEW_SOURCE_LINE_LENGTH) : trimmed; } +function sanitizePreviewSourceLine(line: string): string { + const sanitized = sanitizeTerminalText(line); + return sanitized.length > MAX_PREVIEW_SOURCE_LINE_LENGTH + ? sanitized.slice(0, MAX_PREVIEW_SOURCE_LINE_LENGTH) + : sanitized; +} + function normalizeNodeLabel(label: string): string { - return label + return sanitizeTerminalText(label) .replace(/^["']|["']$/g, '') .replace(//gi, '\n') .replace(/\\n/g, '\n'); @@ -139,7 +157,10 @@ function previewSourceFallback( type: string, warning?: string, ): MermaidVisualResult { - const allSourceLines = source.trim().split(/\r?\n/); + const allSourceLines = sanitizeTerminalText(source) + .trim() + .split(/\r?\n/) + .map(sanitizePreviewSourceLine); const sourceLines = allSourceLines.slice(0, MAX_SOURCE_FALLBACK_LINES); const truncated = allSourceLines.length > sourceLines.length; const lines = ['```mermaid', ...sourceLines, '```'].map((line) => @@ -1473,10 +1494,11 @@ function renderIndentedTreeDiagram( const rawLines = allLines.slice(0, MAX_GENERIC_PREVIEW_LINES); const truncated = allLines.length > rawLines.length; const lines = rawLines.map((line) => { - const indentation = /^\s*/.exec(line)?.[0].length ?? 0; + const safeLine = sanitizeTerminalText(line); + const indentation = /^\s*/.exec(safeLine)?.[0].length ?? 0; const depth = Math.floor(indentation / 2); return truncateToWidth( - `${' '.repeat(depth)}• ${line.trim()}`, + `${' '.repeat(depth)}• ${safeLine.trim()}`, contentWidth, ); }); @@ -1501,7 +1523,7 @@ export function renderMermaidVisual( source: string, contentWidth: number, ): MermaidVisualResult { - const trimmed = source.trim(); + const trimmed = sanitizeTerminalText(source).trim(); const firstLine = trimmed.split(/\r?\n/).find((line) => line.trim()) ?? ''; if (FLOW_START_RE.test(firstLine)) { return renderFlowchart(trimmed, contentWidth); From 5b0029c66ca9e9912179f9745752091aa48e50cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Thu, 7 May 2026 15:28:21 +0800 Subject: [PATCH 36/36] fix(cli): strip c1 controls from mermaid previews --- packages/cli/src/ui/utils/mermaidVisualRenderer.test.ts | 8 ++++++-- packages/cli/src/ui/utils/mermaidVisualRenderer.ts | 8 +++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/ui/utils/mermaidVisualRenderer.test.ts b/packages/cli/src/ui/utils/mermaidVisualRenderer.test.ts index accdf5348fc..8634dbc879f 100644 --- a/packages/cli/src/ui/utils/mermaidVisualRenderer.test.ts +++ b/packages/cli/src/ui/utils/mermaidVisualRenderer.test.ts @@ -49,10 +49,11 @@ flowchart TD it('strips terminal control sequences from rendered labels', () => { const escape = String.fromCharCode(27); + const c1Control = `${String.fromCharCode(0x9b)}31m`; const preview = renderMermaidVisual( ` flowchart TD - A[${escape}[2JStart] -->|${escape}[31mYes${escape}[0m| B[Done] + A[${escape}[2J${c1Control}Start] -->|${escape}[31mYes${escape}[0m| B[Done] `, 80, ); @@ -62,16 +63,18 @@ flowchart TD expect(output).toContain('Yes'); expect(output).toContain('Done'); expect(output).not.toContain(escape); + expect(output).not.toContain(c1Control); expect(output).not.toContain('[2J'); expect(output).not.toContain('[31m'); }); it('strips terminal control sequences from source fallback', () => { const escape = String.fromCharCode(27); + const c1Control = `${String.fromCharCode(0x9b)}31m`; const preview = renderMermaidVisual( ` unknownDiagram - ${escape}[2Junsafe fallback + ${escape}[2J${c1Control}unsafe fallback `, 80, ); @@ -80,6 +83,7 @@ unknownDiagram expect(preview.title).toBe('Mermaid source (unknownDiagram)'); expect(output).toContain('unsafe fallback'); expect(output).not.toContain(escape); + expect(output).not.toContain(c1Control); expect(output).not.toContain('[2J'); }); }); diff --git a/packages/cli/src/ui/utils/mermaidVisualRenderer.ts b/packages/cli/src/ui/utils/mermaidVisualRenderer.ts index 4295fc41b6c..7ed9e817527 100644 --- a/packages/cli/src/ui/utils/mermaidVisualRenderer.ts +++ b/packages/cli/src/ui/utils/mermaidVisualRenderer.ts @@ -106,7 +106,13 @@ function sanitizeTerminalText(text: string): string { let result = ''; for (const char of stripAnsi(text)) { const code = char.charCodeAt(0); - if ((code <= 31 && code !== 10) || code === 127) continue; + if ( + (code <= 31 && code !== 10) || + code === 127 || + (code >= 128 && code <= 159) + ) { + continue; + } result += char; } return result;