diff --git a/docs/design/markdown-syntax-extension.md b/docs/design/markdown-syntax-extension.md
new file mode 100644
index 0000000000..b4ef3d8da5
--- /dev/null
+++ b/docs/design/markdown-syntax-extension.md
@@ -0,0 +1,236 @@
+# 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 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.
+- 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
+ 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
+
+### 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 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 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. 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
+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.
+- Large previews are bounded before graph layout so very large Mermaid blocks
+ do not allocate an unbounded terminal canvas during render.
+
+Example:
+
+```mermaid
+flowchart LR
+ A[Client] --> B[API]
+```
+
+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. 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
+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/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.
+- 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.
+- `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/docs/users/configuration/settings.md b/docs/users/configuration/settings.md
index 05c63d115b..ed115f9c97 100644
--- a/docs/users/configuration/settings.md
+++ b/docs/users/configuration/settings.md
@@ -116,6 +116,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`; 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/_meta.ts b/docs/users/features/_meta.ts
index 661a26c039..7f76ae1526 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 0000000000..1ac79dbc24
--- /dev/null
+++ b/docs/users/features/markdown-rendering.md
@@ -0,0 +1,163 @@
+# 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 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,
+ 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` | 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, 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
+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.
+
+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:
+
+| 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; on macOS this requires Option-as-Meta terminal
+input.
+
+## 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.
+- 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.
+- 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 e6a259e88a..59a1febb4b 100644
--- a/docs/users/reference/keyboard-shortcuts.md
+++ b/docs/users/reference/keyboard-shortcuts.md
@@ -4,16 +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. |
-| `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/integration-tests/terminal-capture/package.json b/integration-tests/terminal-capture/package.json
index 46d602c227..65f81e1cba 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 0000000000..1b49899e65
--- /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/keyBindings.ts b/packages/cli/src/config/keyBindings.ts
index 196602ba1d..2dc1c91937 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_RENDER_MODE = 'toggleRenderMode',
// 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_RENDER_MODE]: [{ key: 'm', meta: true }],
// Shell commands
[Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }],
diff --git a/packages/cli/src/config/settingsSchema.test.ts b/packages/cli/src/config/settingsSchema.test.ts
index 36036a8516..865328814c 100644
--- a/packages/cli/src/config/settingsSchema.test.ts
+++ b/packages/cli/src/config/settingsSchema.test.ts
@@ -227,11 +227,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'],
@@ -242,6 +256,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);
expect(settings.proxy).toBe('http://localhost:7890');
diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts
index 0019bca293..01a2f14903 100644
--- a/packages/cli/src/config/settingsSchema.ts
+++ b/packages/cli/src/config/settingsSchema.ts
@@ -628,6 +628,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; on macOS the terminal must send Option as Meta.',
+ 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.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx
index fe0c68de4d..bd5c21762f 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,
@@ -29,6 +34,10 @@ import {
UIActionsContext,
type UIActions,
} from './contexts/UIActionsContext.js';
+import {
+ useRenderMode,
+ type RenderMode,
+} from './contexts/RenderModeContext.js';
import { type HistoryItem, ToolCallStatus } from './types.js';
import { useContext } from 'react';
import { Box, measureElement } from 'ink';
@@ -48,9 +57,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 ;
}
@@ -124,6 +135,7 @@ 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 { ShellExecutionService } from '@qwen-code/qwen-code-core';
describe('AppContainer State Management', () => {
@@ -152,6 +164,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();
@@ -170,6 +183,7 @@ describe('AppContainer State Management', () => {
capturedUIState = null!;
capturedUIActions = null!;
+ capturedRenderMode = 'render';
// **Provide a default return value for EVERY mocked hook.**
mockedUseHistory.mockReturnValue({
@@ -954,6 +968,103 @@ 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');
+ });
+
+ it('computes render mode toggles from the global render shortcut', () => {
+ const optionMKey: Key = {
+ name: 'm',
+ ctrl: false,
+ meta: true,
+ shift: false,
+ paste: false,
+ sequence: '\u001bm',
+ };
+
+ expect(isRenderModeToggleKey(optionMKey)).toBe(true);
+ expect(getNextRenderMode('render')).toBe('raw');
+ expect(getNextRenderMode(getNextRenderMode('render'))).toBe('render');
+ });
+
+ 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: '\u001bm',
+ };
+
+ 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(() => handleKeypress!(optionMKey)).not.toThrow();
+ });
});
describe('Version Handling', () => {
diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx
index 0f9562ce41..895f753138 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';
@@ -132,6 +134,11 @@ import {
} from './hooks/useExtensionUpdates.js';
import { useCodingPlanUpdates } from './hooks/useCodingPlanUpdates.js';
import { ShellFocusContext } from './contexts/ShellFocusContext.js';
+import {
+ RenderModeProvider,
+ type RenderMode,
+} from './contexts/RenderModeContext.js';
+import { TerminalOutputProvider } from './contexts/TerminalOutputContext.js';
import { useAgentViewState } from './contexts/AgentViewContext.js';
import {
useBackgroundTaskViewState,
@@ -161,6 +168,29 @@ 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)
+ );
+}
+
+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') {
@@ -1567,6 +1597,28 @@ export const AppContainer = (props: AppContainerProps) => {
const [compactMode, setCompactMode] = useState(
settings.merged.ui?.compactMode ?? false,
);
+ const configuredRenderMode = settings.merged.ui?.renderMode;
+ 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);
+ useEffect(() => {
+ if (!renderModeMountedRef.current) {
+ renderModeMountedRef.current = true;
+ return;
+ }
+
+ refreshStatic();
+ }, [renderMode, refreshStatic]);
const [ctrlCPressedOnce, setCtrlCPressedOnce] = useState(false);
const ctrlCTimerRef = useRef(null);
const [ctrlDPressedOnce, setCtrlDPressedOnce] = useState(false);
@@ -2157,7 +2209,9 @@ export const AppContainer = (props: AppContainerProps) => {
setConstrainHeight(true);
}
- if (keyMatchers[Command.TOGGLE_TOOL_DESCRIPTIONS](key)) {
+ if (handleRenderModeToggleKey(key, setRenderMode)) {
+ return;
+ } else if (keyMatchers[Command.TOGGLE_TOOL_DESCRIPTIONS](key)) {
const newValue = !showToolDescriptions;
setShowToolDescriptions(newValue);
@@ -2220,6 +2274,7 @@ export const AppContainer = (props: AppContainerProps) => {
isAuthenticating,
compactMode,
setCompactMode,
+ setRenderMode,
refreshStatic,
handleDoubleEscRewind,
],
@@ -2699,6 +2754,10 @@ export const AppContainer = (props: AppContainerProps) => {
() => ({ compactMode, setCompactMode }),
[compactMode, setCompactMode],
);
+ const renderModeValue = useMemo(
+ () => ({ renderMode, setRenderMode }),
+ [renderMode, setRenderMode],
+ );
return (
@@ -2711,9 +2770,13 @@ 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 b075319674..ca9f78731e 100644
--- a/packages/cli/src/ui/commands/copyCommand.test.ts
+++ b/packages/cli/src/ui/commands/copyCommand.test.ts
@@ -215,6 +215,426 @@ 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: 'mermaid 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: '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',
+ });
+ });
+
+ 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 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');
+
+ 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');
+
+ 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');
+
+ 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 6349869021..8ae1863607 100644
--- a/packages/cli/src/ui/commands/copyCommand.ts
+++ b/packages/cli/src/ui/commands/copyCommand.ts
@@ -9,6 +9,334 @@ 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;
+ langIndex: number | null;
+}
+
+interface SelectedCodeBlock {
+ block: FencedCodeBlock;
+ label: string;
+}
+
+interface LatexBlock {
+ content: string;
+ index: number;
+}
+
+interface SelectedLatexBlock {
+ block: LatexBlock;
+ label: string;
+}
+
+interface InlineLatexExpression {
+ content: string;
+ index: number;
+}
+
+interface SelectedInlineLatexExpression {
+ expression: InlineLatexExpression;
+ label: string;
+}
+
+const INLINE_MATH_MAX_CHARS = 1024;
+const INLINE_MATH_REGEX = new RegExp(
+ String.raw`(?();
+ let activeFence: string | null = null;
+ let activeLang: string | null = null;
+ let activeLangIndex: number | 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;
+ if (activeLang) {
+ const nextLangIndex = (languageCounts.get(activeLang) ?? 0) + 1;
+ languageCounts.set(activeLang, nextLangIndex);
+ activeLangIndex = nextLangIndex;
+ } else {
+ activeLangIndex = 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,
+ langIndex: activeLangIndex,
+ });
+ activeFence = null;
+ activeLang = null;
+ activeLangIndex = null;
+ activeLines = [];
+ continue;
+ }
+
+ activeLines.push(line);
+ }
+
+ return blocks;
+}
+
+function parseInlineLatexExpressions(
+ markdown: string,
+): InlineLatexExpression[] {
+ const expressions: InlineLatexExpression[] = [];
+ const lines = markdown.split(/\r?\n/);
+ const codeFenceRegex = /^ *(`{3,}|~{3,}) *([^`]*)$/;
+ const mathFenceRegex = /^ *\$\$ *$/;
+ let activeCodeFence: string | null = null;
+ let inLatexBlock = false;
+
+ 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 (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/);
+ 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 = [];
+ }
+ 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 | SelectedInlineLatexExpression | null | undefined {
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
+ const firstToken = tokens[0]?.toLowerCase();
+ 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 selectorTokens) {
+ 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,
+): SelectedCodeBlock | null | undefined {
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
+ 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;
+ 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 if (token.toLowerCase() !== 'code') {
+ 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.find((block) => block.langIndex === requestedIndex)
+ : blocks.find((block) => block.index === requestedIndex);
+ return requested
+ ? { block: requested, label: formatCodeBlockLabel(requested, lang) }
+ : 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 = {
name: 'copy',
get description() {
@@ -40,12 +368,53 @@ export const copyCommand: SlashCommand = {
if (lastAiOutput) {
try {
- await copyToClipboard(lastAiOutput);
+ const selectedLatexBlock = selectLatexBlock(lastAiOutput, _args);
+ if (selectedLatexBlock === null) {
+ return {
+ type: 'message',
+ messageType: 'info',
+ 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) {
+ const copiedLatex =
+ 'expression' in selectedLatexBlock
+ ? selectedLatexBlock.expression.content
+ : selectedLatexBlock.block.content;
+ await copyToClipboard(copiedLatex);
+
+ return {
+ type: 'message',
+ messageType: 'info',
+ content: `${selectedLatexBlock.label} copied to the clipboard`,
+ };
+ }
+
+ 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?.block.content ?? lastAiOutput;
+ await copyToClipboard(copiedText);
return {
type: 'message',
messageType: 'info',
- content: 'Last output copied to the clipboard',
+ content: selectedCodeBlock
+ ? `${selectedCodeBlock.label} 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/components/BaseTextInput.test.tsx b/packages/cli/src/ui/components/BaseTextInput.test.tsx
new file mode 100644
index 0000000000..dd6e940675
--- /dev/null
+++ b/packages/cli/src/ui/components/BaseTextInput.test.tsx
@@ -0,0 +1,97 @@
+/**
+ * @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 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('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);
+ });
+
+ 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/components/BaseTextInput.tsx b/packages/cli/src/ui/components/BaseTextInput.tsx
index a0e44591ac..df15b564a8 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_RENDER_MODE](key)) {
+ return;
+ }
+
// ── Standard readline shortcuts ──
// Submit (Enter, no modifiers)
diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx
index 4c00e7b2a1..d5ba64dff4 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.test.tsx b/packages/cli/src/ui/components/MainContent.test.tsx
index 531d9bc39d..0574f08e04 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 8195249387..c8f773f6bc 100644
--- a/packages/cli/src/ui/components/MainContent.tsx
+++ b/packages/cli/src/ui/components/MainContent.tsx
@@ -17,6 +17,10 @@ import { useAppContext } from '../contexts/AppContext.js';
import { AppHeader } from './AppHeader.js';
import { DebugModeNotification } from './DebugModeNotification.js';
import { useCompactMode } from '../contexts/CompactModeContext.js';
+import {
+ countMarkdownSourceBlocks,
+ type MarkdownSourceCopyIndexOffsets,
+} from '../utils/MarkdownDisplay.js';
import {
isForceExpandGroup,
mergeCompactToolGroups,
@@ -28,6 +32,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();
@@ -177,51 +209,118 @@ export const MainContent = () => {
prevMergedLengthRef.current = currMLen;
}, [compactMode, uiState.history, mergedHistory, uiActions]);
+ const { historyItemsWithSourceCopyOffsets, pendingStartSourceCopyOffsets } =
+ useMemo(() => {
+ let runningOffsets = createEmptySourceCopyOffsets();
+
+ 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);
+ 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 };
+ });
+ }, [pendingHistoryItems, pendingStartSourceCopyOffsets]);
+
return (
<>
+ {/*
+ renderMode is intentionally omitted here. AppContainer calls
+ refreshStatic() when renderMode changes, which updates
+ historyRemountKey; including both would remount Static twice.
+ */}
,
,
,
- ...mergedHistory.map((h) => (
-
- )),
+ ...historyItemsWithSourceCopyOffsets.map(
+ ({ item: h, sourceCopyIndexOffsets }) => (
+
+ ),
+ ),
]}
>
{(item) => item}
- {pendingHistoryItems.map((item, i) => (
-
- ))}
+ {pendingHistoryItemsWithSourceCopyOffsets.map(
+ ({ item, sourceCopyIndexOffsets }, i) => (
+
+ ),
+ )}
diff --git a/packages/cli/src/ui/components/messages/ConversationMessages.tsx b/packages/cli/src/ui/components/messages/ConversationMessages.tsx
index 526bc9cfe6..b8d77596f1 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/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx
index 7354230656..9ec371a255 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('preserves typed µ as printable text', () => {
+ 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: 'µ',
+ meta: false,
+ 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/RenderModeContext.tsx b/packages/cli/src/ui/contexts/RenderModeContext.tsx
new file mode 100644
index 0000000000..e3597ea0f2
--- /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/contexts/TerminalOutputContext.tsx b/packages/cli/src/ui/contexts/TerminalOutputContext.tsx
new file mode 100644
index 0000000000..076ce19485
--- /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 d98cc2dceb..8c76838513 100644
--- a/packages/cli/src/ui/keyMatchers.test.ts
+++ b/packages/cli/src/ui/keyMatchers.test.ts
@@ -61,6 +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',
[Command.REVERSE_SEARCH]: (key: Key) => key.ctrl && key.name === 'r',
[Command.SUBMIT_REVERSE_SEARCH]: (key: Key) =>
key.name === 'return' && !key.ctrl,
@@ -286,6 +287,16 @@ describe('keyMatchers', () => {
positive: [createKey('f', { ctrl: true })],
negative: [createKey('f')],
},
+ {
+ command: Command.TOGGLE_RENDER_MODE,
+ positive: [createKey('m', { meta: true })],
+ negative: [
+ createKey('m'),
+ createKey('m', { ctrl: true }),
+ createKey('', { sequence: 'µ' }),
+ createKey('', { sequence: 'µ', paste: true }),
+ ],
+ },
];
describe('Data-driven key binding matches original logic', () => {
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 0000000000..b33d2cc885
--- /dev/null
+++ b/packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx
@@ -0,0 +1,36 @@
+/**
+ * @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$');
+ });
+
+ 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 2403db96f5..e0b853ef22 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,27 +18,47 @@ const STRIKETHROUGH_MARKER_LENGTH = 2; // For "~~")
const INLINE_CODE_MARKER_LENGTH = 1; // For "`"
const UNDERLINE_TAG_START_LENGTH = 3; // For ""
const UNDERLINE_TAG_END_LENGTH = 4; // For ""
+const INLINE_MATH_MARKER_LENGTH = 1; // For "$"
+const INLINE_MATH_MAX_CHARS = 1024;
+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');
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 =
- /(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|.*?<\/u>|https?:\/\/\S+)/g;
+ const inlineRegex = enableInlineMath
+ ? INLINE_MARKDOWN_WITH_MATH_REGEX
+ : INLINE_MARKDOWN_REGEX;
+ inlineRegex.lastIndex = 0;
let match;
while ((match = inlineRegex.exec(text)) !== null) {
@@ -138,6 +159,22 @@ const RenderInlineInternal: React.FC = ({
)}
);
+ } else if (
+ enableInlineMath &&
+ 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 = (
@@ -167,13 +204,23 @@ 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(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');
return stringWidth(cleanText);
diff --git a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx
index fc788e18c7..341e52d9c8 100644
--- a/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx
+++ b/packages/cli/src/ui/utils/MarkdownDisplay.test.tsx
@@ -4,10 +4,12 @@
* 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';
+import { RenderModeProvider } from '../contexts/RenderModeContext.js';
describe('', () => {
const baseProps = {
@@ -165,7 +167,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 +198,7 @@ Some text before.
const { lastFrame } = renderWithProviders(
,
);
- const output = lastFrame();
+ const output = lastFrame() ?? '';
expect(output).toContain('A | B');
expect(output).toContain('C');
});
@@ -209,7 +211,7 @@ next line
const { lastFrame } = renderWithProviders(
,
);
- const output = lastFrame();
+ const output = lastFrame() ?? '';
expect(output).toContain('| just text |');
expect(output).not.toContain('┌');
});
@@ -223,7 +225,7 @@ next line
const { lastFrame } = renderWithProviders(
,
);
- const output = lastFrame();
+ const output = lastFrame() ?? '';
expect(output).toContain('| A | B |');
expect(output).not.toContain('┌');
});
@@ -237,7 +239,7 @@ next line
const { lastFrame } = renderWithProviders(
,
);
- const output = lastFrame();
+ const output = lastFrame() ?? '';
expect(output).toContain('| A | B |');
expect(output).not.toContain('┌');
});
@@ -251,7 +253,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 +362,547 @@ 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('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 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('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 |
+|------|-------|
+| 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(
+ ,
+ );
+ 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('LaTeX block · source: /copy latex 1');
+ 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
+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('source: /copy mermaid 1');
+ expect(output).toContain('Client');
+ expect(output).toContain('API');
+ expect(output).toContain('▶');
+ 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('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 raw 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('keeps enhanced markdown blocks as markdown source in raw mode', () => {
+ const text = `
+| Name | Value |
+|------|-------|
+| Alpha | $x^2$ |
+
+$$
+\\alpha + \\beta
+$$
+
+- [x] Done
+> Important
+`.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).toContain('- [x] Done');
+ expect(output).toContain('> 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', () => {
+ 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
+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('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
+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('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('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
+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
+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).not.toContain('Image rendering unavailable');
+ 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 3c0edcc0e7..89b88f4303 100644
--- a/packages/cli/src/ui/utils/MarkdownDisplay.tsx
+++ b/packages/cli/src/ui/utils/MarkdownDisplay.tsx
@@ -11,6 +11,9 @@ 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';
+import { useRenderMode } from '../contexts/RenderModeContext.js';
interface MarkdownDisplayProps {
text: string;
@@ -18,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
@@ -26,6 +93,73 @@ 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 INLINE_MATH_MAX_CHARS = 1024;
+const TABLE_INLINE_MATH_SPAN_RE = new RegExp(
+ String.raw`(? = ({
text,
@@ -33,24 +167,27 @@ const MarkdownDisplayInternal: React.FC = ({
availableTerminalHeight,
contentWidth,
textColor = theme.text.primary,
+ sourceCopyIndexOffsets,
}) => {
+ const { renderMode } = useRenderMode();
if (!text) return <>>;
+ const renderVisualBlocks = renderMode === 'render';
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,} *$/;
+ const blockquoteRegex = /^ *> ?(.*)$/;
+ const mathFenceRegex = /^ *\$\$ *$/;
const tableRowRegex = /^\s*\|(.+)\|\s*$/;
const tableSeparatorRegex =
/^(?=.*\|)\s*\|?\s*(:?-+:?)\s*(\|\s*(:?-+:?)\s*)*\|?\s*$/;
/** 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(':');
@@ -62,10 +199,20 @@ const MarkdownDisplayInternal: React.FC = ({
const contentBlocks: React.ReactNode[] = [];
let inCodeBlock = false;
+ let codeBlockIndex = 0;
+ let currentCodeBlockIndex = 0;
+ let currentCodeBlockLangIndex = 0;
+ const codeBlockLanguageCounts = new Map(
+ sourceCopyIndexOffsets?.codeBlockLanguageCounts,
+ );
let lastLineEmpty = true;
let codeBlockContent: string[] = [];
let codeBlockLang: string | null = null;
let codeBlockFence = '';
+ let inMathBlock = false;
+ let mathBlockIndex = sourceCopyIndexOffsets?.mathBlockCount ?? 0;
+ let currentMathBlockIndex = 0;
+ let mathBlockContent: string[] = [];
let inTable = false;
let tableRows: string[][] = [];
let tableHeaders: string[] = [];
@@ -93,12 +240,16 @@ const MarkdownDisplayInternal: React.FC = ({
key={key}
content={codeBlockContent}
lang={codeBlockLang}
+ codeBlockIndex={currentCodeBlockIndex}
+ codeBlockLangIndex={currentCodeBlockLangIndex}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
contentWidth={contentWidth}
/>,
);
inCodeBlock = false;
+ currentCodeBlockIndex = 0;
+ currentCodeBlockLangIndex = 0;
codeBlockContent = [];
codeBlockLang = null;
codeBlockFence = '';
@@ -108,30 +259,64 @@ const MarkdownDisplayInternal: React.FC = ({
return;
}
+ if (inMathBlock) {
+ if (mathFenceRegex.test(line)) {
+ addContentBlock(
+ ,
+ );
+ inMathBlock = false;
+ currentMathBlockIndex = 0;
+ 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);
if (codeFenceMatch) {
inCodeBlock = true;
+ codeBlockIndex += 1;
+ currentCodeBlockIndex = codeBlockIndex;
codeBlockFence = codeFenceMatch[1];
- codeBlockLang = codeFenceMatch[2] || null;
- } else if (tableRowMatch && !inTable) {
+ 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 && 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
- 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) {
@@ -143,7 +328,11 @@ const MarkdownDisplayInternal: React.FC = ({
addContentBlock(
-
+
,
);
@@ -153,9 +342,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('');
@@ -174,6 +361,7 @@ const MarkdownDisplayInternal: React.FC = ({
rows={tableRows}
contentWidth={contentWidth}
aligns={tableAligns}
+ enableInlineMath={renderVisualBlocks}
/>,
);
}
@@ -187,7 +375,11 @@ const MarkdownDisplayInternal: React.FC = ({
addContentBlock(
-
+
,
);
@@ -198,6 +390,15 @@ const MarkdownDisplayInternal: React.FC = ({
---
,
);
+ } else if (blockquoteMatch && renderVisualBlocks) {
+ addContentBlock(
+ ,
+ );
} else if (headerMatch) {
const level = headerMatch[1].length;
const headerText = headerMatch[2];
@@ -206,35 +407,55 @@ const MarkdownDisplayInternal: React.FC = ({
case 1:
headerNode = (
-
+
);
break;
case 2:
headerNode = (
-
+
);
break;
case 3:
headerNode = (
-
+
);
break;
case 4:
headerNode = (
-
+
);
break;
default:
headerNode = (
-
+
);
break;
@@ -252,6 +473,7 @@ const MarkdownDisplayInternal: React.FC = ({
marker={marker}
leadingWhitespace={leadingWhitespace}
textColor={textColor}
+ renderVisualBlocks={renderVisualBlocks}
/>,
);
} else if (olMatch) {
@@ -266,6 +488,7 @@ const MarkdownDisplayInternal: React.FC = ({
marker={marker}
leadingWhitespace={leadingWhitespace}
textColor={textColor}
+ renderVisualBlocks={renderVisualBlocks}
/>,
);
} else {
@@ -280,7 +503,11 @@ const MarkdownDisplayInternal: React.FC = ({
addContentBlock(
-
+
,
);
@@ -294,6 +521,8 @@ const MarkdownDisplayInternal: React.FC = ({
key="line-eof"
content={codeBlockContent}
lang={codeBlockLang}
+ codeBlockIndex={currentCodeBlockIndex}
+ codeBlockLangIndex={currentCodeBlockLangIndex}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
contentWidth={contentWidth}
@@ -301,6 +530,19 @@ const MarkdownDisplayInternal: React.FC = ({
);
}
+ if (inMathBlock) {
+ addContentBlock(
+ ,
+ );
+ }
+
// Handle table at end of content
if (inTable && tableHeaders.length > 0 && tableRows.length > 0) {
addContentBlock(
@@ -310,6 +552,7 @@ const MarkdownDisplayInternal: React.FC = ({
rows={tableRows}
contentWidth={contentWidth}
aligns={tableAligns}
+ enableInlineMath={renderVisualBlocks}
/>,
);
}
@@ -322,6 +565,8 @@ const MarkdownDisplayInternal: React.FC = ({
interface RenderCodeBlockProps {
content: string[];
lang: string | null;
+ codeBlockIndex: number;
+ codeBlockLangIndex: number;
isPending: boolean;
availableTerminalHeight?: number;
contentWidth: number;
@@ -330,14 +575,41 @@ interface RenderCodeBlockProps {
const RenderCodeBlockInternal: React.FC = ({
content,
lang,
+ codeBlockIndex,
+ codeBlockLangIndex,
isPending,
availableTerminalHeight,
contentWidth,
}) => {
const settings = useSettings();
+ 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' && renderMode === 'render') {
+ if (isPending) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ }
+
+ const fullContent = content.join('\n');
+
if (isPending && availableTerminalHeight !== undefined) {
const MAX_CODE_LINES_WHEN_PENDING = Math.max(
0,
@@ -373,7 +645,6 @@ const RenderCodeBlockInternal: React.FC = ({
}
}
- const fullContent = content.join('\n');
const colorizedCode = colorizeCode(
fullContent,
lang,
@@ -397,12 +668,140 @@ 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[];
+ 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 (
+
+
+ LaTeX block · source: {sourceCopyCommand}
+
+
+ {rendered}
+
+
+ );
+};
+
+const RenderMathBlock = React.memo(RenderMathBlockInternal);
+
+interface RenderBlockquoteProps {
+ quoteText: string;
+ textColor?: string;
+ enableInlineMath?: boolean;
+}
+
+const RenderBlockquoteInternal: React.FC = ({
+ quoteText,
+ textColor = theme.text.primary,
+ enableInlineMath = true,
+}) => (
+
+ │
+
+
+
+
+
+
+);
+
+const RenderBlockquote = React.memo(RenderBlockquoteInternal);
+
interface RenderListItemProps {
itemText: string;
type: 'ul' | 'ol';
marker: string;
leadingWhitespace?: string;
textColor?: string;
+ renderVisualBlocks?: boolean;
}
const RenderListItemInternal: React.FC = ({
@@ -411,8 +810,17 @@ const RenderListItemInternal: React.FC = ({
marker,
leadingWhitespace = '',
textColor = theme.text.primary,
+ renderVisualBlocks = true,
}) => {
- const prefix = type === 'ol' ? `${marker}. ` : `${marker} `;
+ const taskMatch = itemText.match(/^\[([ xX])\]\s+(.*)$/);
+ const isTaskItem = taskMatch !== null && renderVisualBlocks;
+ const isTaskChecked = taskMatch?.[1]?.toLowerCase() === 'x';
+ const effectiveItemText = isTaskItem ? taskMatch[2] : itemText;
+ const prefix = isTaskItem
+ ? `${isTaskChecked ? '✓' : '○'} `
+ : type === 'ol'
+ ? `${marker}. `
+ : `${marker} `;
const prefixWidth = prefix.length;
const indentation = leadingWhitespace.length;
@@ -426,7 +834,11 @@ const RenderListItemInternal: React.FC = ({
-
+
@@ -440,6 +852,7 @@ interface RenderTableProps {
rows: string[][];
contentWidth: number;
aligns?: ColumnAlign[];
+ enableInlineMath?: boolean;
}
const RenderTableInternal: React.FC = ({
@@ -447,12 +860,14 @@ const RenderTableInternal: React.FC = ({
rows,
contentWidth,
aligns,
+ enableInlineMath = false,
}) => (
);
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 0000000000..563717a334
--- /dev/null
+++ b/packages/cli/src/ui/utils/MermaidDiagram.test.tsx
@@ -0,0 +1,167 @@
+/**
+ * @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 emit delayed iTerm2 image sequences from the TUI', 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(mockedRenderMermaidImageAsync).toHaveBeenCalled();
+ });
+ expect(writeRaw).not.toHaveBeenCalled();
+ });
+
+ 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();
+ });
+
+ it('restarts 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}
+ />,
+ );
+
+ 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 () => {
+ 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
new file mode 100644
index 0000000000..7867152aa9
--- /dev/null
+++ b/packages/cli/src/ui/utils/MermaidDiagram.tsx
@@ -0,0 +1,218 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import React from 'react';
+import { Box, Text } from 'ink';
+import { theme } from '../semantic-colors.js';
+import { MaxSizedBox } from '../components/shared/MaxSizedBox.js';
+import { renderMermaidVisual } from './mermaidVisualRenderer.js';
+import {
+ renderMermaidImageAsync,
+ type MermaidImageRenderResult,
+} from './mermaidImageRenderer.js';
+import { useTerminalOutput } from '../contexts/TerminalOutputContext.js';
+
+interface MermaidDiagramProps {
+ source: string;
+ sourceCopyCommand: string;
+ contentWidth: number;
+ isPending: boolean;
+ availableTerminalHeight?: number;
+}
+
+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,
+ contentWidth,
+ isPending,
+ availableTerminalHeight,
+}) => {
+ const writeRaw = useTerminalOutput();
+ const preparedTerminalImageSequence = React.useRef(null);
+ const [imageState, setImageState] = React.useState(
+ null,
+ );
+ const innerWidth = Math.max(8, contentWidth - MERMAID_PADDING);
+ const imageKey = `${source}\0${innerWidth}\0${
+ availableTerminalHeight ?? 'auto'
+ }`;
+ const image =
+ imageState?.key === imageKey && !isPending ? imageState.result : null;
+ const visual = React.useMemo(
+ () => renderMermaidVisual(source, innerWidth),
+ [source, innerWidth],
+ );
+
+ React.useEffect(() => {
+ if (isPending) {
+ setImageState(null);
+ return;
+ }
+
+ let cancelled = false;
+ const abortController = new AbortController();
+ void renderMermaidImageAsync({
+ source,
+ contentWidth: innerWidth,
+ availableTerminalHeight,
+ signal: abortController.signal,
+ }).then(
+ (result) => {
+ if (!cancelled) {
+ setImageState({ key: imageKey, result });
+ }
+ },
+ (error: unknown) => {
+ if (!cancelled) {
+ setImageState({
+ key: imageKey,
+ result: {
+ kind: 'unavailable',
+ reason: getRenderErrorReason(error),
+ },
+ });
+ }
+ },
+ );
+
+ return () => {
+ cancelled = true;
+ abortController.abort();
+ };
+ }, [availableTerminalHeight, imageKey, innerWidth, isPending, source]);
+
+ const kittySequence =
+ image?.kind === 'terminal-image' &&
+ image.protocol === 'kitty' &&
+ image.placeholder
+ ? image.sequence
+ : null;
+
+ React.useEffect(() => {
+ preparedTerminalImageSequence.current = null;
+ }, [imageKey]);
+
+ React.useEffect(() => {
+ if (
+ !kittySequence ||
+ preparedTerminalImageSequence.current === kittySequence
+ ) {
+ return;
+ }
+ preparedTerminalImageSequence.current = kittySequence;
+ process.nextTick(() => writeRaw(kittySequence));
+ }, [kittySequence, writeRaw]);
+
+ const titleWithSourceHint = (title: string) =>
+ `${title} · source: ${sourceCopyCommand}`;
+
+ if (
+ image?.kind === 'terminal-image' &&
+ image.protocol === 'kitty' &&
+ image.placeholder
+ ) {
+ return (
+
+
+ {titleWithSourceHint(visual.title)}
+
+
+ {image.placeholder.lines.map((line, index) => (
+
+
+ {line}
+
+
+ ))}
+
+
+ );
+ }
+
+ if (image?.kind === 'ansi') {
+ return (
+
+
+ {titleWithSourceHint(visual.title)}
+
+
+ {image.lines.map((line, index) => (
+
+ {line || ' '}
+
+ ))}
+
+
+ );
+ }
+
+ return (
+
+
+ {titleWithSourceHint(visual.title)}
+
+
+ {visual.lines.map((line, index) => (
+
+ {line || ' '}
+
+ ))}
+
+ {visual.warning && (
+
+ {visual.warning}
+
+ )}
+ {!isPending &&
+ image?.kind === 'unavailable' &&
+ image.showReason !== false && (
+
+ Image rendering unavailable: {image.reason}
+
+ )}
+
+ );
+};
+
+export const MermaidDiagram = React.memo(MermaidDiagramInternal);
diff --git a/packages/cli/src/ui/utils/TableRenderer.tsx b/packages/cli/src/ui/utils/TableRenderer.tsx
index 6d647980a1..e65ed9b108 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, ' ')
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 0000000000..59abf41495
--- /dev/null
+++ b/packages/cli/src/ui/utils/latexRenderer.test.ts
@@ -0,0 +1,38 @@
+/**
+ * @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('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)`,
+ );
+ expect(renderInlineLatex(String.raw`\mathbb{E}[X]`)).toBe(
+ 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
new file mode 100644
index 0000000000..d9fee638b6
--- /dev/null
+++ b/packages/cli/src/ui/utils/latexRenderer.ts
@@ -0,0 +1,243 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+const COMMAND_REPLACEMENTS: Record = {
+ '\\alpha': 'α',
+ '\\beta': 'β',
+ '\\gamma': 'γ',
+ '\\delta': 'δ',
+ '\\epsilon': 'ε',
+ '\\varepsilon': 'ε',
+ '\\theta': 'θ',
+ '\\lambda': 'λ',
+ '\\mu': 'μ',
+ '\\pi': 'π',
+ '\\rho': 'ρ',
+ '\\sigma': 'σ',
+ '\\tau': 'τ',
+ '\\phi': 'φ',
+ '\\varphi': 'φ',
+ '\\omega': 'ω',
+ '\\Gamma': 'Γ',
+ '\\Delta': 'Δ',
+ '\\Theta': 'Θ',
+ '\\Lambda': 'Λ',
+ '\\Pi': 'Π',
+ '\\Sigma': 'Σ',
+ '\\Phi': 'Φ',
+ '\\Omega': 'Ω',
+ '\\sum': 'Σ',
+ '\\prod': '∏',
+ '\\int': '∫',
+ '\\infty': '∞',
+ '\\partial': '∂',
+ '\\sqrt': '√',
+ '\\times': '×',
+ '\\cdot': '·',
+ '\\pm': '±',
+ '\\leq': '≤',
+ '\\geq': '≥',
+ '\\neq': '≠',
+ '\\approx': '≈',
+ '\\rightarrow': '→',
+ '\\to': '→',
+ '\\leftarrow': '←',
+ '\\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 MAX_RENDER_DEPTH = 10;
+
+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('');
+}
+
+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, depth = 0): string {
+ let output = input.trim();
+ if (depth > MAX_RENDER_DEPTH) {
+ return output;
+ }
+
+ output = replaceBraceCommand(
+ output,
+ 'frac',
+ 2,
+ ([numerator, denominator]) =>
+ `${renderInlineLatex(numerator ?? '', depth + 1)}/${renderInlineLatex(
+ denominator ?? '',
+ depth + 1,
+ )}`,
+ );
+
+ output = replaceBraceCommand(
+ output,
+ 'sqrt',
+ 1,
+ ([radicand]) => `√(${renderInlineLatex(radicand ?? '', depth + 1)})`,
+ );
+
+ 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),
+ );
+
+ output = output.replace(
+ COMMAND_REPLACEMENT_REGEX,
+ (command) => COMMAND_REPLACEMENTS[command] ?? command,
+ );
+
+ return output
+ .replace(/\\(?:left|right)\./g, '')
+ .replace(/\\left|\\right/g, '')
+ .replace(/\\,/g, ' ')
+ .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 0000000000..9efa550fad
--- /dev/null
+++ b/packages/cli/src/ui/utils/mermaidImageRenderer.test.ts
@@ -0,0 +1,691 @@
+/**
+ * @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, beforeEach, describe, expect, it } from 'vitest';
+import {
+ buildKittyPlaceholder,
+ detectTerminalImageProtocol,
+ encodeITerm2InlineImage,
+ encodeKittyImage,
+ encodeKittyVirtualImage,
+ readPngSize,
+ renderMermaidImageAsync,
+ renderMermaidImageSync,
+} from './mermaidImageRenderer.js';
+
+const PNG_1X1 = Buffer.from(
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=',
+ 'base64',
+);
+
+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');
+ 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,
+ (bodyLines ?? defaultBodyLines).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', ...(bodyLines ?? defaultBodyLines)].join('\n');
+ fs.writeFileSync(fakeMmdc, command, 'utf8');
+ 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);
+}
+
+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 });
+ }
+});
+
+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('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({
+ 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('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('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(
+ '\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('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);
+ createFakeMmdc(binDir);
+
+ 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: '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 Mermaid through Kitty 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: 'kitty',
+ },
+ });
+
+ expect(result.kind).toBe('terminal-image');
+ 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', () => {
+ 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);
+ 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 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('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);
+ 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('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, [
+ 'let remaining = 80;',
+ 'const writeNext = () => {',
+ ' if (remaining-- <= 0) {',
+ ' process.exit(1);',
+ ' return;',
+ ' }',
+ ' process.stderr.write("x".repeat(1024), writeNext);',
+ '};',
+ 'writeNext();',
+ ]);
+
+ 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('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);
+ createFakeMmdc(binDir);
+
+ 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('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}');
+ });
+
+ 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',
+ );
+ });
+
+ 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
new file mode 100644
index 0000000000..594073a936
--- /dev/null
+++ b/packages/cli/src/ui/utils/mermaidImageRenderer.ts
@@ -0,0 +1,1348 @@
+/**
+ * @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 { spawn, spawnSync } from 'node:child_process';
+
+export type TerminalImageProtocol = 'kitty' | 'iterm2';
+
+export interface MermaidImageRenderOptions {
+ source: string;
+ contentWidth: number;
+ availableTerminalHeight?: number;
+ env?: NodeJS.ProcessEnv;
+ signal?: AbortSignal;
+}
+
+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;
+ showReason?: boolean;
+}
+
+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 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;
+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}',
+ '\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 }
+>();
+let cachedResultsBytes = 0;
+let cachedPngResultsBytes = 0;
+
+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 === 'off' || forced === 'none' || forced === '0') {
+ return null;
+ }
+
+ if (
+ !process.stdout.isTTY ||
+ env['TMUX'] ||
+ env['SSH_TTY'] ||
+ env['SSH_CLIENT']
+ ) {
+ 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() ?? '';
+
+ 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 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: clampedWidth }, (_, 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),
+ };
+}
+
+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' &&
+ 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, 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,
+ 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 = 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),
+ });
+}
+
+export async function renderMermaidImageAsync({
+ source,
+ contentWidth,
+ availableTerminalHeight,
+ env = process.env,
+ signal,
+}: MermaidImageRenderOptions): Promise {
+ if (isMermaidImageRenderingDisabled(env)) {
+ return unavailableImageRenderingDisabled();
+ }
+
+ 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);
+ 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 {
+ 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);
+ 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,
+ });
+ }
+
+ 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,
+ signal,
+ );
+ if (!ansi.ok) {
+ if (signal?.aborted) {
+ return {
+ kind: 'unavailable',
+ reason: ansi.error,
+ };
+ }
+ 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 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,
+ 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,
+ env: NodeJS.ProcessEnv,
+): 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)
+ .update('\0')
+ .update(String(getMermaidCellAspectRatio(env)))
+ .digest('hex');
+}
+
+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);
+ cachedResultsBytes += resultBytes;
+ while (
+ cachedResults.size > CACHE_LIMIT ||
+ cachedResultsBytes > CACHE_BYTE_LIMIT
+ ) {
+ const oldest = cachedResults.keys().next().value;
+ if (!oldest) break;
+ const oldestResult = cachedResults.get(oldest);
+ if (oldestResult) {
+ cachedResultsBytes -= estimateResultBytes(oldestResult);
+ }
+ cachedResults.delete(oldest);
+ }
+ return result;
+}
+
+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);
+ cachedPngResultsBytes += resultBytes;
+ while (
+ cachedPngResults.size > PNG_CACHE_LIMIT ||
+ cachedPngResultsBytes > PNG_CACHE_BYTE_LIMIT
+ ) {
+ const oldest = cachedPngResults.keys().next().value;
+ 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;
+
+ 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}`));
+ }
+ };
+
+ 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)) {
+ const normalizedDir = normalizeExecutableDir(dir);
+ if (
+ !allowLocalRenderers &&
+ (normalizedDir === localRendererDir ||
+ normalizedDir.endsWith(`${path.sep}node_modules${path.sep}.bin`))
+ ) {
+ 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);
+ 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: createRendererChildEnv(env),
+ shell: shouldRunThroughShell(command),
+ timeout: getMermaidRenderTimeout(env),
+ });
+
+ 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.' };
+ }
+ 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 {
+ fs.rmSync(tempDir, { recursive: true, force: true });
+ }
+}
+
+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-'),
+ );
+ 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: createRendererChildEnv(env),
+ shell: shouldRunThroughShell(command),
+ timeout: getMermaidRenderTimeout(env),
+ signal,
+ });
+
+ 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);
+}
+
+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 getMermaidRenderTimeout(env: NodeJS.ProcessEnv): number {
+ const configuredTimeout = Number(env['QWEN_CODE_MERMAID_RENDER_TIMEOUT_MS']);
+ if (Number.isFinite(configuredTimeout) && configuredTimeout > 0) {
+ 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'],
+ );
+ if (Number.isFinite(configuredAspectRatio) && configuredAspectRatio > 0) {
+ return Math.max(0.2, Math.min(configuredAspectRatio, 2));
+ }
+ return 0.5;
+}
+
+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: createRendererChildEnv(env),
+ shell: shouldRunThroughShell(chafa),
+ timeout: getMermaidRenderTimeout(env),
+ },
+ );
+
+ 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 });
+ }
+}
+
+async function renderPngWithChafaAsync(
+ png: Buffer,
+ widthCells: number,
+ 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-'),
+ );
+ 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: createRendererChildEnv(env),
+ shell: shouldRunThroughShell(chafa),
+ timeout: getMermaidRenderTimeout(env),
+ signal,
+ },
+ );
+
+ 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;
+}
+
+interface BoundedRendererOutput {
+ text: string;
+ truncated: boolean;
+}
+
+function runCommand(
+ command: string,
+ args: string[],
+ options: {
+ 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,
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ let stdout: BoundedRendererOutput = { text: '', truncated: false };
+ let stderr: BoundedRendererOutput = { text: '', truncated: false };
+ let settled = false;
+ let killTimer: NodeJS.Timeout | undefined;
+ let terminationRequested = false;
+ const finish = (result: CommandResult) => {
+ if (settled) return;
+ settled = true;
+ 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');
+ child.stdout?.on('data', (chunk: string) => {
+ stdout = appendBoundedRendererOutput(stdout, chunk);
+ });
+ child.stderr?.on('data', (chunk: string) => {
+ stderr = appendBoundedRendererOutput(stderr, chunk);
+ });
+ child.on('error', (error) => {
+ finish({
+ status: null,
+ stdout: finalizeBoundedRendererOutput(stdout),
+ stderr: finalizeBoundedRendererOutput(stderr),
+ error: error.message,
+ });
+ });
+ child.on('close', (status) => {
+ finish({
+ status,
+ stdout: finalizeBoundedRendererOutput(stdout),
+ stderr: finalizeBoundedRendererOutput(stderr),
+ });
+ });
+ });
+}
+
+function appendBoundedRendererOutput(
+ current: BoundedRendererOutput,
+ chunk: string,
+): BoundedRendererOutput {
+ if (current.truncated) {
+ return current;
+ }
+
+ const next = current.text + chunk;
+ if (next.length <= MAX_RENDERER_OUTPUT_CHARS) {
+ return { text: next, truncated: false };
+ }
+
+ return {
+ text:
+ next.slice(
+ 0,
+ MAX_RENDERER_OUTPUT_CHARS - OUTPUT_TRUNCATION_MARKER.length,
+ ) + OUTPUT_TRUNCATION_MARKER,
+ truncated: true,
+ };
+}
+
+function finalizeBoundedRendererOutput(output: BoundedRendererOutput): string {
+ if (!output.truncated || output.text.endsWith(OUTPUT_TRUNCATION_MARKER)) {
+ return output.text;
+ }
+
+ return (
+ output.text.slice(
+ 0,
+ MAX_RENDERER_OUTPUT_CHARS - OUTPUT_TRUNCATION_MARKER.length,
+ ) + OUTPUT_TRUNCATION_MARKER
+ );
+}
+
+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 * getMermaidCellAspectRatio(env),
+ );
+ 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.test.ts b/packages/cli/src/ui/utils/mermaidVisualRenderer.test.ts
new file mode 100644
index 0000000000..8634dbc879
--- /dev/null
+++ b/packages/cli/src/ui/utils/mermaidVisualRenderer.test.ts
@@ -0,0 +1,89 @@
+/**
+ * @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('否');
+ });
+
+ 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}[2J${c1Control}Start] -->|${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(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}[2J${c1Control}unsafe 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(c1Control);
+ expect(output).not.toContain('[2J');
+ });
+});
diff --git a/packages/cli/src/ui/utils/mermaidVisualRenderer.ts b/packages/cli/src/ui/utils/mermaidVisualRenderer.ts
new file mode 100644
index 0000000000..7ed9e81752
--- /dev/null
+++ b/packages/cli/src/ui/utils/mermaidVisualRenderer.ts
@@ -0,0 +1,1588 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import stringWidth from 'string-width';
+import stripAnsi from 'strip-ansi';
+
+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 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 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;
+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;
+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;
+ 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 sanitizeTerminalText(text: string): string {
+ let result = '';
+ for (const char of stripAnsi(text)) {
+ const code = char.charCodeAt(0);
+ if (
+ (code <= 31 && code !== 10) ||
+ code === 127 ||
+ (code >= 128 && code <= 159)
+ ) {
+ continue;
+ }
+ result += char;
+ }
+ return result;
+}
+
+function stripMermaidPunctuation(text: string): string {
+ return sanitizeTerminalText(text)
+ .trim()
+ .replace(/[;,]+$/g, '')
+ .trim();
+}
+
+function normalizePreviewLine(line: string): string {
+ 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 sanitizeTerminalText(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 previewSourceFallback(
+ source: string,
+ contentWidth: number,
+ type: string,
+ warning?: string,
+): MermaidVisualResult {
+ 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) =>
+ 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(/^\|.*?\|/, '')
+ .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 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();
+
+ 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 === '') return existing;
+ if (next === '') return existing;
+ 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 allRawLines = source
+ .split(/\r?\n/)
+ .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;
+ 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: 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.flatMap(parseFlowEdges);
+ const normalizedEdges = normalizeFlowNodeLabels(edges);
+
+ if (normalizedEdges.length === 0) {
+ return previewSourceFallback(
+ source,
+ contentWidth,
+ 'flowchart',
+ 'Flowchart preview supports simple A --> B style edges; showing source instead.',
+ );
+ }
+
+ 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:
+ [
+ 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,
+ };
+}
+
+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 allRawLines = source
+ .split(/\r?\n/)
+ .map(normalizePreviewLine)
+ .filter(
+ (line) =>
+ line.length > 0 &&
+ !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);
+ 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);
+ if (messages.length >= MAX_SEQUENCE_PREVIEW_MESSAGES) {
+ messageBudgetExceeded = true;
+ continue;
+ }
+ 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';
+ 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);
+
+ return {
+ title: 'Mermaid sequence diagram',
+ lines: lines.slice(0, MAX_RENDERED_LINES),
+ warning:
+ [
+ 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,
+ };
+}
+
+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 safeLine = sanitizeTerminalText(line);
+ const indentation = /^\s*/.exec(safeLine)?.[0].length ?? 0;
+ const depth = Math.floor(indentation / 2);
+ return truncateToWidth(
+ `${' '.repeat(depth)}• ${safeLine.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,
+): MermaidVisualResult {
+ 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);
+ }
+ 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 previewSourceFallback(trimmed, contentWidth, type);
+}
diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json
index 804e3bbdd4..62405ab0ef 100644
--- a/packages/vscode-ide-companion/schemas/settings.schema.json
+++ b/packages/vscode-ide-companion/schemas/settings.schema.json
@@ -194,6 +194,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; on macOS the terminal must send Option as Meta. Options: render, raw",
+ "enum": [
+ "render",
+ "raw"
+ ],
+ "default": "render"
+ },
"showCitations": {
"description": "Show citations for generated text in the chat.",
"type": "boolean",