feat(coding-agent): render Mermaid diagrams inline in the terminal (#1893)
* feat(coding-agent): render Mermaid code blocks as inline Unicode diagrams Ports pi-mono's Mermaid rendering (grok-mermaid) into prime-agent: - pi-tui Markdown gains an options.transform hook that rewrites source Markdown with the exact content width before parsing (ported from upstream pi-tui). - New mermaid.ts transform replaces top-level mermaid code blocks with themed Unicode diagrams, falls back to the raw block when the diagram is wider than the message, and appends a themed warning line for partially rendered final diagrams. - AssistantMessageComponent applies the transform to text blocks only (never thinking blocks) and tracks streaming state so the streaming-to-final transition re-renders. - New markdown.mermaid setting (off/final/streaming, default streaming) with a settings-selector entry. Linear: ENG-5673 * test(coding-agent): kill mermaid mutation survivors Adversarial review mutation testing found three surviving mutants: - width guard flipped to >= (exact-fit diagrams would fall back) - codeSpan fence reduced to a single backtick (backtick labels break) - isStreaming inverted at the message_end call site (final mode never renders) Adds an exact-width boundary test, a backtick-label test, and a handleEvent-level final-mode transition test that kill them. * build(coding-agent): pin grok-mermaid exactly for renderer-output stability * test(coding-agent): collapse redundant mermaid coverage * docs(coding-agent): tighten mermaid comments * fix(coding-agent): tolerate non-object markdown settings values Normalize a malformed markdown settings value at the migration boundary (mirroring the telemetry handling) so changing the Mermaid mode cannot throw, and add the missing tui changelog fragment. * test(coding-agent): drop mermaid.test.ts * chore(coding-agent): one-line mermaid codeSpan comments
3418 unmodified linesExpand all341934203421342234233424342534263427342834293430343134323433343434352255 unmodified linesExpand all569156925693569456955696569756985699138 unmodified linesExpand all3418 unmodified linesExpand all "node_modules/graceful-fs": { "version": "4.2.11", "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", "license": "MIT", "engines": {2255 unmodified linesExpand all "diff": "^9.0.0", "extract-zip": "^2.0.1", "file-type": "^21.1.1", "glob": "^13.0.1", "grok-mermaid": "0.2.3", "hosted-git-info": "^9.0.2", "ignore": "^7.0.5", "jiti": "^2.7.0", "marked": "^18.0.7",138 unmodified linesExpand all56 unmodified lines57585960616263646547 unmodified lines56 unmodified lines "diff": "^9.0.0", "extract-zip": "^2.0.1", "file-type": "^21.1.1", "glob": "^13.0.1", "grok-mermaid": "0.2.3", "hosted-git-info": "^9.0.2", "ignore": "^7.0.5", "jiti": "^2.7.0", "marked": "^18.0.7",47 unmodified lines1- Render Mermaid code blocks in assistant messages as inline Unicode diagrams, with a "Mermaid diagrams" setting (off/final/streaming, default streaming).4 unmodified lines5678910111213194 unmodified linesExpand all208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244137 unmodified linesExpand all4 unmodified linesimport type { AgentConnectionSessionEvent } from "../src/modes/agent-connection/index.js";import { AgentActivityTracker } from "../src/modes/interactive/agent-activity.js";import type { AssistantMessageComponent } from "../src/modes/interactive/components/assistant-message.js";import type { FileChangeSummary } from "../src/modes/interactive/components/edit-summary.js";import { createMermaidMarkdownTransform } from "../src/modes/interactive/components/mermaid.js";import type { ToolExecutionComponent } from "../src/modes/interactive/components/tool-execution.js";import { InteractiveMode } from "../src/modes/interactive/interactive-mode.js";import { getMarkdownTheme, initTheme } from "../src/modes/interactive/theme/theme.js";
194 unmodified linesExpand all expect(fakeThis.streamingComponent).toBeUndefined(); expect(fakeThis.streamingMessage).toBeUndefined(); });
test("defers mermaid rendering to message_end in final mode", async () => { const fakeThis = createFakeInteractiveModeThis() as HandleEventThis & { mermaidMarkdownTransform?: ReturnType<typeof createMermaidMarkdownTransform>; }; fakeThis.mermaidMarkdownTransform = createMermaidMarkdownTransform({ getMode: () => "final" }); const handleEvent = (InteractiveMode.prototype as unknown as { handleEvent: HandleEvent }).handleEvent; const mermaidText = "```mermaid\nflowchart LR\n A[Start] --> B[Done]\n```";
await handleEvent.call(fakeThis, { type: "message_update", message: createAssistantMessage(mermaidText), assistantMessageEvent: { type: "text_delta", contentIndex: 0, delta: mermaidText, partial: createAssistantMessage(mermaidText), }, });
expect(renderChat(fakeThis.chatContainer)).not.toContain("───▶");
await handleEvent.call(fakeThis, { type: "message_end", message: createAssistantMessage(mermaidText), });
expect(renderChat(fakeThis.chatContainer)).toContain("───▶"); });
test("renders one agent-run edit total only when files changed", async () => { const fakeThis = createFakeInteractiveModeThis(); const handleEvent = (InteractiveMode.prototype as unknown as { handleEvent: HandleEvent }).handleEvent; const message = createAssistantMessage("");137 unmodified linesExpand all204 unmodified linesExpand all205206207208209210211212213214215216217218219220221222223224225346 unmodified linesExpand all204 unmodified linesExpand all expect(manager.getOnboardingShown()).toBe(true); }); });
describe("mermaid rendering mode", () => { it("survives a non-object markdown settings value when saving the mode", async () => { writeFileSync(join(agentDir, "settings.json"), JSON.stringify({ markdown: "custom" })); const manager = SettingsManager.create(projectDir, agentDir);
manager.setMermaidRenderingMode("off"); await manager.flush();
expect(manager.getMermaidRenderingMode()).toBe("off"); expect(SettingsManager.create(projectDir, agentDir).getMermaidRenderingMode()).toBe("off"); }); });
describe("autoRefine", () => { it("defaults to enabled while preserving explicit opt-out", () => { const manager = SettingsManager.create(projectDir, agentDir);
346 unmodified linesExpand all22 unmodified lines23242526272829303117 unmodified lines49505152535455565758 unmodified lines22 unmodified lines availableThinkingLevels: ["off"], currentTheme: "dark", availableThemes: ["dark"], hideThinkingBlock: false, mermaidRenderingMode: "streaming", treeFilterMode: "user-only", showHardwareCursor: false, editorPaddingX: 0, autocompleteMaxVisible: 5,17 unmodified lines onTransportChange: () => {}, onThinkingLevelChange: () => {}, onThemeChange: () => {}, onHideThinkingBlockChange: () => {}, onMermaidRenderingModeChange: () => {}, onTreeFilterModeChange: () => {}, onShowHardwareCursorChange: () => {}, onEditorPaddingXChange: () => {}, onAutocompleteMaxVisibleChange: () => {},58 unmodified lines1- Add an optional `transform` hook to `Markdown` so callers can rewrite markdown with the exact content width before rendering.9 unmodified lines10111213141415161718118 unmodified linesExpand all9 unmodified linesexport { Editor, type EditorOptions, type EditorTheme } from "./components/editor.js";export { Image, type ImageOptions, type ImageTheme } from "./components/image.js";export { Input } from "./components/input.js";export { Loader, type LoaderIndicatorOptions } from "./components/loader.js";export { type DefaultTextStyle, Markdown, type MarkdownTheme } from "./components/markdown.js";export { type DefaultTextStyle, Markdown, type MarkdownOptions, type MarkdownTheme } from "./components/markdown.js";export { type SelectItem, SelectList, type SelectListLayoutOptions,118 unmodified linesExpand all58 unmodified lines59606162636465666768697071392 unmodified linesExpand all464465466467468469470471472473474475476477478813 unmodified linesExpand all129212931294129512961297129812991300130113021303130413051306130713081309131013116 unmodified lines58 unmodified lines medium?: number; high?: number;}
export type MermaidRenderingMode = "off" | "final" | "streaming";
export interface MarkdownSettings { codeBlockIndent?: string; // default: " " mermaid?: MermaidRenderingMode; // default: "streaming"}
export interface BundledSkillsSettings { websearch?: boolean; // default: true392 unmodified linesExpand all ) { delete settings.telemetry; }
if ( settings.markdown !== undefined && (typeof settings.markdown !== "object" || settings.markdown === null || Array.isArray(settings.markdown)) ) { delete settings.markdown; }
return settings as Settings; }
getGlobalSettings(): Settings {813 unmodified linesExpand all getCodeBlockIndent(): string { 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 ?? {}) }; }
6 unmodified lines