feat(coding-agent): upgrade Mermaid terminal rendering (#8158)

Replaces grok-mermaid with lovely-mermaid 0.3.3. Closes #8157
This commit is contained in:
Alexey Zaytsev 2026-11-04 07:06:51 -03:00 • committed by GitHub
parent e6865ec7e1
commit aceb1bd128
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 119 additions and 49 deletions

11
package-lock.json generated
View file

@ -4434,6 +4434,15 @@
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/lovely-mermaid": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/lovely-mermaid/-/lovely-mermaid-0.3.3.tgz",
"integrity": "sha512-EMLJS934qwt2MLO/a5o+T/89HGbuXZPwmCpHIhHO+DVrm+y5tHlEHgpZzlC2o4yW/rFPHRFO9xSMafzqH4wH5A==",
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/lowlight": { "node_modules/lowlight": {
"version": "3.3.0", "version": "3.3.0",
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz", "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz",
@ -6033,11 +6042,11 @@
"chalk": "6.0.0", "chalk": "6.0.0",
"cross-spawn": "7.0.6", "cross-spawn": "7.0.6",
"diff": "8.0.4", "diff": "8.0.4",
"grok-mermaid": "0.2.3",
"highlight.js": "10.7.3", "highlight.js": "10.7.3",
"hosted-git-info": "9.0.3", "hosted-git-info": "9.0.3",
"ignore": "7.0.8", "ignore": "7.0.8",
"jiti": "2.7.0", "jiti": "2.7.0",
"lovely-mermaid": "0.3.3",
"minimatch": "11.0.3", "minimatch": "11.0.3",
"proper-lockfile": "4.1.2", "proper-lockfile": "4.1.2",
"quickjs-wasi": "3.6.2", "quickjs-wasi": "3.6.2",

View file

@ -506,11 +506,11 @@
"chalk": "6.0.0", "chalk": "6.0.0",
"cross-spawn": "7.0.6", "cross-spawn": "7.0.6",
"diff": "8.0.4", "diff": "8.0.4",
"grok-mermaid": "0.2.3",
"highlight.js": "10.7.3", "highlight.js": "10.7.3",
"hosted-git-info": "9.0.3", "hosted-git-info": "9.0.3",
"ignore": "7.0.8", "ignore": "7.0.8",
"jiti": "2.7.0", "jiti": "2.7.0",
"lovely-mermaid": "0.3.3",
"minimatch": "11.0.3", "minimatch": "11.0.3",
"proper-lockfile": "4.1.2", "proper-lockfile": "4.1.2",
"quickjs-wasi": "3.6.2", "quickjs-wasi": "3.6.2",
@ -1015,15 +1015,6 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/grok-mermaid": {
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/grok-mermaid/-/grok-mermaid-0.2.3.tgz",
"integrity": "sha512-/4KopAbsjvuRP9MdPtlDjOHUmUVEohOX73JNcsWpzAtFxh+bq5+Dhb6gzvRieLDwIPQIR3/vy8V1NNTuz4Zsmg==",
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/highlight.js": { "node_modules/highlight.js": {
"version": "10.7.3", "version": "10.7.3",
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz",
@ -1154,6 +1145,15 @@
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/lovely-mermaid": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/lovely-mermaid/-/lovely-mermaid-0.3.3.tgz",
"integrity": "sha512-EMLJS934qwt2MLO/a5o+T/89HGbuXZPwmCpHIhHO+DVrm+y5tHlEHgpZzlC2o4yW/rFPHRFO9xSMafzqH4wH5A==",
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/lru-cache": { "node_modules/lru-cache": {
"version": "11.4.0", "version": "11.4.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz",

View file

@ -51,11 +51,11 @@
"chalk": "6.0.0", "chalk": "6.0.0",
"cross-spawn": "7.0.6", "cross-spawn": "7.0.6",
"diff": "8.0.4", "diff": "8.0.4",
"grok-mermaid": "0.2.3",
"highlight.js": "10.7.3", "highlight.js": "10.7.3",
"hosted-git-info": "9.0.3", "hosted-git-info": "9.0.3",
"ignore": "7.0.8", "ignore": "7.0.8",
"jiti": "2.7.0", "jiti": "2.7.0",
"lovely-mermaid": "0.3.3",
"minimatch": "11.0.3", "minimatch": "11.0.3",
"proper-lockfile": "4.1.2", "proper-lockfile": "4.1.2",
"quickjs-wasi": "3.6.2", "quickjs-wasi": "3.6.2",

View file

@ -1,14 +1,16 @@
import { Marked, type Token } from "@earendil-works/pi-tui"; import { getCapabilities, Marked, type Token } from "@earendil-works/pi-tui";
import { type MermaidArt, render, type Span } from "grok-mermaid"; import { type AnsiTheme, type MermaidArt, render, toAnsi } from "lovely-mermaid";
import type { MarkdownTransformer } from "../../../core/extensions/types.ts"; import type { MarkdownTransformer } from "../../../core/extensions/types.ts";
import type { MermaidRenderingMode } from "../../../core/settings-manager.ts"; import type { MermaidRenderingMode } from "../../../core/settings-manager.ts";
import type { Theme } from "../theme/theme.ts"; import type { Theme, ThemeColor } from "../theme/theme.ts";
const markdownParser = new Marked(); const markdownParser = new Marked();
type MermaidTheme = Pick<Theme, "fg" | "getFgAnsi">;
interface MermaidTransformerOptions { interface MermaidTransformerOptions {
getMode: () => MermaidRenderingMode; getMode: () => MermaidRenderingMode;
theme?: Theme; theme?: MermaidTheme;
} }
function isMermaid(token: Token): token is Token & { type: "code"; text: string; lang?: string } { function isMermaid(token: Token): token is Token & { type: "code"; text: string; lang?: string } {
@ -35,25 +37,29 @@ function codeSpan(line: string): string {
return `${fence}${padding}${content}${padding}${fence}`; return `${fence}${padding}${content}${padding}${fence}`;
} }
function styleSpan(span: Span, theme: Theme): string { function themedLines(art: MermaidArt, theme: MermaidTheme): string[] {
switch (span.cls) { const foreground = (color: ThemeColor) => theme.getFgAnsi(color).slice(2, -1);
case "border": const ansiTheme: AnsiTheme = {
return theme.fg("borderMuted", span.text); border: foreground("borderMuted"),
case "text": text: foreground("text"),
return theme.fg("text", span.text); edge: foreground("accent"),
case "edge": edgeLabel: foreground("text"),
return theme.fg("accent", span.text); title: `1;${foreground("accent")}`,
case "edgeLabel": };
return theme.fg("muted", span.text); const linkLabels = getCapabilities().hyperlinks;
case "title": const displayArt: MermaidArt = {
return theme.fg("accent", theme.bold(span.text)); ...art,
case "none": // Only osc 8 the text, not the whole box.
return span.text; styled: art.styled.map((row) =>
} row.map((span) => {
} if (span.href === undefined || (linkLabels && span.role === "text")) return span;
const unlinked = { ...span };
function themedLines(art: MermaidArt, theme: Theme): string[] { delete unlinked.href;
return art.styled.map((row) => row.map((span) => styleSpan(span, theme)).join("")); return unlinked;
}),
),
};
return toAnsi(displayArt, ansiTheme);
} }
/** Create a transformer that replaces top-level Mermaid code blocks with Unicode terminal diagrams. */ /** Create a transformer that replaces top-level Mermaid code blocks with Unicode terminal diagrams. */

View file

@ -1,15 +1,18 @@
import { describe, expect, it } from "vitest"; import { resetCapabilitiesCache, setCapabilities } from "@earendil-works/pi-tui";
import { afterEach, describe, expect, it } from "vitest";
import type { MarkdownTransformContext } from "../src/core/extensions/types.ts"; import type { MarkdownTransformContext } from "../src/core/extensions/types.ts";
import type { MermaidRenderingMode } from "../src/core/settings-manager.ts"; import type { MermaidRenderingMode } from "../src/core/settings-manager.ts";
import { createMermaidMarkdownTransformer } from "../src/modes/interactive/components/mermaid.ts"; import { createMermaidMarkdownTransformer } from "../src/modes/interactive/components/mermaid.ts";
import type { Theme } from "../src/modes/interactive/theme/theme.ts"; import type { Theme } from "../src/modes/interactive/theme/theme.ts";
afterEach(() => resetCapabilitiesCache());
interface TransformOptions { interface TransformOptions {
maxWidth?: number; maxWidth?: number;
isStreaming?: boolean; isStreaming?: boolean;
messageType?: MarkdownTransformContext["messageType"]; messageType?: MarkdownTransformContext["messageType"];
mode?: MermaidRenderingMode; mode?: MermaidRenderingMode;
theme?: Theme; theme?: Pick<Theme, "fg" | "getFgAnsi">;
} }
function transformMermaid(markdown: string, options: TransformOptions = {}): string { function transformMermaid(markdown: string, options: TransformOptions = {}): string {
@ -38,22 +41,74 @@ describe("Mermaid rendering", () => {
}); });
it("leaves unsupported and oversized diagrams unchanged", () => { it("leaves unsupported and oversized diagrams unchanged", () => {
const unsupported = '```mermaid\npie\n title Pets\n "Dogs" : 4\n```'; const unsupported = "```mermaid\ngantt\n title Plan\n```";
const oversized = "```mermaid\nflowchart LR\n A[Start] --> B[Done]\n```"; const oversized = "```mermaid\nflowchart LR\n A[Start] --> B[Done]\n```";
expect(transformMermaid(unsupported)).toBe(unsupported); expect(transformMermaid(unsupported)).toBe(unsupported);
expect(transformMermaid(oversized, { maxWidth: 10 })).toBe(oversized); expect(transformMermaid(oversized, { maxWidth: 10 })).toBe(oversized);
}); });
it("renders diagram types added by lovely-mermaid", () => {
const rendered = transformMermaid('```mermaid\npie\n title Pets\n "Dogs" : 4\n```');
expect(rendered).toContain("Pets");
expect(rendered).toContain("Dogs");
expect(rendered).toContain("████████████████████");
expect(rendered).not.toContain("```mermaid");
});
it("maps semantic spans through the Pi theme", () => { it("maps semantic spans through the Pi theme", () => {
const theme = { const theme = {
fg: (color: string, text: string) => `<${color}>${text}</${color}>`, fg: (_color: string, text: string) => text,
bold: (text: string) => `<bold>${text}</bold>`, getFgAnsi: (color: string) => {
} as Theme; const colors: Record<string, number> = {
accent: 45,
borderMuted: 240,
muted: 245,
text: 255,
};
return `\x1b[38;5;${colors[color]}m`;
},
};
const rendered = transformMermaid("```mermaid\nflowchart LR\n A --> B\n```", { theme }); const rendered = transformMermaid("```mermaid\nflowchart LR\n A --> B\n```", { theme });
expect(rendered).toContain("<borderMuted>"); expect(rendered).toContain("\x1b[38;5;240m");
expect(rendered).toContain("<accent>"); expect(rendered).toContain("\x1b[38;5;45m");
});
it("applies author class styles over the Pi theme", () => {
const theme = {
fg: (_color: string, text: string) => text,
getFgAnsi: () => "\x1b[39m",
};
const rendered = transformMermaid(
"```mermaid\nflowchart LR\n A[Hot]:::highlight --> B[Plain]\n classDef highlight fill:#f96,stroke:#333,color:#000,font-weight:bold\n```",
{ theme },
);
expect(rendered).toContain("\x1b[1;38;2;51;51;51;48;2;255;153;102m");
expect(rendered).toContain("\x1b[1;38;2;0;0;0;48;2;255;153;102m");
expect(rendered).not.toContain("```mermaid");
});
it("links only Mermaid labels and only when the terminal supports hyperlinks", () => {
const theme = {
fg: (_color: string, text: string) => text,
getFgAnsi: () => "\x1b[39m",
};
const markdown = '```mermaid\nflowchart TD\n A[Docs] --> B[Done]\n click A "https://example.com/docs"\n```';
setCapabilities({ images: null, trueColor: true, hyperlinks: true });
const linked = transformMermaid(markdown, { theme });
const linkOpen = "\x1b]8;;https://example.com/docs\x1b\\";
expect(linked.split(linkOpen)).toHaveLength(2);
expect(linked).not.toContain("```mermaid");
setCapabilities({ images: null, trueColor: true, hyperlinks: false });
const unlinked = transformMermaid(markdown, { theme });
expect(unlinked).not.toContain("\x1b]8;;");
expect(unlinked).not.toContain("https://example.com/docs");
expect(unlinked).not.toContain("```mermaid");
}); });
it("renders incomplete Mermaid blocks during streaming", () => { it("renders incomplete Mermaid blocks during streaming", () => {
@ -72,29 +127,29 @@ describe("Mermaid rendering", () => {
}); });
it("falls back to the code block with a warning after streaming", () => { it("falls back to the code block with a warning after streaming", () => {
const markdown = "```mermaid\nflowchart LR\n A[Foo] invalid\n```"; const markdown = "```mermaid\nflowchart LR\n A --> B\n broken !\n```";
const final = transformMermaid(markdown); const final = transformMermaid(markdown);
const followedByText = transformMermaid(`${markdown}\nFollowing text`); const followedByText = transformMermaid(`${markdown}\nFollowing text`);
const streaming = transformMermaid(markdown, { isStreaming: true }); const streaming = transformMermaid(markdown, { isStreaming: true });
expect(final).toContain(markdown); expect(final).toContain(markdown);
expect(final).toContain("```\n`Mermaid diagram not rendered"); expect(final).toContain("```\n`Mermaid diagram not rendered");
expect(final).toContain('dropped, expected a link: "invalid"'); expect(final).toContain('dropped, expected a link: "!"');
expect(final).not.toContain("more)"); expect(final).not.toContain("more)");
expect(followedByText).toContain(" \nFollowing text"); expect(followedByText).toContain(" \nFollowing text");
expect(streaming).not.toContain("Mermaid diagram not rendered"); expect(streaming).not.toContain("Mermaid diagram not rendered");
expect(streaming).not.toContain("```mermaid"); expect(streaming).not.toContain("```mermaid");
expect(streaming).toContain("│ Foo │"); expect(streaming).toContain("│ broken │");
}); });
it("summarizes additional partial-render warnings", () => { it("summarizes additional partial-render warnings", () => {
const markdown = "```mermaid\nflowchart LR\n A[Foo] invalid\n B[Bar] also-invalid\n```"; const markdown = "```mermaid\nflowchart LR\n A --> B\n broken !\n other ?\n```";
const rendered = transformMermaid(markdown); const rendered = transformMermaid(markdown);
expect(rendered).toContain(markdown); expect(rendered).toContain(markdown);
expect(rendered).toContain('dropped, expected a link: "invalid"'); expect(rendered).toContain('dropped, expected a link: "!"');
expect(rendered).toContain("(+1 more)"); expect(rendered).toContain("(+1 more)");
expect(rendered).not.toContain('dropped, expected a link: "also-invalid"'); expect(rendered).not.toContain('dropped, expected a link: "?"');
}); });
it("respects rendering modes and skips thinking blocks and non-transcript surfaces", () => { it("respects rendering modes and skips thinking blocks and non-transcript surfaces", () => {