feat(coding-agent): render Mermaid diagrams (#7624)

This commit is contained in:
Alexey Zaytsev 2026-08-05 04:43:24 -03:00 • committed by GitHub
parent 46b53b995b
commit 66534fbdc7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 287 additions and 2 deletions

10
package-lock.json generated
View file

@ -3284,6 +3284,15 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/grok-mermaid": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/grok-mermaid/-/grok-mermaid-0.2.2.tgz",
"integrity": "sha512-XcJEP5dDC8liHBh52mlLjU18fNvu1ckFsu0QpIG3+APZ270fsj9wxpiA6cOURmbUEuoMVgjbC2+UYgTdCqqgzA==",
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@ -5535,6 +5544,7 @@
"cross-spawn": "7.0.6",
"diff": "8.0.4",
"glob": "13.0.6",
"grok-mermaid": "0.2.2",
"highlight.js": "10.7.3",
"hosted-git-info": "9.0.3",
"ignore": "7.0.5",

View file

@ -81,6 +81,7 @@
- Added a draggable transcript scrollbar to fullscreen mode with configurable `auto`, `always`, and `hidden` modes through `/settings`; `always` reserves the rightmost column.
- Added page scrolling and marked-message navigation shortcuts to fullscreen mode.
- Added an optional `scrollbarThumb` theme color for fullscreen scrollbar thumbs, falling back to `selectedBg`.
- Added configurable themed Unicode rendering for supported Mermaid diagrams in interactive messages, including optional rendering while streaming.
### Fixed

View file

@ -228,6 +228,7 @@ When multiple sources specify a session directory, precedence is `--session-dir`
| Setting | Type | Default | Description |
|---------|------|---------|-------------|
| `markdown.codeBlockIndent` | string | `" "` | Indentation for code blocks |
| `markdown.mermaid` | string | `"streaming"` | Mermaid rendering mode: `"off"`, `"final"`, or `"streaming"` |
### Resources

View file

@ -514,6 +514,7 @@
"cross-spawn": "7.0.6",
"diff": "8.0.4",
"glob": "13.0.6",
"grok-mermaid": "0.2.2",
"highlight.js": "10.7.3",
"hosted-git-info": "9.0.3",
"ignore": "7.0.5",
@ -1311,6 +1312,15 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/grok-mermaid": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/grok-mermaid/-/grok-mermaid-0.2.2.tgz",
"integrity": "sha512-XcJEP5dDC8liHBh52mlLjU18fNvu1ckFsu0QpIG3+APZ270fsj9wxpiA6cOURmbUEuoMVgjbC2+UYgTdCqqgzA==",
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/highlight.js": {
"version": "10.7.3",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz",

View file

@ -19,6 +19,7 @@
"cross-spawn": "7.0.6",
"diff": "8.0.4",
"glob": "13.0.6",
"grok-mermaid": "0.2.2",
"highlight.js": "10.7.3",
"hosted-git-info": "9.0.3",
"ignore": "7.0.5",
@ -1301,6 +1302,15 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC"
},
"node_modules/grok-mermaid": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/grok-mermaid/-/grok-mermaid-0.2.2.tgz",
"integrity": "sha512-XcJEP5dDC8liHBh52mlLjU18fNvu1ckFsu0QpIG3+APZ270fsj9wxpiA6cOURmbUEuoMVgjbC2+UYgTdCqqgzA==",
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/highlight.js": {
"version": "10.7.3",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz",

View file

@ -53,6 +53,7 @@
"cross-spawn": "7.0.6",
"diff": "8.0.4",
"glob": "13.0.6",
"grok-mermaid": "0.2.2",
"highlight.js": "10.7.3",
"hosted-git-info": "9.0.3",
"ignore": "7.0.5",

View file

@ -54,8 +54,11 @@ export interface ThinkingBudgetsSettings {
high?: number;
}
export type MermaidRenderingMode = "off" | "final" | "streaming";
export interface MarkdownSettings {
codeBlockIndent?: string; // default: " "
mermaid?: MermaidRenderingMode; // default: "streaming"
}
export interface WarningSettings {
@ -1245,6 +1248,18 @@ export class SettingsManager {
return this.settings.markdown?.codeBlockIndent ?? " ";
}
getMermaidRenderingMode(): MermaidRenderingMode {
const mode = this.settings.markdown?.mermaid;
return mode === "off" || mode === "final" ? mode : "streaming";
}
setMermaidRenderingMode(mode: MermaidRenderingMode): void {
this.globalSettings.markdown ??= {};
this.globalSettings.markdown.mermaid = mode;
this.markModified("markdown", "mermaid");
this.save();
}
getWarnings(): WarningSettings {
return { ...(this.settings.warnings ?? {}) };
}

View file

@ -0,0 +1,89 @@
import { Marked, type Token } from "@earendil-works/pi-tui";
import { type MermaidArt, render, type Span } from "grok-mermaid";
import type { MarkdownTransformer } from "../../../core/extensions/types.ts";
import type { MermaidRenderingMode } from "../../../core/settings-manager.ts";
import type { Theme } from "../theme/theme.ts";
const markdownParser = new Marked();
interface MermaidTransformerOptions {
getMode: () => MermaidRenderingMode;
theme?: Theme;
}
function isMermaid(token: Token): token is Token & { type: "code"; text: string; lang?: string } {
return token.type === "code" && token.lang?.trim().split(/\s+/, 1)[0]?.toLowerCase() === "mermaid";
}
function codeSpan(line: string): string {
// Encode each diagram row as inline code (` ... `) so Markdown preserves its spacing and
// box-drawing characters. Use a non-breaking space for blank rows because an
// empty code span has no visible height.
const content = line || "\u00a0";
// CommonMark code spans use matching backtick delimiters, so choose one
// longer than any backtick run in the content (``hel`lo`` -> <code>hel`lo</code>).
// If the content starts or ends with a backtick, separating it from the
// delimiter with a space keeps that backtick as content; CommonMark removes
// the padding when rendering (`` `edge` `` -> <code>`edge`</code>).
// Mermaid labels can preserve backticks, for example:
// `┌──────────────┐ ┌──────────────┐`
// ```│ plain ` tick ├───▶│ two `` ticks │```
// `└──────────────┘ └──────────────┘`
const longestBacktickRun = Math.max(0, ...Array.from(content.matchAll(/`+/g), (match) => match[0].length));
const fence = "`".repeat(longestBacktickRun + 1);
const padding = content.startsWith("`") || content.endsWith("`") ? " " : "";
return `${fence}${padding}${content}${padding}${fence}`;
}
function styleSpan(span: Span, theme: Theme): string {
switch (span.cls) {
case "border":
return theme.fg("borderMuted", span.text);
case "text":
return theme.fg("text", span.text);
case "edge":
return theme.fg("accent", span.text);
case "edgeLabel":
return theme.fg("muted", span.text);
case "title":
return theme.fg("accent", theme.bold(span.text));
case "none":
return span.text;
}
}
function themedLines(art: MermaidArt, theme: Theme): string[] {
return art.styled.map((row) => row.map((span) => styleSpan(span, theme)).join(""));
}
/** Create a transformer that replaces top-level Mermaid code blocks with Unicode terminal diagrams. */
export function createMermaidMarkdownTransformer(options: MermaidTransformerOptions): MarkdownTransformer {
return (markdown, context) => {
const mode = options.getMode();
if (
mode === "off" ||
context.messageType === "assistant-thinking" ||
(context.isStreaming && mode !== "streaming")
) {
return markdown;
}
return markdownParser
.lexer(markdown)
.map((token) => {
if (!isMermaid(token)) return token.raw;
const art = render(token.text);
if (!art || art.width > context.availableWidth) return token.raw;
if (!context.isStreaming && art.warnings.length > 0) {
const suffix = art.warnings.length > 1 ? ` (+${art.warnings.length - 1} more)` : "";
const warning = `Mermaid diagram not rendered: ${art.warnings[0]}${suffix}`;
const styledWarning = options.theme ? options.theme.fg("warning", warning) : warning;
return `${token.raw}\n${codeSpan(styledWarning)} \n`;
}
const lines = options.theme ? themedLines(art, options.theme) : art.plain;
// Markdown hard breaks keep every diagram row on its own line.
return `${lines.map(codeSpan).join(" \n")}\n`;
})
.join("");
};
}

View file

@ -14,7 +14,12 @@ import {
Text,
} from "@earendil-works/pi-tui";
import { formatHttpIdleTimeoutMs, HTTP_IDLE_TIMEOUT_CHOICES } from "../../../core/http-dispatcher.ts";
import type { DefaultProjectTrust, UiMode, WarningSettings } from "../../../core/settings-manager.ts";
import type {
DefaultProjectTrust,
MermaidRenderingMode,
UiMode,
WarningSettings,
} from "../../../core/settings-manager.ts";
import {
getSelectListTheme,
getSettingsListTheme,
@ -67,6 +72,7 @@ export interface SettingsConfig {
terminalTheme: TerminalTheme;
availableThemes: string[];
hideThinkingBlock: boolean;
mermaidRenderingMode: MermaidRenderingMode;
showCacheMissNotices: boolean;
collapseChangelog: boolean;
enableInstallTelemetry: boolean;
@ -100,6 +106,7 @@ export interface SettingsCallbacks {
onThemeChange: (theme: string) => void;
onThemePreview?: (theme: string) => void;
onHideThinkingBlockChange: (hidden: boolean) => void;
onMermaidRenderingModeChange: (mode: MermaidRenderingMode) => void;
onShowCacheMissNoticesChange: (shown: boolean) => void;
onCollapseChangelogChange: (collapsed: boolean) => void;
onEnableInstallTelemetryChange: (enabled: boolean) => void;
@ -529,6 +536,13 @@ export class SettingsSelectorComponent extends Container {
currentValue: config.hideThinkingBlock ? "true" : "false",
values: ["true", "false"],
},
{
id: "mermaid-rendering",
label: "Mermaid diagrams",
description: "Render Mermaid code blocks as Unicode diagrams",
currentValue: config.mermaidRenderingMode,
values: ["off", "final", "streaming"],
},
{
id: "cache-miss-notices",
label: "Cache miss notices",
@ -793,6 +807,9 @@ export class SettingsSelectorComponent extends Container {
case "hide-thinking":
callbacks.onHideThinkingBlockChange(newValue === "true");
break;
case "mermaid-rendering":
callbacks.onMermaidRenderingModeChange(newValue as MermaidRenderingMode);
break;
case "cache-miss-notices":
callbacks.onShowCacheMissNoticesChange(newValue === "true");
break;

View file

@ -127,6 +127,7 @@ import { ExtensionSelectorComponent } from "./components/extension-selector.ts";
import { FooterComponent, formatTokens } from "./components/footer.ts";
import { formatKeyText, keyDisplayText, keyHint, keyText, rawKeyHint } from "./components/keybinding-hints.ts";
import { LoginDialogComponent } from "./components/login-dialog.ts";
import { createMermaidMarkdownTransformer } from "./components/mermaid.ts";
import { ModelSelectorComponent } from "./components/model-selector.ts";
import {
type AuthSelectorProvider,
@ -431,6 +432,10 @@ export class InteractiveMode {
// Thinking block visibility state
private hideThinkingBlock = false;
private outputPad = 1;
private readonly mermaidMarkdownTransformer: MarkdownTransformer = createMermaidMarkdownTransformer({
getMode: () => this.settingsManager.getMermaidRenderingMode(),
theme,
});
// Skill commands: command name -> skill file path
private skillCommands = new Map<string, string>();
@ -1951,7 +1956,7 @@ export class InteractiveMode {
}
private getMarkdownTransformers(): MarkdownTransformer[] {
return this.session.extensionRunner.getMarkdownTransformers();
return [this.mermaidMarkdownTransformer, ...this.session.extensionRunner.getMarkdownTransformers()];
}
/**
@ -4358,6 +4363,7 @@ export class InteractiveMode {
terminalTheme: this.themeController.getTerminalTheme(),
availableThemes: getAvailableThemes(),
hideThinkingBlock: this.hideThinkingBlock,
mermaidRenderingMode: this.settingsManager.getMermaidRenderingMode(),
collapseChangelog: this.settingsManager.getCollapseChangelog(),
enableInstallTelemetry: this.settingsManager.getEnableInstallTelemetry(),
doubleEscapeAction: this.settingsManager.getDoubleEscapeAction(),
@ -4442,6 +4448,11 @@ export class InteractiveMode {
this.chatContainer.clear();
this.rebuildChatFromMessages();
},
onMermaidRenderingModeChange: (mode) => {
this.settingsManager.setMermaidRenderingMode(mode);
this.chatContainer.invalidate();
this.ui.requestRender();
},
onShowCacheMissNoticesChange: (shown) => {
this.settingsManager.setShowCacheMissNotices(shown);
this.rebuildChatFromMessages();

View file

@ -0,0 +1,99 @@
import { describe, expect, it } from "vitest";
import type { MarkdownTransformContext } from "../src/core/extensions/types.ts";
import type { MermaidRenderingMode } from "../src/core/settings-manager.ts";
import { createMermaidMarkdownTransformer } from "../src/modes/interactive/components/mermaid.ts";
import type { Theme } from "../src/modes/interactive/theme/theme.ts";
interface TransformOptions {
maxWidth?: number;
isStreaming?: boolean;
messageType?: MarkdownTransformContext["messageType"];
mode?: MermaidRenderingMode;
theme?: Theme;
}
function transformMermaid(markdown: string, options: TransformOptions = {}): string {
const transformer = createMermaidMarkdownTransformer({
getMode: () => options.mode ?? "streaming",
theme: options.theme,
});
return transformer(markdown, {
availableWidth: options.maxWidth ?? 100,
isStreaming: options.isStreaming ?? false,
messageType: options.messageType ?? "assistant",
});
}
describe("Mermaid rendering", () => {
it("replaces Mermaid code blocks with Unicode diagrams", () => {
const markdown = "Before\n\n```mermaid\nflowchart LR\n A[Start] --> B[Done]\n```\nAfter";
const rendered = transformMermaid(markdown);
expect(rendered).toContain("Before");
expect(rendered).toContain("┌───────┐");
expect(rendered).toContain("│ Start ├───▶│ Done │");
expect(rendered).toContain("└───────┘ └──────┘`\nAfter");
expect(rendered).not.toContain("```mermaid");
expect(rendered).toContain("After");
});
it("leaves unsupported and oversized diagrams unchanged", () => {
const unsupported = '```mermaid\npie\n title Pets\n "Dogs" : 4\n```';
const oversized = "```mermaid\nflowchart LR\n A[Start] --> B[Done]\n```";
expect(transformMermaid(unsupported)).toBe(unsupported);
expect(transformMermaid(oversized, { maxWidth: 10 })).toBe(oversized);
});
it("maps semantic spans through the Pi theme", () => {
const theme = {
fg: (color: string, text: string) => `<${color}>${text}</${color}>`,
bold: (text: string) => `<bold>${text}</bold>`,
} as Theme;
const rendered = transformMermaid("```mermaid\nflowchart LR\n A --> B\n```", { theme });
expect(rendered).toContain("<borderMuted>");
expect(rendered).toContain("<accent>");
});
it("renders incomplete Mermaid blocks during streaming", () => {
const partialMarkdown = "```mermaid\nflowchart LR\n A --> B";
expect(transformMermaid(partialMarkdown, { isStreaming: true })).toContain("───▶");
});
it("falls back to the code block with a warning after streaming", () => {
const markdown = "```mermaid\nflowchart LR\n A[Foo]:::highlight --> B[Bar]\n```";
const final = transformMermaid(markdown);
const followedByText = transformMermaid(`${markdown}\nFollowing text`);
const streaming = transformMermaid(markdown, { isStreaming: true });
expect(final).toContain(markdown);
expect(final).toContain("```\n`Mermaid diagram not rendered");
expect(final).toContain('dropped, expected a link: ":::highlight --> B[Bar]"');
expect(final).not.toContain("more)");
expect(followedByText).toContain(" \nFollowing text");
expect(streaming).not.toContain("Mermaid diagram not rendered");
expect(streaming).not.toContain("```mermaid");
expect(streaming).toContain("│ Foo │");
});
it("summarizes additional partial-render warnings", () => {
const markdown = "```mermaid\nflowchart LR\n A[Foo]:::highlight --> B[Bar]\n C[Baz]:::other --> D[Qux]\n```";
const rendered = transformMermaid(markdown);
expect(rendered).toContain(markdown);
expect(rendered).toContain('dropped, expected a link: ":::highlight --> B[Bar]"');
expect(rendered).toContain("(+1 more)");
expect(rendered).not.toContain('dropped, expected a link: ":::other --> D[Qux]"');
});
it("respects rendering modes and skips thinking blocks", () => {
const markdown = "```mermaid\nflowchart LR\n A --> B\n```";
expect(transformMermaid(markdown, { mode: "off" })).toBe(markdown);
expect(transformMermaid(markdown, { mode: "final", isStreaming: true })).toBe(markdown);
expect(transformMermaid(markdown, { mode: "final" })).not.toContain("```mermaid");
expect(transformMermaid(markdown, { messageType: "assistant-thinking" })).toBe(markdown);
});
});

View file

@ -455,6 +455,27 @@ describe("SettingsManager", () => {
});
});
describe("markdown.mermaid", () => {
it("defaults to streaming and persists rendering modes", async () => {
const manager = SettingsManager.create(projectDir, agentDir);
expect(manager.getMermaidRenderingMode()).toBe("streaming");
manager.setMermaidRenderingMode("final");
await manager.flush();
expect(manager.getMermaidRenderingMode()).toBe("final");
const savedSettings = JSON.parse(readFileSync(join(agentDir, "settings.json"), "utf-8"));
expect(savedSettings.markdown.mermaid).toBe("final");
});
it("falls back to streaming for unsupported values", () => {
writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ markdown: { mermaid: "sometimes" } }));
expect(SettingsManager.create(projectDir, agentDir).getMermaidRenderingMode()).toBe("streaming");
});
});
describe("shellCommandPrefix", () => {
it("should load shellCommandPrefix from settings", () => {
const settingsPath = join(agentDir, "settings.json");