diff --git a/package-lock.json b/package-lock.json
index 3873f7e1b..3f954c459 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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",
diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md
index 4d82a69f8..0a71a1751 100644
--- a/packages/coding-agent/CHANGELOG.md
+++ b/packages/coding-agent/CHANGELOG.md
@@ -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
diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md
index 74cfe2e4f..36a397507 100644
--- a/packages/coding-agent/docs/settings.md
+++ b/packages/coding-agent/docs/settings.md
@@ -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
diff --git a/packages/coding-agent/install-lock/package-lock.json b/packages/coding-agent/install-lock/package-lock.json
index 946d07399..7dc318ce4 100644
--- a/packages/coding-agent/install-lock/package-lock.json
+++ b/packages/coding-agent/install-lock/package-lock.json
@@ -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",
diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json
index 0c2951bee..c0cbba3c5 100644
--- a/packages/coding-agent/npm-shrinkwrap.json
+++ b/packages/coding-agent/npm-shrinkwrap.json
@@ -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",
diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json
index 4dbc065e9..4d1bbedae 100644
--- a/packages/coding-agent/package.json
+++ b/packages/coding-agent/package.json
@@ -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",
diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts
index 05872d7a6..8550bc189 100644
--- a/packages/coding-agent/src/core/settings-manager.ts
+++ b/packages/coding-agent/src/core/settings-manager.ts
@@ -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 ?? {}) };
}
diff --git a/packages/coding-agent/src/modes/interactive/components/mermaid.ts b/packages/coding-agent/src/modes/interactive/components/mermaid.ts
new file mode 100644
index 000000000..a8d5e3cdf
--- /dev/null
+++ b/packages/coding-agent/src/modes/interactive/components/mermaid.ts
@@ -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`` -> hel`lo).
+ // 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` `` -> `edge`).
+ // 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("");
+ };
+}
diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts
index 460c76d5f..1a4431330 100644
--- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts
+++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts
@@ -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;
diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts
index 348fd6cd3..061b4484e 100644
--- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts
+++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts
@@ -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();
@@ -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();
diff --git a/packages/coding-agent/test/mermaid.test.ts b/packages/coding-agent/test/mermaid.test.ts
new file mode 100644
index 000000000..13fa524ac
--- /dev/null
+++ b/packages/coding-agent/test/mermaid.test.ts
@@ -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) => `${text}`,
+ } as Theme;
+ const rendered = transformMermaid("```mermaid\nflowchart LR\n A --> B\n```", { theme });
+
+ expect(rendered).toContain("");
+ expect(rendered).toContain("");
+ });
+
+ 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);
+ });
+});
diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts
index 64eea52dc..5ebdfd5de 100644
--- a/packages/coding-agent/test/settings-manager.test.ts
+++ b/packages/coding-agent/test/settings-manager.test.ts
@@ -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");