feat: introduced render_mermaid tool for ASCII diagram output

- Added render_mermaid tool to convert Mermaid diagrams to ASCII output with configurable rendering options.
- Added renderMermaid.enabled setting to control availability of the render_mermaid tool.
- Changed Mermaid rendering from PNG terminal graphics to ASCII text format across theme and TUI components.
- Migrated mermaid utilities from pi-tui to pi-utils package with new ASCII rendering functions.
- Removed PNG-based mermaid rendering APIs (getMermaidImage, renderMermaidToPng) in favor of ASCII alternatives.
This commit is contained in:
can1357
2026-03-01 09:38:36 +01:00
parent 0fbd76d3fa
commit 1c30f5c972
18 changed files with 199 additions and 275 deletions
+2 -1
View File
@@ -45,7 +45,8 @@
"!**/template.generated.ts",
"!**/docs-index.generated.ts",
"!**/gen/agent_pb.ts",
"!.worktrees/**/*"
"!.worktrees/**/*",
"!.wt/**/*"
]
},
"assist": { "actions": { "source": { "organizeImports": "on" } } }
+5
View File
@@ -165,6 +165,7 @@
"name": "@oh-my-pi/pi-utils",
"version": "13.5.1",
"dependencies": {
"beautiful-mermaid": "1.1.3",
"winston": "^3.19",
"winston-daily-rotate-file": "^5.0",
},
@@ -524,6 +525,8 @@
"basic-ftp": ["basic-ftp@5.2.0", "", {}, "sha512-VoMINM2rqJwJgfdHq6RiUudKt2BV+FY5ZFezP/ypmwayk68+NzzAQy4XXLlqsGD4MCzq3DrmNFD/uUmBJuGoXw=="],
"beautiful-mermaid": ["beautiful-mermaid@1.1.3", "", { "dependencies": { "elkjs": "^0.11.0", "entities": "^7.0.1" } }, "sha512-TItrtrAyHp1vwFfFVYauWGrquouk/6SS21Aq3RsxindSYZODcN4xYrPZD6BiZRU+o5mKJzDPz9MUSMvELdylyg=="],
"bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
"boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="],
@@ -604,6 +607,8 @@
"ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
"elkjs": ["elkjs@0.11.0", "", {}, "sha512-u4J8h9mwEDaYMqo0RYJpqNMFDoMK7f+pu4GjcV+N8jIC7TRdORgzkfSjTJemhqONFfH6fBI3wpysgWbhgVWIXw=="],
"emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
"enabled": ["enabled@2.0.0", "", {}, "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ=="],
+2 -1
View File
@@ -37,7 +37,8 @@
"release": "bun scripts/release.ts",
"generate-models": "bun --cwd=packages/ai scripts/generate-models.ts",
"generate-docs-index": "bun --cwd=packages/coding-agent run generate-docs-index",
"sync-exports": "bun scripts/sync-exports.ts"
"sync-exports": "bun scripts/sync-exports.ts",
"mermaid-build": "bun scripts/mermaid-build.ts"
},
"devDependencies": {
"@biomejs/biome": "^2",
+9
View File
@@ -1,6 +1,15 @@
# Changelog
## [Unreleased]
### Added
- Added `render_mermaid` tool to convert Mermaid graph source into ASCII diagram output
- Added `renderMermaid.enabled` setting to control availability of the render_mermaid tool
### Changed
- Changed Mermaid rendering from PNG images to ASCII diagrams in theme rendering
- Changed `prerenderMermaid()` function to synchronously render ASCII instead of asynchronously rendering PNG
## [13.5.0] - 2026-03-01
@@ -460,6 +460,15 @@ export const SETTINGS_SCHEMA = {
description: "Enable the ast_edit tool for structural AST rewrites",
},
},
"renderMermaid.enabled": {
type: "boolean",
default: false,
ui: {
tab: "tools",
label: "Enable Render Mermaid",
description: "Enable the render_mermaid tool for Mermaid-to-ASCII rendering",
},
},
"notebook.enabled": {
type: "boolean",
default: true,
@@ -1,88 +1,48 @@
import {
extractMermaidBlocks,
type MermaidImage,
type MermaidRenderOptions,
renderMermaidToPng,
} from "@oh-my-pi/pi-tui";
import { logger } from "@oh-my-pi/pi-utils";
import { extractMermaidBlocks, logger, renderMermaidAsciiSafe } from "@oh-my-pi/pi-utils";
const cache = new Map<bigint, MermaidImage>();
const pending = new Map<bigint, Promise<MermaidImage | null>>();
const cache = new Map<bigint, string>();
const failed = new Set<bigint>();
const defaultOptions: MermaidRenderOptions = {
theme: "dark",
backgroundColor: "transparent",
};
let onRenderNeeded: (() => void) | null = null;
/**
* Set callback to trigger TUI re-render when mermaid images become available.
* Set callback to trigger TUI re-render when mermaid ASCII renders become available.
*/
export function setMermaidRenderCallback(callback: (() => void) | null): void {
onRenderNeeded = callback;
}
/**
* Get a pre-rendered mermaid image by hash.
* Get a pre-rendered mermaid ASCII diagram by hash.
* Returns null if not cached or rendering failed.
*/
export function getMermaidImage(hash: bigint): MermaidImage | null {
export function getMermaidAscii(hash: bigint): string | null {
return cache.get(hash) ?? null;
}
/**
* Pre-render all mermaid blocks in markdown text.
* Renders in parallel, deduplicates concurrent requests.
* Calls render callback when new images are cached.
* Render all mermaid blocks in markdown text.
* Caches results and calls render callback when new diagrams are available.
*/
export async function prerenderMermaid(
markdown: string,
options: MermaidRenderOptions = defaultOptions,
): Promise<void> {
export function prerenderMermaid(markdown: string): void {
const blocks = extractMermaidBlocks(markdown);
if (blocks.length === 0) return;
const promises: Promise<boolean>[] = [];
let hasNew = false;
for (const { source, hash } of blocks) {
if (cache.has(hash) || failed.has(hash)) continue;
let promise = pending.get(hash);
if (!promise) {
promise = renderMermaidToPng(source, options);
pending.set(hash, promise);
const ascii = renderMermaidAsciiSafe(source);
if (ascii) {
cache.set(hash, ascii);
hasNew = true;
} else {
failed.add(hash);
}
promises.push(
promise
.then(image => {
pending.delete(hash);
if (image) {
cache.set(hash, image);
failed.delete(hash);
return true;
}
failed.add(hash);
return false;
})
.catch(error => {
pending.delete(hash);
failed.add(hash);
logger.warn("Mermaid render failed", {
hash,
error: error instanceof Error ? error.message : String(error),
});
return false;
}),
);
}
const results = await Promise.all(promises);
const newImages = results.some(added => added);
if (newImages && onRenderNeeded) {
if (hasNew && onRenderNeeded) {
try {
onRenderNeeded();
} catch (error) {
@@ -107,5 +67,4 @@ export function hasPendingMermaid(markdown: string): boolean {
export function clearMermaidCache(): void {
cache.clear();
failed.clear();
pending.clear();
}
@@ -16,7 +16,7 @@ import chalk from "chalk";
import darkThemeJson from "./dark.json" with { type: "json" };
import { defaultThemes } from "./defaults";
import lightThemeJson from "./light.json" with { type: "json" };
import { getMermaidImage } from "./mermaid-cache";
import { getMermaidAscii } from "./mermaid-cache";
// ============================================================================
// Symbol Presets
@@ -2340,7 +2340,7 @@ export function getMarkdownTheme(): MarkdownTheme {
underline: (text: string) => theme.underline(text),
strikethrough: (text: string) => chalk.strikethrough(text),
symbols: getSymbolTheme(),
getMermaidImage,
getMermaidAscii,
highlightCode: (code: string, lang?: string): string[] => {
const validLang = lang && nativeSupportsLanguage(lang) ? lang : undefined;
try {
@@ -0,0 +1,9 @@
Convert Mermaid graph source into ASCII diagram output.
Parameters:
- `mermaid` (required): Mermaid graph text to render.
- `config` (optional): JSON render configuration (spacing and layout options).
Behavior:
- Returns ASCII diagram text.
- Saves full ASCII output to an artifact URL (`artifact://<id>`) when artifact storage is available.
- Returns an error when the Mermaid input is invalid or rendering fails.
+4
View File
@@ -30,6 +30,7 @@ import { NotebookTool } from "./notebook";
import { wrapToolWithMetaNotice } from "./output-meta";
import { PythonTool } from "./python";
import { ReadTool } from "./read";
import { RenderMermaidTool } from "./render-mermaid";
import { ResolveTool } from "./resolve";
import { reportFindingTool } from "./review";
import { loadSshTool } from "./ssh";
@@ -63,6 +64,7 @@ export * from "./notebook";
export * from "./pending-action";
export * from "./python";
export * from "./read";
export * from "./render-mermaid";
export * from "./resolve";
export * from "./review";
export * from "./ssh";
@@ -150,6 +152,7 @@ type ToolFactory = (session: ToolSession) => Tool | null | Promise<Tool | null>;
export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
ast_grep: s => new AstGrepTool(s),
ast_edit: s => new AstEditTool(s),
render_mermaid: s => new RenderMermaidTool(s),
ask: AskTool.createIf,
bash: s => new BashTool(s),
python: s => new PythonTool(s),
@@ -281,6 +284,7 @@ export async function createTools(session: ToolSession, toolNames?: string[]): P
if (name === "grep") return session.settings.get("grep.enabled");
if (name === "ast_grep") return session.settings.get("astGrep.enabled");
if (name === "ast_edit") return session.settings.get("astEdit.enabled");
if (name === "render_mermaid") return session.settings.get("renderMermaid.enabled");
if (name === "notebook") return session.settings.get("notebook.enabled");
if (name === "fetch") return session.settings.get("fetch.enabled");
if (name === "web_search") return session.settings.get("web_search.enabled");
@@ -0,0 +1,67 @@
import type { AgentTool, AgentToolContext, AgentToolResult, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import { type MermaidAsciiRenderOptions, renderMermaidAscii } from "@oh-my-pi/pi-utils";
import { type Static, Type } from "@sinclair/typebox";
import { renderPromptTemplate } from "../config/prompt-templates";
import renderMermaidDescription from "../prompts/tools/render-mermaid.md" with { type: "text" };
import type { ToolSession } from "./index";
const renderMermaidSchema = Type.Object({
mermaid: Type.String({ description: "Mermaid graph source text" }),
config: Type.Optional(
Type.Object({
useAscii: Type.Optional(Type.Boolean()),
paddingX: Type.Optional(Type.Number()),
paddingY: Type.Optional(Type.Number()),
boxBorderPadding: Type.Optional(Type.Number()),
}),
),
});
type RenderMermaidParams = Static<typeof renderMermaidSchema>;
function sanitizeRenderConfig(config: MermaidAsciiRenderOptions | undefined): MermaidAsciiRenderOptions | undefined {
if (!config) return undefined;
return {
useAscii: config.useAscii,
boxBorderPadding:
config.boxBorderPadding === undefined ? undefined : Math.max(0, Math.floor(config.boxBorderPadding)),
paddingX: config.paddingX === undefined ? undefined : Math.max(0, Math.floor(config.paddingX)),
paddingY: config.paddingY === undefined ? undefined : Math.max(0, Math.floor(config.paddingY)),
};
}
export interface RenderMermaidToolDetails {
artifactId?: string;
}
export class RenderMermaidTool implements AgentTool<typeof renderMermaidSchema, RenderMermaidToolDetails> {
readonly name = "render_mermaid";
readonly label = "RenderMermaid";
readonly description: string;
readonly parameters = renderMermaidSchema;
readonly strict = true;
constructor(private readonly session: ToolSession) {
this.description = renderPromptTemplate(renderMermaidDescription);
}
async execute(
_toolCallId: string,
params: RenderMermaidParams,
_signal?: AbortSignal,
_onUpdate?: AgentToolUpdateCallback<RenderMermaidToolDetails>,
_context?: AgentToolContext,
): Promise<AgentToolResult<RenderMermaidToolDetails>> {
const ascii = renderMermaidAscii(params.mermaid, sanitizeRenderConfig(params.config));
const { path: artifactPath, id: artifactId } =
(await this.session.allocateOutputArtifact?.("render_mermaid")) ?? {};
if (artifactPath) {
await Bun.write(artifactPath, ascii);
}
const artifactLine = artifactId ? `\n\nSaved artifact: artifact://${artifactId}` : "";
return {
content: [{ type: "text", text: `${ascii}${artifactLine}` }],
details: { artifactId },
};
}
}
@@ -130,6 +130,25 @@ describe("createTools", () => {
expect(names).toContain("ask");
});
it("excludes render_mermaid tool by default", async () => {
const session = createTestSession();
const tools = await createTools(session);
const names = tools.map(t => t.name);
expect(names).not.toContain("render_mermaid");
});
it("includes render_mermaid tool when enabled", async () => {
const session = createTestSession({
settings: createSettingsWithOverrides({
"renderMermaid.enabled": true,
}),
});
const tools = await createTools(session);
const names = tools.map(t => t.name);
expect(names).toContain("render_mermaid");
});
it("HIDDEN_TOOLS contains review tools", () => {
expect(Object.keys(HIDDEN_TOOLS).sort()).toEqual([
"exit_plan_mode",
+8
View File
@@ -1,6 +1,14 @@
# Changelog
## [Unreleased]
### Breaking Changes
- Removed `getMermaidImage` callback from MarkdownTheme; replaced with `getMermaidAscii` that accepts ASCII string instead of image data
- Removed mermaid module exports (`renderMermaidToPng`, `extractMermaidBlocks`, `prerenderMermaidBlocks`, `MermaidImage` interface)
### Changed
- Mermaid diagrams now render as ASCII text instead of terminal graphics protocol images
## [13.5.1] - 2026-03-01
### Fixed
+14 -73
View File
@@ -1,7 +1,6 @@
import { marked, type Token } from "marked";
import type { MermaidImage } from "../mermaid";
import type { SymbolTheme } from "../symbols";
import { encodeITerm2, encodeKitty, getCellDimensions, ImageProtocol, TERMINAL } from "../terminal-capabilities";
import { TERMINAL } from "../terminal-capabilities";
import type { Component } from "../tui";
import { applyBackgroundToLine, padding, replaceTabs, visibleWidth, wrapTextWithAnsi } from "../utils";
@@ -45,11 +44,11 @@ export interface MarkdownTheme {
underline: (text: string) => string;
highlightCode?: (code: string, lang?: string) => string[];
/**
* Lookup a pre-rendered mermaid image by source hash.
* Lookup a pre-rendered mermaid ASCII rendering by source hash.
* Hash is computed as `Bun.hash.xxHash64(source.trim())`.
* Return null to fall back to text rendering.
* Return null to fall back to fenced code rendering.
*/
getMermaidImage?: (sourceHash: bigint) => MermaidImage | null;
getMermaidAscii?: (sourceHash: bigint) => string | null;
symbols: SymbolTheme;
}
@@ -320,20 +319,19 @@ export class Markdown implements Component {
}
case "code": {
// Handle mermaid diagrams with image rendering when available
if (token.lang === "mermaid" && this.#theme.getMermaidImage) {
// Handle mermaid diagrams with ASCII rendering when available
if (token.lang === "mermaid" && this.#theme.getMermaidAscii) {
const hash = Bun.hash.xxHash64(token.text.trim());
const image = this.#theme.getMermaidImage(hash);
const ascii = this.#theme.getMermaidAscii(hash);
if (image && TERMINAL.imageProtocol) {
const imageLines = this.#renderMermaidImage(image, width);
if (imageLines) {
lines.push(...imageLines);
if (nextTokenType !== "space") {
lines.push("");
}
break;
if (ascii) {
for (const asciiLine of Bun.stripANSI(ascii).split("\n")) {
lines.push(asciiLine);
}
if (nextTokenType !== "space") {
lines.push("");
}
break;
}
}
@@ -811,61 +809,4 @@ export class Markdown implements Component {
lines.push(""); // Add spacing after table
return lines;
}
/**
* Render a mermaid image using terminal graphics protocol.
* Returns array of lines (image placeholder rows) or null if rendering fails.
*/
#renderMermaidImage(image: MermaidImage, availableWidth: number): string[] | null {
if (!TERMINAL.imageProtocol) return null;
const cellDims = getCellDimensions();
const scale = 0.5; // Render at 50% of natural size
// Calculate natural size in cells (don't scale up, only down if needed)
const naturalColumns = Math.ceil((image.widthPx * scale) / cellDims.widthPx);
const naturalRows = Math.ceil((image.heightPx * scale) / cellDims.heightPx);
// Use natural size, but cap to available width
const columns = Math.min(naturalColumns, availableWidth);
// If we had to shrink width, calculate proportional height
let rows: number;
if (columns < naturalColumns) {
// Scaled down - recalculate height
const scale = columns / naturalColumns;
rows = Math.max(1, Math.ceil(naturalRows * scale));
} else {
// Natural size
rows = naturalRows;
}
let sequence: string;
switch (TERMINAL.imageProtocol) {
case ImageProtocol.Kitty:
sequence = encodeKitty(image.base64, { columns, rows });
break;
case ImageProtocol.Iterm2:
sequence = encodeITerm2(image.base64, {
width: columns,
height: "auto",
preserveAspectRatio: true,
});
break;
default:
return null;
}
// Reserve space with empty lines, then output image with cursor-up
// This ensures TUI accounts for image height in layout
const lines: string[] = [];
for (let i = 0; i < rows - 1; i++) {
lines.push("");
}
// Move cursor up to first row, then output image
const moveUp = rows > 1 ? `\x1b[${rows - 1}A` : "";
lines.push(moveUp + sequence);
return lines;
}
}
-1
View File
@@ -25,7 +25,6 @@ export * from "./keybindings";
// Kitty keyboard protocol helpers
export * from "./keys";
// Mermaid diagram support
export * from "./mermaid";
// Input buffering for batch splitting
export * from "./stdin-buffer";
export type * from "./symbols";
-140
View File
@@ -1,140 +0,0 @@
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { $ } from "bun";
export interface MermaidImage {
base64: string;
widthPx: number;
heightPx: number;
}
export interface MermaidRenderOptions {
theme?: "default" | "dark" | "forest" | "neutral";
backgroundColor?: string;
width?: number;
scale?: number;
}
/**
* Render mermaid diagram source to PNG.
*
* Uses `mmdc` (mermaid-cli) which must be installed and in PATH.
* Returns null if rendering fails or mmdc is unavailable.
*/
export async function renderMermaidToPng(
source: string,
options: MermaidRenderOptions = {},
): Promise<MermaidImage | null> {
const mmdc = Bun.which("mmdc");
if (!mmdc) {
return null;
}
const tmpDir = path.join(os.tmpdir(), `mermaid-${Date.now()}-${Math.random().toString(36).slice(2)}`);
const inputPath = path.join(tmpDir, "input.mmd");
const outputPath = path.join(tmpDir, "output.png");
try {
await Bun.write(inputPath, source);
const args: string[] = ["-i", inputPath, "-o", outputPath, "-q"];
if (options.theme) {
args.push("-t", options.theme);
}
if (options.backgroundColor) {
args.push("-b", options.backgroundColor);
}
if (options.width) {
args.push("-w", String(options.width));
}
if (options.scale) {
args.push("-s", String(options.scale));
}
const result = await $`${mmdc} ${args}`.quiet().nothrow();
if (result.exitCode !== 0) {
return null;
}
const outputFile = Bun.file(outputPath);
if (!(await outputFile.exists())) {
return null;
}
const buffer = await outputFile.bytes();
const base64 = buffer.toBase64();
const dims = parsePngDimensions(buffer);
if (!dims) {
return null;
}
return {
base64,
widthPx: dims.width,
heightPx: dims.height,
};
} catch {
return null;
} finally {
await fs.rm(tmpDir, { recursive: true, force: true }).catch(() => {});
}
}
function parsePngDimensions(buffer: Uint8Array): { width: number; height: number } | null {
if (buffer.length < 24) return null;
if (buffer[0] !== 0x89 || buffer[1] !== 0x50 || buffer[2] !== 0x4e || buffer[3] !== 0x47) {
return null;
}
const view = new DataView(buffer.buffer, buffer.byteOffset, buffer.byteLength);
return {
width: view.getUint32(16, false),
height: view.getUint32(20, false),
};
}
/**
* Extract mermaid code blocks from markdown text.
* Returns array of { source, startIndex, endIndex } for each block.
*/
export function extractMermaidBlocks(markdown: string): { source: string; hash: bigint }[] {
const blocks: { source: string; hash: bigint }[] = [];
const regex = /```mermaid\s*\n([\s\S]*?)```/g;
for (let match = regex.exec(markdown); match !== null; match = regex.exec(markdown)) {
const source = match[1].trim();
const hash = Bun.hash.xxHash64(source);
blocks.push({ source, hash });
}
return blocks;
}
/**
* Pre-render all mermaid blocks in markdown text.
* Returns a cache map: hash → MermaidImage.
*/
export async function prerenderMermaidBlocks(
markdown: string,
options: MermaidRenderOptions = {},
): Promise<Map<bigint, MermaidImage>> {
const blocks = extractMermaidBlocks(markdown);
const cache = new Map<bigint, MermaidImage>();
const results = await Promise.all(
blocks.map(async ({ source, hash }) => {
const image = await renderMermaidToPng(source, options);
return { hash, image };
}),
);
for (const { hash, image } of results) {
if (image) {
cache.set(hash, image);
}
}
return cache;
}
+1
View File
@@ -27,6 +27,7 @@
"test": "bun test"
},
"dependencies": {
"beautiful-mermaid": "1.1.3",
"winston": "^3.19",
"winston-daily-rotate-file": "^5.0"
},
+1
View File
@@ -9,6 +9,7 @@ export * from "./glob";
export * from "./indent";
export * from "./json";
export * as logger from "./logger";
export * from "./mermaid-ascii";
export * as postmortem from "./postmortem";
export * as procmgr from "./procmgr";
export { setNativeKillTree } from "./procmgr";
+31
View File
@@ -0,0 +1,31 @@
import { type AsciiRenderOptions, renderMermaidASCII } from "beautiful-mermaid";
export type { AsciiRenderOptions as MermaidAsciiRenderOptions };
export function renderMermaidAscii(source: string, options?: AsciiRenderOptions): string {
return renderMermaidASCII(source, options);
}
export function renderMermaidAsciiSafe(source: string, options?: AsciiRenderOptions): string | null {
try {
return renderMermaidASCII(source, options);
} catch {
return null;
}
}
/**
* Extract mermaid code blocks from markdown text.
*/
export function extractMermaidBlocks(markdown: string): { source: string; hash: bigint }[] {
const blocks: { source: string; hash: bigint }[] = [];
const regex = /```mermaid\s*\n([\s\S]*?)```/g;
for (let match = regex.exec(markdown); match !== null; match = regex.exec(markdown)) {
const source = match[1].trim();
const hash = Bun.hash.xxHash64(source);
blocks.push({ source, hash });
}
return blocks;
}