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",