mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
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
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:
parent
b1ec8d64c7
commit
7f0c9791b7
38 changed files with 7378 additions and 99 deletions
236
docs/design/markdown-syntax-extension.md
Normal file
236
docs/design/markdown-syntax-extension.md
Normal 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.
|
||||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
163
docs/users/features/markdown-rendering.md
Normal file
163
docs/users/features/markdown-rendering.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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 }],
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
97
packages/cli/src/ui/components/BaseTextInput.test.tsx
Normal file
97
packages/cli/src/ui/components/BaseTextInput.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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' && (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
25
packages/cli/src/ui/contexts/RenderModeContext.tsx
Normal file
25
packages/cli/src/ui/contexts/RenderModeContext.tsx
Normal 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);
|
||||
}
|
||||
29
packages/cli/src/ui/contexts/TerminalOutputContext.tsx
Normal file
29
packages/cli/src/ui/contexts/TerminalOutputContext.tsx
Normal 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);
|
||||
}
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
36
packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx
Normal file
36
packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
|
|||
167
packages/cli/src/ui/utils/MermaidDiagram.test.tsx
Normal file
167
packages/cli/src/ui/utils/MermaidDiagram.test.tsx
Normal 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',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
218
packages/cli/src/ui/utils/MermaidDiagram.tsx
Normal file
218
packages/cli/src/ui/utils/MermaidDiagram.tsx
Normal 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);
|
||||
|
|
@ -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, ' ')
|
||||
|
|
|
|||
38
packages/cli/src/ui/utils/latexRenderer.test.ts
Normal file
38
packages/cli/src/ui/utils/latexRenderer.test.ts
Normal 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`);
|
||||
});
|
||||
});
|
||||
243
packages/cli/src/ui/utils/latexRenderer.ts
Normal file
243
packages/cli/src/ui/utils/latexRenderer.ts
Normal 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();
|
||||
}
|
||||
691
packages/cli/src/ui/utils/mermaidImageRenderer.test.ts
Normal file
691
packages/cli/src/ui/utils/mermaidImageRenderer.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
1348
packages/cli/src/ui/utils/mermaidImageRenderer.ts
Normal file
1348
packages/cli/src/ui/utils/mermaidImageRenderer.ts
Normal file
File diff suppressed because it is too large
Load diff
89
packages/cli/src/ui/utils/mermaidVisualRenderer.test.ts
Normal file
89
packages/cli/src/ui/utils/mermaidVisualRenderer.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
1588
packages/cli/src/ui/utils/mermaidVisualRenderer.ts
Normal file
1588
packages/cli/src/ui/utils/mermaidVisualRenderer.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue