feat(cli): expand TUI markdown rendering (#3680)
Some checks are pending
Qwen Code CI / Lint (push) Waiting to run
Qwen Code CI / Test (push) Blocked by required conditions
Qwen Code CI / Test-1 (push) Blocked by required conditions
Qwen Code CI / Test-2 (push) Blocked by required conditions
Qwen Code CI / Test-3 (push) Blocked by required conditions
Qwen Code CI / Test-4 (push) Blocked by required conditions
Qwen Code CI / Test-5 (push) Blocked by required conditions
Qwen Code CI / Test-6 (push) Blocked by required conditions
Qwen Code CI / Test-7 (push) Blocked by required conditions
Qwen Code CI / Test-8 (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
SDK Python / SDK Python (3.10) (push) Waiting to run
SDK Python / SDK Python (3.11) (push) Waiting to run
SDK Python / SDK Python (3.12) (push) Waiting to run

* feat(cli): expand markdown rendering in tui

* fix(cli): render finalized mermaid images synchronously

* fix(cli): harden markdown rendering paths

* fix(cli): preserve mermaid source fallbacks

* test(cli): make mermaid image renderer mock cross-platform

* fix(cli): run windows mmdc shims through shell

* fix(cli): address markdown rendering review comments

* fix(cli): validate mermaid render timeout

* feat(cli): expose rendered markdown source blocks

* fix(cli): align mermaid source copy controls

* fix(cli): make markdown render toggle visible

* fix(cli): keep markdown render toggle quiet

* fix(cli): generalize markdown render mode

* fix(cli): broaden render mode and copy latex blocks

* fix(cli): align rendered copy source indices

* fix(cli): support copying inline latex expressions

* feat(cli): document markdown render controls

* test(cli): cover markdown render controls

* fix(cli): tighten markdown render fallbacks

* fix(cli): bound mermaid renderer cache

* fix(cli): address markdown render review feedback

* fix(cli): address markdown render review comments

* test(cli): strengthen render mode shortcut coverage

* fix(cli): address mermaid image review comments

* fix(cli): stabilize renderer output truncation

* test(cli): flush fake renderer stderr before exit

* fix(cli): address markdown renderer review feedback

* docs(cli): clarify mermaid image limits

* fix(cli): refresh mermaid images on height resize

* fix(cli): address markdown render review

* chore: revert unrelated review formatting churn

* fix(cli): avoid mermaid regex codeql alert

* fix(cli): silence mermaid operator codeql alert

* fix(cli): render inline math in markdown tables

* fix(cli): harden markdown visual renderers

* fix(cli): strip c1 controls from mermaid previews

---------

Co-authored-by: 秦奇 <gary.gq@alibaba-inc.com>
This commit is contained in:
ChiGao 2026-05-07 16:24:13 +08:00 committed by GitHub
parent b1ec8d64c7
commit 7f0c9791b7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 7378 additions and 99 deletions

View file

@ -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 `<Static>` 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` / `<br>` 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 <node>` 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.

View file

@ -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` |

View file

@ -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',

View file

@ -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.

View file

@ -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

View file

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

View file

@ -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;

View file

@ -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 }],

View file

@ -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');

View file

@ -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',

View file

@ -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 <Box ref={capturedUIState.mainControlsRef} />;
}
@ -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(
<AppContainer
config={mockConfig}
settings={rawSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
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(
<AppContainer
config={mockConfig}
settings={invalidSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
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(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);
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', () => {

View file

@ -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<SetStateAction<RenderMode>>,
): 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<boolean>(
settings.merged.ui?.compactMode ?? false,
);
const configuredRenderMode = settings.merged.ui?.renderMode;
const [renderMode, setRenderMode] = useState<RenderMode>(
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<NodeJS.Timeout | null>(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 (
<UIStateContext.Provider value={uiState}>
@ -2711,9 +2770,13 @@ export const AppContainer = (props: AppContainerProps) => {
}}
>
<CompactModeProvider value={compactModeValue}>
<ShellFocusContext.Provider value={isFocused}>
<App />
</ShellFocusContext.Provider>
<RenderModeProvider value={renderModeValue}>
<TerminalOutputProvider value={writeRaw}>
<ShellFocusContext.Provider value={isFocused}>
<App />
</ShellFocusContext.Provider>
</TerminalOutputProvider>
</RenderModeProvider>
</CompactModeProvider>
</AppContext.Provider>
</ConfigContext.Provider>

View file

@ -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');

View file

@ -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`(?<![\w$])\$(?![\s\d$])(?=[^$\n]{1,${INLINE_MATH_MAX_CHARS}}\S\$)([^$\n]{1,${INLINE_MATH_MAX_CHARS}})\$(?![\w$])`,
'g',
);
function parseFencedCodeBlocks(markdown: string): FencedCodeBlock[] {
const blocks: FencedCodeBlock[] = [];
const lines = markdown.split(/\r?\n/);
const fenceRegex = /^ *(`{3,}|~{3,}) *([^`]*)$/;
const languageCounts = new Map<string, number>();
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);

View file

@ -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>): 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(<BaseTextInput buffer={buffer} onSubmit={vi.fn()} />);
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(<BaseTextInput buffer={buffer} onSubmit={vi.fn()} />);
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(<BaseTextInput buffer={buffer} onSubmit={vi.fn()} />);
const handler = captureKeypressHandler();
const typedKey = makeKey({ name: 'µ', sequence: 'µ' });
handler(typedKey);
expect(buffer.handleInput).toHaveBeenCalledWith(typedKey);
});
});

View file

@ -145,6 +145,10 @@ export const BaseTextInput: React.FC<BaseTextInputProps> = ({
return;
}
if (keyMatchers[Command.TOGGLE_RENDER_MODE](key)) {
return;
}
// ── Standard readline shortcuts ──
// Submit (Enter, no modifiers)

View file

@ -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<HistoryItemDisplayProps> = ({
@ -94,6 +98,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
availableTerminalHeightGemini,
compactLabel,
summaryAbsorbed = false,
sourceCopyIndexOffsets,
}) => {
const marginTop =
item.type === 'gemini_content' || item.type === 'gemini_thought_content'
@ -131,6 +136,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
availableTerminalHeightGemini ?? availableTerminalHeight
}
contentWidth={contentWidth}
sourceCopyIndexOffsets={sourceCopyIndexOffsets}
/>
)}
{itemForDisplay.type === 'gemini_content' && (
@ -141,6 +147,7 @@ const HistoryItemDisplayComponent: React.FC<HistoryItemDisplayProps> = ({
availableTerminalHeightGemini ?? availableTerminalHeight
}
contentWidth={contentWidth}
sourceCopyIndexOffsets={sourceCopyIndexOffsets}
/>
)}
{!compactMode && itemForDisplay.type === 'gemini_thought' && (

View file

@ -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 } }) => (
<Text>{`HISTORY:${item.id}`}</Text>
),
HistoryItemDisplay: (props: { item: { id: number } }) => {
historyItemDisplayPropsSpy(props);
return <Text>{`HISTORY:${props.item.id}`}</Text>;
},
}));
vi.mock('./ShowMoreLines.js', () => ({
@ -201,6 +203,7 @@ describe('<MainContent />', () => {
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('<MainContent />', () => {
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);
});
});

View file

@ -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<string, number>(),
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.
*/}
<Static
key={historyRemountKey}
key={`${historyRemountKey}-${uiState.currentModel}`}
items={[
<AppHeader key="app-header" version={version} />,
<DebugModeNotification key="debug-notification" />,
<Notifications key="notifications" />,
...mergedHistory.map((h) => (
<HistoryItemDisplay
terminalWidth={terminalWidth}
mainAreaWidth={mainAreaWidth}
availableTerminalHeight={staticAreaMaxItemHeight}
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
key={h.id}
item={h}
isPending={false}
commands={uiState.slashCommands}
compactLabel={getCompactLabel(h)}
summaryAbsorbed={isSummaryAbsorbed(h)}
/>
)),
...historyItemsWithSourceCopyOffsets.map(
({ item: h, sourceCopyIndexOffsets }) => (
<HistoryItemDisplay
terminalWidth={terminalWidth}
mainAreaWidth={mainAreaWidth}
availableTerminalHeight={staticAreaMaxItemHeight}
availableTerminalHeightGemini={MAX_GEMINI_MESSAGE_LINES}
key={h.id}
item={h}
isPending={false}
commands={uiState.slashCommands}
compactLabel={getCompactLabel(h)}
summaryAbsorbed={isSummaryAbsorbed(h)}
sourceCopyIndexOffsets={sourceCopyIndexOffsets}
/>
),
),
]}
>
{(item) => item}
</Static>
<OverflowProvider>
<Box flexDirection="column">
{pendingHistoryItems.map((item, i) => (
<HistoryItemDisplay
key={i}
availableTerminalHeight={
uiState.constrainHeight ? availableTerminalHeight : undefined
}
terminalWidth={terminalWidth}
mainAreaWidth={mainAreaWidth}
item={{ ...item, id: 0 }}
isPending={true}
isFocused={!uiState.isEditorDialogOpen}
activeShellPtyId={uiState.activePtyId}
embeddedShellFocused={uiState.embeddedShellFocused}
compactLabel={getCompactLabel(item)}
summaryAbsorbed={isSummaryAbsorbed(item)}
/>
))}
{pendingHistoryItemsWithSourceCopyOffsets.map(
({ item, sourceCopyIndexOffsets }, i) => (
<HistoryItemDisplay
key={i}
availableTerminalHeight={
uiState.constrainHeight ? availableTerminalHeight : undefined
}
terminalWidth={terminalWidth}
mainAreaWidth={mainAreaWidth}
item={{ ...item, id: 0 }}
isPending={true}
isFocused={!uiState.isEditorDialogOpen}
activeShellPtyId={uiState.activePtyId}
embeddedShellFocused={uiState.embeddedShellFocused}
compactLabel={getCompactLabel(item)}
summaryAbsorbed={isSummaryAbsorbed(item)}
sourceCopyIndexOffsets={sourceCopyIndexOffsets}
/>
),
)}
<ShowMoreLines constrainHeight={uiState.constrainHeight} />
</Box>
</OverflowProvider>

View file

@ -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<PrefixedMarkdownMessageProps> = ({
contentWidth,
ariaLabel,
textColor,
sourceCopyIndexOffsets,
}) => {
const prefixWidth = getPrefixWidth(prefix);
@ -143,6 +151,7 @@ const PrefixedMarkdownMessage: React.FC<PrefixedMarkdownMessageProps> = ({
availableTerminalHeight={availableTerminalHeight}
contentWidth={contentWidth - prefixWidth}
textColor={textColor}
sourceCopyIndexOffsets={sourceCopyIndexOffsets}
/>
</Box>
</Box>
@ -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}
/>
</Box>
);
@ -203,6 +214,7 @@ export const AssistantMessage: React.FC<AssistantMessageProps> = ({
isPending,
availableTerminalHeight,
contentWidth,
sourceCopyIndexOffsets,
}) => (
<PrefixedMarkdownMessage
text={text}
@ -212,18 +224,26 @@ export const AssistantMessage: React.FC<AssistantMessageProps> = ({
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
contentWidth={contentWidth}
sourceCopyIndexOffsets={sourceCopyIndexOffsets}
/>
);
export const AssistantMessageContent: React.FC<
AssistantMessageContentProps
> = ({ text, isPending, availableTerminalHeight, contentWidth }) => (
> = ({
text,
isPending,
availableTerminalHeight,
contentWidth,
sourceCopyIndexOffsets,
}) => (
<ContinuationMarkdownMessage
text={text}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
contentWidth={contentWidth}
basePrefix="✦"
sourceCopyIndexOffsets={sourceCopyIndexOffsets}
/>
);

View file

@ -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();

View file

@ -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<React.SetStateAction<RenderMode>>;
}
const RenderModeContext = React.createContext<RenderModeContextValue>({
renderMode: 'render',
setRenderMode: () => undefined,
});
export const RenderModeProvider = RenderModeContext.Provider;
export function useRenderMode(): RenderModeContextValue {
return React.useContext(RenderModeContext);
}

View file

@ -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<WriteTerminalRaw>(defaultWriteRaw);
export const TerminalOutputProvider: FC<{
value: WriteTerminalRaw;
children: ReactNode;
}> = ({ value, children }) => (
<TerminalOutputContext.Provider value={value}>
{children}
</TerminalOutputContext.Provider>
);
export function useTerminalOutput(): WriteTerminalRaw {
return useContext(TerminalOutputContext);
}

View file

@ -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', () => {

View file

@ -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('<RenderInline />', () => {
it('leaves shell-style dollar variables untouched by default', () => {
const { lastFrame } = renderWithProviders(
<RenderInline text="echo $HOME && echo $PATH" />,
);
expect(lastFrame()).toContain('echo $HOME && echo $PATH');
});
it('renders inline math only when explicitly enabled', () => {
const { lastFrame } = renderWithProviders(
<RenderInline text="value $\\alpha$" enableInlineMath />,
);
expect(lastFrame()).toContain('α');
expect(lastFrame()).not.toContain('$\\alpha$');
});
it('does not parse ordinary dollar amounts as inline math', () => {
const { lastFrame } = renderWithProviders(
<RenderInline text="cost is $5 and $10 later" enableInlineMath />,
);
expect(lastFrame()).toContain('cost is $5 and $10 later');
});
});

View file

@ -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 "<u>"
const UNDERLINE_TAG_END_LENGTH = 4; // For "</u>"
const INLINE_MATH_MARKER_LENGTH = 1; // For "$"
const INLINE_MATH_MAX_CHARS = 1024;
const INLINE_MATH_PATTERN = new RegExp(
String.raw`(?<![\w$])\$(?![\s\d$])(?=[^$\n]{1,${INLINE_MATH_MAX_CHARS}}\S\$)[^$\n]{1,${INLINE_MATH_MAX_CHARS}}\$(?![\w$])`,
'g',
);
const INLINE_MARKDOWN_REGEX =
/(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|<u>.*?<\/u>|https?:\/\/\S+)/g;
const INLINE_MARKDOWN_WITH_MATH_REGEX = new RegExp(
String.raw`(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|` +
String.raw`\`+.+?\`+|(?<![\w$])\$(?![\s\d$])(?=[^$\n]{1,${INLINE_MATH_MAX_CHARS}}\S\$)[^$\n]{1,${INLINE_MATH_MAX_CHARS}}\$(?![\w$])|<u>.*?<\/u>|https?:\/\/\S+)`,
'g',
);
const debugLogger = createDebugLogger('INLINE_MARKDOWN');
interface RenderInlineProps {
text: string;
textColor?: string;
enableInlineMath?: boolean;
}
const RenderInlineInternal: React.FC<RenderInlineProps> = ({
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 color={textColor}>{text}</Text>;
}
const nodes: React.ReactNode[] = [];
let lastIndex = 0;
const inlineRegex =
/(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|<u>.*?<\/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<RenderInlineProps> = ({
)}
</Text>
);
} else if (
enableInlineMath &&
fullMatch.startsWith('$') &&
fullMatch.endsWith('$') &&
fullMatch.length > INLINE_MATH_MARKER_LENGTH * 2
) {
renderedNode = (
<Text key={key} color={theme.text.accent}>
{renderInlineLatex(
fullMatch.slice(
INLINE_MATH_MARKER_LENGTH,
-INLINE_MATH_MARKER_LENGTH,
),
)}
</Text>
);
} else if (fullMatch.match(/^https?:\/\//)) {
renderedNode = (
<Text key={key} color={theme.text.link}>
@ -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>(.*?)<\/u>/g, '$1')
.replace(/.*\[(.*?)\]\(.*\)/g, '$1');
return stringWidth(cleanText);

View file

@ -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('<MarkdownDisplay />', () => {
const baseProps = {
@ -165,7 +167,7 @@ Some text before.
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
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(
<MarkdownDisplay {...baseProps} text={text} />,
);
const output = lastFrame();
const output = lastFrame() ?? '';
expect(output).toContain('A | B');
expect(output).toContain('C');
});
@ -209,7 +211,7 @@ next line
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
const output = lastFrame();
const output = lastFrame() ?? '';
expect(output).toContain('| just text |');
expect(output).not.toContain('┌');
});
@ -223,7 +225,7 @@ next line
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
const output = lastFrame();
const output = lastFrame() ?? '';
expect(output).toContain('| A | B |');
expect(output).not.toContain('┌');
});
@ -237,7 +239,7 @@ next line
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
const output = lastFrame();
const output = lastFrame() ?? '';
expect(output).toContain('| A | B |');
expect(output).not.toContain('┌');
});
@ -251,7 +253,7 @@ data
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
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(
<MarkdownDisplay {...baseProps} text={text} />,
);
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(
<MarkdownDisplay {...baseProps} text={text} />,
);
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(
<MarkdownDisplay {...baseProps} text={text} />,
);
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(
<MarkdownDisplay {...baseProps} text={text} />,
);
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(
<MarkdownDisplay {...baseProps} text={text} />,
);
expect(lastFrame()).toContain('$5 and $10 later');
});
it('renders blockquotes as quoted text', () => {
const text = '> Important **note**'.replace(/\n/g, eol);
const { lastFrame } = renderWithProviders(
<MarkdownDisplay {...baseProps} text={text} />,
);
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(
<MarkdownDisplay {...baseProps} text={text} />,
);
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(
<MarkdownDisplay {...baseProps} text={text} />,
);
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(
<MarkdownDisplay {...baseProps} text={text} />,
);
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(
<MarkdownDisplay {...baseProps} text={text} />,
);
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(
<MarkdownDisplay {...baseProps} text={text} />,
);
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(
<RenderModeProvider
value={{
renderMode: 'raw',
setRenderMode: () => undefined,
}}
>
<MarkdownDisplay {...baseProps} text={text} />
</RenderModeProvider>,
);
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(
<RenderModeProvider
value={{
renderMode: 'raw',
setRenderMode: () => undefined,
}}
>
<MarkdownDisplay {...baseProps} text={text} />
</RenderModeProvider>,
);
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(
<MarkdownDisplay
{...baseProps}
text={text}
sourceCopyIndexOffsets={{
codeBlockLanguageCounts: new Map([['mermaid', 1]]),
mathBlockCount: 2,
}}
/>,
);
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(
<MarkdownDisplay {...baseProps} text={text} />,
);
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(
<MarkdownDisplay {...baseProps} text={text} />,
);
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(
<MarkdownDisplay {...baseProps} text={text} />,
);
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(
<MarkdownDisplay {...baseProps} text={text} />,
);
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(
<MarkdownDisplay {...baseProps} text={text} isPending={false} />,
);
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(
<MarkdownDisplay {...baseProps} text={text} isPending={true} />,
);
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', () => {

View file

@ -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<string, number>;
mathBlockCount: number;
}
export interface MarkdownSourceBlockCounts {
codeBlockLanguageCounts: Map<string, number>;
mathBlockCount: number;
}
export function countMarkdownSourceBlocks(
text: string,
): MarkdownSourceBlockCounts {
const codeBlockLanguageCounts = new Map<string, number>();
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`(?<![\w$])\$(?![\s\d$])(?=[^$\n]{1,${INLINE_MATH_MAX_CHARS}}\S\$)[^$\n]{1,${INLINE_MATH_MAX_CHARS}}\$(?![\w$])`,
'y',
);
function readTableInlineMathSpan(row: string, index: number): string | null {
TABLE_INLINE_MATH_SPAN_RE.lastIndex = index;
return TABLE_INLINE_MATH_SPAN_RE.exec(row)?.[0] ?? null;
}
function splitMarkdownTableRow(row: string): string[] {
const cells: string[] = [];
let current = '';
let activeCodeFenceLength = 0;
for (let index = 0; index < row.length; index++) {
const char = row[index]!;
if (char === '\\') {
const next = row[index + 1];
if (next === '|') {
current += '|';
index += 1;
continue;
}
current += char;
continue;
}
if (char === '`') {
let runLength = 1;
while (row[index + runLength] === '`') {
runLength += 1;
}
if (activeCodeFenceLength === 0) {
activeCodeFenceLength = runLength;
} else if (runLength === activeCodeFenceLength) {
activeCodeFenceLength = 0;
}
current += '`'.repeat(runLength);
index += runLength - 1;
continue;
}
if (char === '$' && activeCodeFenceLength === 0) {
const mathSpan = readTableInlineMathSpan(row, index);
if (mathSpan) {
current += mathSpan;
index += mathSpan.length - 1;
continue;
}
}
if (char === '|' && activeCodeFenceLength === 0) {
cells.push(current.trim());
current = '';
continue;
}
current += char;
}
cells.push(current.trim());
return cells;
}
const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
text,
@ -33,24 +167,27 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
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(/(?<!\\)\|/)
.map((cell) => cell.trim())
splitMarkdownTableRow(line)
.filter((cell) => cell.length > 0)
.map((cell) => {
const startsWithColon = cell.startsWith(':');
@ -62,10 +199,20 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
const contentBlocks: React.ReactNode[] = [];
let inCodeBlock = false;
let codeBlockIndex = 0;
let currentCodeBlockIndex = 0;
let currentCodeBlockLangIndex = 0;
const codeBlockLanguageCounts = new Map<string, number>(
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<MarkdownDisplayProps> = ({
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<MarkdownDisplayProps> = ({
return;
}
if (inMathBlock) {
if (mathFenceRegex.test(line)) {
addContentBlock(
<RenderMathBlock
key={key}
content={mathBlockContent}
sourceCopyCommand={`/copy latex ${currentMathBlockIndex}`}
contentWidth={contentWidth}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
/>,
);
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(/(?<!\\)\|/)
.map((cell) => 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(/(?<!\\)\|/)
.map((c) => 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<MarkdownDisplayProps> = ({
addContentBlock(
<Box key={key}>
<Text wrap="wrap">
<RenderInline text={line} textColor={textColor} />
<RenderInline
text={line}
textColor={textColor}
enableInlineMath={renderVisualBlocks}
/>
</Text>
</Box>,
);
@ -153,9 +342,7 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
tableAligns = parseTableAligns(line);
} else if (inTable && tableRowMatch) {
// Add table row
const cells = tableRowMatch[1]
.split(/(?<!\\)\|/)
.map((cell) => 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<MarkdownDisplayProps> = ({
rows={tableRows}
contentWidth={contentWidth}
aligns={tableAligns}
enableInlineMath={renderVisualBlocks}
/>,
);
}
@ -187,7 +375,11 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
addContentBlock(
<Box key={key}>
<Text wrap="wrap">
<RenderInline text={line} textColor={textColor} />
<RenderInline
text={line}
textColor={textColor}
enableInlineMath={renderVisualBlocks}
/>
</Text>
</Box>,
);
@ -198,6 +390,15 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
<Text dimColor>---</Text>
</Box>,
);
} else if (blockquoteMatch && renderVisualBlocks) {
addContentBlock(
<RenderBlockquote
key={key}
quoteText={blockquoteMatch[1]}
textColor={textColor}
enableInlineMath={renderVisualBlocks}
/>,
);
} else if (headerMatch) {
const level = headerMatch[1].length;
const headerText = headerMatch[2];
@ -206,35 +407,55 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
case 1:
headerNode = (
<Text bold color={textColor}>
<RenderInline text={headerText} textColor={textColor} />
<RenderInline
text={headerText}
textColor={textColor}
enableInlineMath={renderVisualBlocks}
/>
</Text>
);
break;
case 2:
headerNode = (
<Text bold color={textColor}>
<RenderInline text={headerText} textColor={textColor} />
<RenderInline
text={headerText}
textColor={textColor}
enableInlineMath={renderVisualBlocks}
/>
</Text>
);
break;
case 3:
headerNode = (
<Text bold color={textColor}>
<RenderInline text={headerText} textColor={textColor} />
<RenderInline
text={headerText}
textColor={textColor}
enableInlineMath={renderVisualBlocks}
/>
</Text>
);
break;
case 4:
headerNode = (
<Text italic color={textColor}>
<RenderInline text={headerText} textColor={textColor} />
<RenderInline
text={headerText}
textColor={textColor}
enableInlineMath={renderVisualBlocks}
/>
</Text>
);
break;
default:
headerNode = (
<Text color={textColor}>
<RenderInline text={headerText} textColor={textColor} />
<RenderInline
text={headerText}
textColor={textColor}
enableInlineMath={renderVisualBlocks}
/>
</Text>
);
break;
@ -252,6 +473,7 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
marker={marker}
leadingWhitespace={leadingWhitespace}
textColor={textColor}
renderVisualBlocks={renderVisualBlocks}
/>,
);
} else if (olMatch) {
@ -266,6 +488,7 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
marker={marker}
leadingWhitespace={leadingWhitespace}
textColor={textColor}
renderVisualBlocks={renderVisualBlocks}
/>,
);
} else {
@ -280,7 +503,11 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
addContentBlock(
<Box key={key}>
<Text wrap="wrap" color={textColor}>
<RenderInline text={line} textColor={textColor} />
<RenderInline
text={line}
textColor={textColor}
enableInlineMath={renderVisualBlocks}
/>
</Text>
</Box>,
);
@ -294,6 +521,8 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
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<MarkdownDisplayProps> = ({
);
}
if (inMathBlock) {
addContentBlock(
<RenderMathBlock
key="math-eof"
content={mathBlockContent}
sourceCopyCommand={`/copy latex ${currentMathBlockIndex}`}
contentWidth={contentWidth}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
/>,
);
}
// Handle table at end of content
if (inTable && tableHeaders.length > 0 && tableRows.length > 0) {
addContentBlock(
@ -310,6 +552,7 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
rows={tableRows}
contentWidth={contentWidth}
aligns={tableAligns}
enableInlineMath={renderVisualBlocks}
/>,
);
}
@ -322,6 +565,8 @@ const MarkdownDisplayInternal: React.FC<MarkdownDisplayProps> = ({
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<RenderCodeBlockProps> = ({
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 (
<RenderPendingMermaidBlock
content={content}
availableTerminalHeight={availableTerminalHeight}
contentWidth={contentWidth}
/>
);
}
return (
<MermaidDiagram
source={content.join('\n')}
sourceCopyCommand={`/copy mermaid ${codeBlockLangIndex || codeBlockIndex}`}
isPending={isPending}
availableTerminalHeight={availableTerminalHeight}
contentWidth={contentWidth}
/>
);
}
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<RenderCodeBlockProps> = ({
}
}
const fullContent = content.join('\n');
const colorizedCode = colorizeCode(
fullContent,
lang,
@ -397,12 +668,140 @@ const RenderCodeBlockInternal: React.FC<RenderCodeBlockProps> = ({
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 (
<Box
paddingLeft={CODE_BLOCK_PREFIX_PADDING}
flexDirection="column"
width={contentWidth}
flexShrink={0}
>
<Text color={theme.text.accent}>Mermaid diagram is being written...</Text>
{previewLines.map((line, index) => (
<Text key={index} color={theme.text.secondary} wrap="truncate-end">
{line || ' '}
</Text>
))}
{content.length > previewLines.length && (
<Text color={theme.text.secondary}>... generating more ...</Text>
)}
</Box>
);
};
const RenderPendingMermaidBlock = React.memo(RenderPendingMermaidBlockInternal);
interface RenderMathBlockProps {
content: string[];
sourceCopyCommand: string;
contentWidth: number;
isPending: boolean;
availableTerminalHeight?: number;
}
const RenderMathBlockInternal: React.FC<RenderMathBlockProps> = ({
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 (
<Box
paddingLeft={MATH_BLOCK_PREFIX_PADDING}
flexDirection="column"
width={contentWidth}
flexShrink={0}
>
<Text bold color={theme.text.accent}>
LaTeX block · source: {sourceCopyCommand}
</Text>
{previewLines.map((line, index) => (
<Text key={index} color={theme.text.secondary} wrap="truncate-end">
{line || ' '}
</Text>
))}
<Text color={theme.text.secondary}>... generating more ...</Text>
</Box>
);
}
}
const rendered = renderInlineLatex(content.join(' '));
return (
<Box
paddingLeft={MATH_BLOCK_PREFIX_PADDING}
flexDirection="column"
width={contentWidth}
flexShrink={0}
>
<Text bold color={theme.text.accent}>
LaTeX block · source: {sourceCopyCommand}
</Text>
<Text color={theme.text.accent} wrap="wrap">
{rendered}
</Text>
</Box>
);
};
const RenderMathBlock = React.memo(RenderMathBlockInternal);
interface RenderBlockquoteProps {
quoteText: string;
textColor?: string;
enableInlineMath?: boolean;
}
const RenderBlockquoteInternal: React.FC<RenderBlockquoteProps> = ({
quoteText,
textColor = theme.text.primary,
enableInlineMath = true,
}) => (
<Box paddingLeft={BLOCKQUOTE_PREFIX_PADDING} flexDirection="row">
<Text color={theme.text.secondary}> </Text>
<Box flexGrow={LIST_ITEM_TEXT_FLEX_GROW}>
<Text wrap="wrap" color={textColor} italic>
<RenderInline
text={quoteText}
textColor={textColor}
enableInlineMath={enableInlineMath}
/>
</Text>
</Box>
</Box>
);
const RenderBlockquote = React.memo(RenderBlockquoteInternal);
interface RenderListItemProps {
itemText: string;
type: 'ul' | 'ol';
marker: string;
leadingWhitespace?: string;
textColor?: string;
renderVisualBlocks?: boolean;
}
const RenderListItemInternal: React.FC<RenderListItemProps> = ({
@ -411,8 +810,17 @@ const RenderListItemInternal: React.FC<RenderListItemProps> = ({
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<RenderListItemProps> = ({
</Box>
<Box flexGrow={LIST_ITEM_TEXT_FLEX_GROW}>
<Text wrap="wrap" color={textColor}>
<RenderInline text={itemText} textColor={textColor} />
<RenderInline
text={effectiveItemText}
textColor={textColor}
enableInlineMath={renderVisualBlocks}
/>
</Text>
</Box>
</Box>
@ -440,6 +852,7 @@ interface RenderTableProps {
rows: string[][];
contentWidth: number;
aligns?: ColumnAlign[];
enableInlineMath?: boolean;
}
const RenderTableInternal: React.FC<RenderTableProps> = ({
@ -447,12 +860,14 @@ const RenderTableInternal: React.FC<RenderTableProps> = ({
rows,
contentWidth,
aligns,
enableInlineMath = false,
}) => (
<TableRenderer
headers={headers}
rows={rows}
contentWidth={contentWidth}
aligns={aligns}
enableInlineMath={enableInlineMath}
/>
);

View file

@ -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(
<TerminalOutputProvider value={writeRaw}>
<MermaidDiagram
source={'flowchart TD\nA[Start] --> B[End]'}
sourceCopyCommand="/copy mermaid 1"
contentWidth={80}
isPending={false}
availableTerminalHeight={20}
/>
</TerminalOutputProvider>,
);
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(
<TerminalOutputProvider value={writeRaw}>
<MermaidDiagram
source={'flowchart TD\nA[Start] --> B[End]'}
sourceCopyCommand="/copy mermaid 1"
contentWidth={80}
isPending={false}
availableTerminalHeight={20}
/>
</TerminalOutputProvider>,
);
await vi.waitFor(() => {
expect(mockedRenderMermaidImageAsync).toHaveBeenCalled();
});
expect(writeRaw).not.toHaveBeenCalled();
});
it('does not start image rendering while the Mermaid block is pending', () => {
render(
<MermaidDiagram
source={'flowchart TD\nA[Start] --> 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(
<MermaidDiagram
source={'flowchart TD\nA[Start] --> B[End]'}
sourceCopyCommand="/copy mermaid 1"
contentWidth={80}
isPending={false}
availableTerminalHeight={20}
/>,
);
await vi.waitFor(() => {
expect(mockedRenderMermaidImageAsync).toHaveBeenCalledTimes(1);
});
rerender(
<MermaidDiagram
source={'flowchart TD\nA[Start] --> 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(
<MermaidDiagram
source={'flowchart TD\nA[Start] --> 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',
);
});
});
});

View file

@ -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<MermaidDiagramProps> = ({
source,
sourceCopyCommand,
contentWidth,
isPending,
availableTerminalHeight,
}) => {
const writeRaw = useTerminalOutput();
const preparedTerminalImageSequence = React.useRef<string | null>(null);
const [imageState, setImageState] = React.useState<MermaidImageState | null>(
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 (
<Box
paddingLeft={MERMAID_PADDING}
flexDirection="column"
width={contentWidth}
flexShrink={0}
>
<Text bold color={theme.text.accent}>
{titleWithSourceHint(visual.title)}
</Text>
<MaxSizedBox
maxHeight={availableTerminalHeight}
maxWidth={innerWidth}
overflowDirection="bottom"
>
{image.placeholder.lines.map((line, index) => (
<Box key={index}>
<Text color={image.placeholder!.color} wrap="truncate-end">
{line}
</Text>
</Box>
))}
</MaxSizedBox>
</Box>
);
}
if (image?.kind === 'ansi') {
return (
<Box
paddingLeft={MERMAID_PADDING}
flexDirection="column"
width={contentWidth}
flexShrink={0}
>
<Text bold color={theme.text.accent}>
{titleWithSourceHint(visual.title)}
</Text>
<MaxSizedBox
maxHeight={availableTerminalHeight}
maxWidth={innerWidth}
overflowDirection="bottom"
>
{image.lines.map((line, index) => (
<Box key={index}>
<Text>{line || ' '}</Text>
</Box>
))}
</MaxSizedBox>
</Box>
);
}
return (
<Box
paddingLeft={MERMAID_PADDING}
flexDirection="column"
width={contentWidth}
flexShrink={0}
>
<Text bold color={theme.text.accent}>
{titleWithSourceHint(visual.title)}
</Text>
<MaxSizedBox
maxHeight={availableTerminalHeight}
maxWidth={innerWidth}
overflowDirection="bottom"
>
{visual.lines.map((line, index) => (
<Box key={index}>
<Text color={theme.text.primary}>{line || ' '}</Text>
</Box>
))}
</MaxSizedBox>
{visual.warning && (
<Text color={theme.text.secondary} wrap="wrap">
{visual.warning}
</Text>
)}
{!isPending &&
image?.kind === 'unavailable' &&
image.showReason !== false && (
<Text color={theme.text.secondary} wrap="wrap">
Image rendering unavailable: {image.reason}
</Text>
)}
</Box>
);
};
export const MermaidDiagram = React.memo(MermaidDiagramInternal);

View file

@ -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`(?<![\w$])\$(?![\s\d$])(?=[^$\n]{1,${INLINE_MATH_MAX_CHARS}}\S\$)[^$\n]{1,${INLINE_MATH_MAX_CHARS}}\$(?![\w$])`;
const INLINE_MARKDOWN_REGEX =
/(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|<u>.*?<\/u>|https?:\/\/\S+)/g;
const INLINE_MARKDOWN_WITH_MATH_REGEX = new RegExp(
String.raw`(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|` +
String.raw`\`+.+?\`+|${INLINE_MATH_PATTERN}|<u>.*?<\/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>.*?<\/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('<u>') &&
fullMatch.endsWith('</u>') &&
@ -246,6 +271,7 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
rows,
contentWidth,
aligns,
enableInlineMath = false,
}) => {
const colCount = headers.length;
@ -256,7 +282,7 @@ export const TableRenderer: React.FC<TableRendererProps> = ({
// ── 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<TableRendererProps> = ({
}
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, ' ')

View file

@ -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`);
});
});

View file

@ -0,0 +1,243 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
const COMMAND_REPLACEMENTS: Record<string, string> = {
'\\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<string, string> = {
'0': '⁰',
'1': '¹',
'2': '²',
'3': '³',
'4': '⁴',
'5': '⁵',
'6': '⁶',
'7': '⁷',
'8': '⁸',
'9': '⁹',
'+': '⁺',
'-': '⁻',
'=': '⁼',
'(': '⁽',
')': '⁾',
n: 'ⁿ',
i: 'ⁱ',
};
const SUBSCRIPT: Record<string, string> = {
'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, string>): 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();
}

View file

@ -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');
});
});

File diff suppressed because it is too large Load diff

View file

@ -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');
});
});

File diff suppressed because it is too large Load diff

View file

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