From 66534fbdc7bb09492b40142471bbbb87b4a4d5bc Mon Sep 17 00:00:00 2001 From: Alexey Zaytsev Date: Wed, 5 Aug 2026 04:43:24 -0300 Subject: [PATCH] feat(coding-agent): render Mermaid diagrams (#7624) --- package-lock.json | 10 ++ packages/coding-agent/CHANGELOG.md | 1 + packages/coding-agent/docs/settings.md | 1 + .../install-lock/package-lock.json | 10 ++ packages/coding-agent/npm-shrinkwrap.json | 10 ++ packages/coding-agent/package.json | 1 + .../coding-agent/src/core/settings-manager.ts | 15 +++ .../modes/interactive/components/mermaid.ts | 89 +++++++++++++++++ .../components/settings-selector.ts | 19 +++- .../src/modes/interactive/interactive-mode.ts | 13 ++- packages/coding-agent/test/mermaid.test.ts | 99 +++++++++++++++++++ .../test/settings-manager.test.ts | 21 ++++ 12 files changed, 287 insertions(+), 2 deletions(-) create mode 100644 packages/coding-agent/src/modes/interactive/components/mermaid.ts create mode 100644 packages/coding-agent/test/mermaid.test.ts 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}`, + 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");