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

View file

@ -506,11 +506,11 @@
"chalk": "6.0.0",
"cross-spawn": "7.0.6",
"diff": "8.0.4",
"grok-mermaid": "0.2.3",
"highlight.js": "10.7.3",
"hosted-git-info": "9.0.3",
"ignore": "7.0.8",
"jiti": "2.7.0",
"lovely-mermaid": "0.3.3",
"minimatch": "11.0.3",
"proper-lockfile": "4.1.2",
"quickjs-wasi": "3.6.2",
@ -1015,15 +1015,6 @@
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"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": {
"version": "10.7.3",
"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==",
"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": {
"version": "11.4.0",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.4.0.tgz",

View file

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

View file

@ -1,14 +1,16 @@
import { Marked, type Token } from "@earendil-works/pi-tui";
import { type MermaidArt, render, type Span } from "grok-mermaid";
import { getCapabilities, Marked, type Token } from "@earendil-works/pi-tui";
import { type AnsiTheme, type MermaidArt, render, toAnsi } from "lovely-mermaid";
import type { MarkdownTransformer } from "../../../core/extensions/types.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();
type MermaidTheme = Pick<Theme, "fg" | "getFgAnsi">;
interface MermaidTransformerOptions {
getMode: () => MermaidRenderingMode;
theme?: Theme;
theme?: MermaidTheme;
}
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}`;
}
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(""));
function themedLines(art: MermaidArt, theme: MermaidTheme): string[] {
const foreground = (color: ThemeColor) => theme.getFgAnsi(color).slice(2, -1);
const ansiTheme: AnsiTheme = {
border: foreground("borderMuted"),
text: foreground("text"),
edge: foreground("accent"),
edgeLabel: foreground("text"),
title: `1;${foreground("accent")}`,
};
const linkLabels = getCapabilities().hyperlinks;
const displayArt: MermaidArt = {
...art,
// Only osc 8 the text, not the whole box.
styled: art.styled.map((row) =>
row.map((span) => {
if (span.href === undefined || (linkLabels && span.role === "text")) return span;
const unlinked = { ...span };
delete unlinked.href;
return unlinked;
}),
),
};
return toAnsi(displayArt, ansiTheme);
}
/** 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 { 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";
afterEach(() => resetCapabilitiesCache());
interface TransformOptions {
maxWidth?: number;
isStreaming?: boolean;
messageType?: MarkdownTransformContext["messageType"];
mode?: MermaidRenderingMode;
theme?: Theme;
theme?: Pick<Theme, "fg" | "getFgAnsi">;
}
function transformMermaid(markdown: string, options: TransformOptions = {}): string {
@ -38,22 +41,74 @@ describe("Mermaid rendering", () => {
});
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```";
expect(transformMermaid(unsupported)).toBe(unsupported);
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", () => {
const theme = {
fg: (color: string, text: string) => `<${color}>${text}</${color}>`,
bold: (text: string) => `<bold>${text}</bold>`,
} as Theme;
fg: (_color: string, text: string) => text,
getFgAnsi: (color: string) => {
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 });
expect(rendered).toContain("<borderMuted>");
expect(rendered).toContain("<accent>");
expect(rendered).toContain("\x1b[38;5;240m");
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", () => {
@ -72,29 +127,29 @@ describe("Mermaid rendering", () => {
});
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 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: "invalid"');
expect(final).toContain('dropped, expected a link: "!"');
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 │");
expect(streaming).toContain("│ broken │");
});
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);
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).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", () => {