Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
5b220a5
feat(cli): expand markdown rendering in tui
qinqi-ali Apr 27, 2026
f9e838f
fix(cli): render finalized mermaid images synchronously
qinqi-ali Apr 27, 2026
5839dca
fix(cli): harden markdown rendering paths
qinqi-ali Apr 28, 2026
97a0dea
fix(cli): preserve mermaid source fallbacks
qinqi-ali Apr 28, 2026
c36e1e8
test(cli): make mermaid image renderer mock cross-platform
qinqi-ali Apr 28, 2026
3eafa5f
Merge remote-tracking branch 'origin/main' into codex/markdown-syntax…
qinqi-ali Apr 28, 2026
dc3bc86
fix(cli): run windows mmdc shims through shell
qinqi-ali Apr 28, 2026
bd826de
fix(cli): address markdown rendering review comments
qinqi-ali Apr 28, 2026
1a5793d
fix(cli): validate mermaid render timeout
qinqi-ali Apr 28, 2026
4df35e3
feat(cli): expose rendered markdown source blocks
qinqi-ali Apr 28, 2026
0003a8d
fix(cli): align mermaid source copy controls
qinqi-ali Apr 28, 2026
d0348c5
fix(cli): make markdown render toggle visible
qinqi-ali Apr 28, 2026
398d9a5
fix(cli): keep markdown render toggle quiet
qinqi-ali Apr 28, 2026
96cad6b
fix(cli): generalize markdown render mode
qinqi-ali Apr 28, 2026
f47fc26
fix(cli): broaden render mode and copy latex blocks
qinqi-ali Apr 28, 2026
e032f64
fix(cli): align rendered copy source indices
qinqi-ali Apr 28, 2026
5ad907c
fix(cli): support copying inline latex expressions
qinqi-ali Apr 28, 2026
0b371a2
feat(cli): document markdown render controls
qinqi-ali Apr 28, 2026
06f80c9
test(cli): cover markdown render controls
qinqi-ali Apr 28, 2026
7448a80
fix(cli): tighten markdown render fallbacks
qinqi-ali Apr 28, 2026
2056b99
fix(cli): bound mermaid renderer cache
qinqi-ali Apr 29, 2026
e83d8b3
Merge remote-tracking branch 'origin/main' into codex/markdown-syntax…
qinqi-ali Apr 29, 2026
5aceede
chore: merge main into markdown syntax extension
qinqi-ali May 5, 2026
0e08496
fix(cli): address markdown render review feedback
qinqi-ali May 5, 2026
b28c07f
fix(cli): address markdown render review comments
qinqi-ali May 6, 2026
c32b3ab
test(cli): strengthen render mode shortcut coverage
qinqi-ali May 6, 2026
9410d39
fix(cli): address mermaid image review comments
qinqi-ali May 6, 2026
11c6a3c
fix(cli): stabilize renderer output truncation
qinqi-ali May 6, 2026
5fe276c
test(cli): flush fake renderer stderr before exit
qinqi-ali May 6, 2026
81a1e00
fix(cli): address markdown renderer review feedback
qinqi-ali May 6, 2026
696655d
docs(cli): clarify mermaid image limits
qinqi-ali May 6, 2026
715bb44
fix(cli): refresh mermaid images on height resize
qinqi-ali May 6, 2026
854e395
fix(cli): address markdown render review
qinqi-ali May 7, 2026
dc4def7
chore: revert unrelated review formatting churn
qinqi-ali May 7, 2026
259c610
fix(cli): avoid mermaid regex codeql alert
qinqi-ali May 7, 2026
4bc06e0
fix(cli): silence mermaid operator codeql alert
qinqi-ali May 7, 2026
a450d6a
fix(cli): render inline math in markdown tables
qinqi-ali May 7, 2026
ee3b9dc
fix(cli): harden markdown visual renderers
qinqi-ali May 7, 2026
5b0029c
fix(cli): strip c1 controls from mermaid previews
qinqi-ali May 7, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
207 changes: 207 additions & 0 deletions docs/design/markdown-syntax-extension.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
# 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`.

## 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. A dedicated one-key "copy Mermaid source" interaction is still a
separate UI affordance for a future PR because the current message renderer
does not expose per-block actions.

The fallback is still not a complete Mermaid engine. It is a fast,
dependency-light 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/ui/utils/MarkdownDisplay.test.tsx
npx vitest run src/ui/utils/mermaidImageRenderer.test.ts
```

Broader verification before PR submission:

```bash
npm run typecheck --workspace=packages/cli
npm run lint --workspace=packages/cli
git diff --check
```

Manual scenarios:

- Assistant response with a Mermaid `flowchart LR` block.
- Assistant response with a Mermaid `sequenceDiagram` block.
- Markdown table plus Mermaid in the same answer.
- Fenced JavaScript code block still showing code formatting.
- Narrow terminal width.
- Constrained tool/detail surface.
36 changes: 36 additions & 0 deletions packages/cli/src/ui/utils/InlineMarkdownRenderer.test.tsx
Original file line number Diff line number Diff line change
@@ -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');
});
});
44 changes: 40 additions & 4 deletions packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 "**"
Expand All @@ -17,27 +18,36 @@ 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_PATTERN =
/(?<![\w$])\$(?![\s\d$])(?=[^$\n]{1,240}\S\$)[^$\n]{1,240}\$(?![\w$])/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
Comment thread
chiga0 marked this conversation as resolved.
Resolved
? /(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|(?<![\w$])\$(?![\s\d$])(?=[^$\n]{1,240}\S\$)[^$\n]{1,240}\$(?![\w$])|<u>.*?<\/u>|https?:\/\/\S+)/g
: /(\*\*.*?\*\*|\*.*?\*|_.*?_|~~.*?~~|\[.*?\]\(.*?\)|`+.+?`+|<u>.*?<\/u>|https?:\/\/\S+)/g;
let match;

while ((match = inlineRegex.exec(text)) !== null) {
Comment thread
chiga0 marked this conversation as resolved.
Resolved
Expand Down Expand Up @@ -138,6 +148,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}>
Expand Down Expand Up @@ -167,13 +193,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);
Expand Down
Loading
Loading