From 2eaffb1caae584f94aa7af84caa97ecc833fa45c Mon Sep 17 00:00:00 2001 From: Alexey Zaytsev Date: Sat, 15 Aug 2026 01:45:53 -0300 Subject: [PATCH] feat(coding-agent): upgrade Mermaid terminal rendering --- package-lock.json | 20 ++--- .../install-lock/package-lock.json | 20 ++--- packages/coding-agent/npm-shrinkwrap.json | 20 ++--- packages/coding-agent/package.json | 2 +- .../modes/interactive/components/mermaid.ts | 52 +++++++----- packages/coding-agent/test/mermaid.test.ts | 83 +++++++++++++++---- 6 files changed, 129 insertions(+), 68 deletions(-) diff --git a/package-lock.json b/package-lock.json index fec9dbd0c..735ddf3fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/packages/coding-agent/install-lock/package-lock.json b/packages/coding-agent/install-lock/package-lock.json index 7d0a6eace..f637de6a5 100644 --- a/packages/coding-agent/install-lock/package-lock.json +++ b/packages/coding-agent/install-lock/package-lock.json @@ -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", diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index 00054a461..6d7833bc0 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -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", diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index e3edfd0a3..22ff6c5d3 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -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", diff --git a/packages/coding-agent/src/modes/interactive/components/mermaid.ts b/packages/coding-agent/src/modes/interactive/components/mermaid.ts index a8d5e3cdf..38cbc3b51 100644 --- a/packages/coding-agent/src/modes/interactive/components/mermaid.ts +++ b/packages/coding-agent/src/modes/interactive/components/mermaid.ts @@ -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; + 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. */ diff --git a/packages/coding-agent/test/mermaid.test.ts b/packages/coding-agent/test/mermaid.test.ts index 332bf1f30..20af41e65 100644 --- a/packages/coding-agent/test/mermaid.test.ts +++ b/packages/coding-agent/test/mermaid.test.ts @@ -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; } 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}`, - bold: (text: string) => `${text}`, - } as Theme; + fg: (_color: string, text: string) => text, + getFgAnsi: (color: string) => { + const colors: Record = { + 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(""); - expect(rendered).toContain(""); + 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", () => {