feat(coding-agent): upgrade Mermaid terminal rendering

This commit is contained in:
Alexey Zaytsev 2026-08-15 01:45:53 -03:00
parent 36b60d2e89
commit 2eaffb1caa
6 changed files with 129 additions and 68 deletions

20
package-lock.json generated
View file

@ -3014,15 +3014,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/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@ -3688,6 +3679,15 @@
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/lovely-mermaid": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/lovely-mermaid/-/lovely-mermaid-0.3.2.tgz",
"integrity": "sha512-/tpiKaAOAy1Lw7k74pAL3sGdJgGbB8gaA3bey7C7jn53nDLHmMjpIl++rrEe76xNH5dAScf9IShWNGEzvM4pBQ==",
"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",
@ -5403,11 +5403,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.2",
"minimatch": "10.2.6",
"proper-lockfile": "4.1.2",
"semver": "7.8.5",

View file

@ -472,11 +472,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.2",
"minimatch": "10.2.6",
"proper-lockfile": "4.1.2",
"semver": "7.8.5",
@ -1411,15 +1411,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",
@ -1550,6 +1541,15 @@
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/lovely-mermaid": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/lovely-mermaid/-/lovely-mermaid-0.3.2.tgz",
"integrity": "sha512-/tpiKaAOAy1Lw7k74pAL3sGdJgGbB8gaA3bey7C7jn53nDLHmMjpIl++rrEe76xNH5dAScf9IShWNGEzvM4pBQ==",
"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

@ -17,11 +17,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.2",
"minimatch": "10.2.6",
"proper-lockfile": "4.1.2",
"semver": "7.8.5",
@ -1401,15 +1401,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",
@ -1540,6 +1531,15 @@
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
"license": "Apache-2.0"
},
"node_modules/lovely-mermaid": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/lovely-mermaid/-/lovely-mermaid-0.3.2.tgz",
"integrity": "sha512-/tpiKaAOAy1Lw7k74pAL3sGdJgGbB8gaA3bey7C7jn53nDLHmMjpIl++rrEe76xNH5dAScf9IShWNGEzvM4pBQ==",
"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

@ -57,11 +57,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.2",
"minimatch": "10.2.6",
"proper-lockfile": "4.1.2",
"semver": "7.8.5",

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", () => {