- skills/archify: vendored tt-a1i/archify 2.14.0 (MIT), pure Node 18+, zero deps - extensions/pi-mermaid: renders mermaid blocks as ASCII in TUI; deps beautiful-mermaid + mermaid - skills/project-diagramming-*: orchestration skills, per-project docs/ convention
597 lines
19 KiB
TypeScript
597 lines
19 KiB
TypeScript
import type { ExtensionAPI, ExtensionContext, MessageRenderer, SessionEntry } from "@mariozechner/pi-coding-agent";
|
||
import { getMarkdownTheme, keyHint } from "@mariozechner/pi-coding-agent";
|
||
import { Box, Spacer, Text, type Component, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
||
import { createHash } from "node:crypto";
|
||
import { renderMermaidAscii } from "beautiful-mermaid";
|
||
|
||
const MESSAGE_TYPE = "pi-mermaid";
|
||
const MERMAID_BLOCK_RE = /```mermaid\s*([\s\S]*?)```/gi;
|
||
const ISSUE_LINE_RE = /^\[mermaid:(warning|error)\](?:\[hash:[^\]]+\])?\s*(.*)$/;
|
||
const COLLAPSED_LINES = 10;
|
||
const MAX_BLOCKS = 5;
|
||
const MAX_SOURCE_LINES = 400;
|
||
const MAX_SOURCE_CHARS = 20000;
|
||
const MAX_SEEN_ISSUES = 200;
|
||
const MAX_ASCII_CACHE = 200;
|
||
const ASCII_PRESETS: Array<{ key: string; paddingX: number; boxBorderPadding: number }> = [
|
||
{ key: "default", paddingX: 5, boxBorderPadding: 1 },
|
||
{ key: "compact", paddingX: 3, boxBorderPadding: 1 },
|
||
{ key: "tight", paddingX: 2, boxBorderPadding: 1 },
|
||
{ key: "squeezed", paddingX: 1, boxBorderPadding: 0 },
|
||
];
|
||
|
||
type AsciiPreset = (typeof ASCII_PRESETS)[number];
|
||
|
||
const SUPPORTED_TYPES = new Map<string, string>([
|
||
["graph", "flowchart"],
|
||
["flowchart", "flowchart"],
|
||
["sequenceDiagram", "sequence"],
|
||
["classDiagram", "class"],
|
||
["erDiagram", "er"],
|
||
["stateDiagram", "state"],
|
||
["stateDiagram-v2", "state"],
|
||
]);
|
||
const SUPPORTED_TYPE_LABEL = "graph/flowchart, sequenceDiagram, classDiagram, erDiagram, stateDiagram(-v2)";
|
||
|
||
let mermaidParser: ((text: string) => Promise<void>) | null = null;
|
||
let mermaidParserError: string | null = null;
|
||
let mermaidParserWarned = false;
|
||
const seenIssueKeys = new Map<string, true>();
|
||
const asciiCache = new Map<string, AsciiVariant>();
|
||
const asciiLinesCache = new Map<string, { lines: string[]; previewLines: string[] }>();
|
||
|
||
function isDomPurifyError(message: string): boolean {
|
||
return message.includes("DOMPurify.addHook") || message.includes("DOMPurify");
|
||
}
|
||
|
||
async function getMermaidParser(): Promise<((text: string) => Promise<void>) | null> {
|
||
if (mermaidParser || mermaidParserError) return mermaidParser;
|
||
|
||
try {
|
||
const mod = await import("mermaid");
|
||
const api = (mod as any).default ?? (mod as any).mermaidAPI ?? mod;
|
||
if (!api || typeof api.parse !== "function") {
|
||
mermaidParserError = "Mermaid parse API not available";
|
||
return null;
|
||
}
|
||
if (typeof api.initialize === "function") {
|
||
try {
|
||
api.initialize({ startOnLoad: false });
|
||
} catch {
|
||
// ignore initialization errors
|
||
}
|
||
}
|
||
mermaidParser = async (text: string) => {
|
||
const result = api.parse(text);
|
||
if (result && typeof result.then === "function") {
|
||
await result;
|
||
}
|
||
};
|
||
return mermaidParser;
|
||
} catch (error) {
|
||
mermaidParserError = error instanceof Error ? error.message : String(error);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
interface MermaidIssue {
|
||
severity: "warning" | "error";
|
||
message: string;
|
||
}
|
||
|
||
interface AsciiVariant {
|
||
presetKey: string;
|
||
ascii: string;
|
||
lineCount: number;
|
||
maxLineWidth: number;
|
||
}
|
||
|
||
interface MermaidDetails {
|
||
source: string;
|
||
index: number;
|
||
ascii: string;
|
||
lineCount: number;
|
||
variants?: AsciiVariant[];
|
||
issues?: MermaidIssue[];
|
||
}
|
||
|
||
type MermaidNotification = { message: string; type: "warning" | "error" };
|
||
|
||
type ProcessBlockResult = {
|
||
diagramHash: string;
|
||
details: MermaidDetails;
|
||
issues: MermaidIssue[];
|
||
notifications: MermaidNotification[];
|
||
};
|
||
|
||
function normalizeMermaidSource(source: string): string {
|
||
return source.replace(/\s+$/g, "");
|
||
}
|
||
|
||
function formatIssueLines(issues: MermaidIssue[], hash: string): string {
|
||
if (issues.length === 0) return "";
|
||
return issues.map((issue) => `[mermaid:${issue.severity}][hash:${hash}] ${issue.message}`).join("\n");
|
||
}
|
||
|
||
function buildContextContent(
|
||
block: string,
|
||
hash: string,
|
||
issues: MermaidIssue[],
|
||
includeSource: boolean,
|
||
): string {
|
||
const issueLines = formatIssueLines(issues, hash);
|
||
if (!includeSource) return issueLines;
|
||
|
||
const normalizedBlock = normalizeMermaidSource(block);
|
||
const sourceBlock = `%% mermaid-hash: ${hash}\n${normalizedBlock}`;
|
||
const contextBlock = `\`\`\`mermaid\n${sourceBlock}\n\`\`\``;
|
||
return issueLines ? `${issueLines}\n\n${contextBlock}` : contextBlock;
|
||
}
|
||
|
||
function extractText(content: unknown): string {
|
||
if (typeof content === "string") return content;
|
||
if (Array.isArray(content)) {
|
||
return content
|
||
.map((part: any) => (part && part.type === "text" ? part.text : ""))
|
||
.filter((part: string) => part.trim().length > 0)
|
||
.join("\n");
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function extractMermaidBlocks(text: string, maxBlocks = Infinity): string[] {
|
||
const blocks: string[] = [];
|
||
MERMAID_BLOCK_RE.lastIndex = 0;
|
||
let match: RegExpExecArray | null = null;
|
||
while ((match = MERMAID_BLOCK_RE.exec(text)) !== null) {
|
||
const code = match[1]?.trim();
|
||
if (code) blocks.push(code);
|
||
if (blocks.length >= maxBlocks) break;
|
||
}
|
||
return blocks;
|
||
}
|
||
|
||
function getMermaidTypeToken(block: string): string | null {
|
||
const lines = block.split(/\r?\n/);
|
||
for (const line of lines) {
|
||
const trimmed = line.trim();
|
||
if (!trimmed) continue;
|
||
if (trimmed.startsWith("%%")) continue;
|
||
return trimmed.split(/\s+/)[0] ?? null;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function getSupportedMermaidType(block: string): { token: string | null; normalized: string | null } {
|
||
const token = getMermaidTypeToken(block);
|
||
if (!token) return { token, normalized: null };
|
||
return { token, normalized: SUPPORTED_TYPES.get(token) ?? null };
|
||
}
|
||
|
||
function hashMermaid(block: string): string {
|
||
return createHash("sha256").update(block).digest("hex").slice(0, 8);
|
||
}
|
||
|
||
function getAsciiCacheKey(diagramHash: string, presetKey: string): string {
|
||
return `${diagramHash}:${presetKey}`;
|
||
}
|
||
|
||
function getCachedVariant(key: string): AsciiVariant | null {
|
||
const cached = asciiCache.get(key);
|
||
if (!cached) return null;
|
||
asciiCache.delete(key);
|
||
asciiCache.set(key, cached);
|
||
return cached;
|
||
}
|
||
|
||
function setCachedVariant(key: string, variant: AsciiVariant): void {
|
||
asciiCache.set(key, variant);
|
||
if (asciiCache.size > MAX_ASCII_CACHE) {
|
||
const oldest = asciiCache.keys().next().value as string | undefined;
|
||
if (oldest) asciiCache.delete(oldest);
|
||
}
|
||
}
|
||
|
||
function countAsciiLines(ascii: string): number {
|
||
if (!ascii) return 0;
|
||
return ascii.split(/\r?\n/).length;
|
||
}
|
||
|
||
function maxAsciiLineWidth(ascii: string): number {
|
||
if (!ascii) return 0;
|
||
return ascii.split(/\r?\n/).reduce((max, line) => Math.max(max, visibleWidth(line)), 0);
|
||
}
|
||
|
||
function getCachedAsciiLines(ascii: string): { lines: string[]; previewLines: string[] } {
|
||
if (!ascii) return { lines: [], previewLines: [] };
|
||
const cached = asciiLinesCache.get(ascii);
|
||
if (cached) {
|
||
asciiLinesCache.delete(ascii);
|
||
asciiLinesCache.set(ascii, cached);
|
||
return cached;
|
||
}
|
||
|
||
const lines = ascii.split(/\r?\n/);
|
||
const previewLines = lines.length > COLLAPSED_LINES ? lines.slice(0, COLLAPSED_LINES) : lines;
|
||
const entry = { lines, previewLines };
|
||
asciiLinesCache.set(ascii, entry);
|
||
if (asciiLinesCache.size > MAX_ASCII_CACHE) {
|
||
const oldest = asciiLinesCache.keys().next().value as string | undefined;
|
||
if (oldest) asciiLinesCache.delete(oldest);
|
||
}
|
||
return entry;
|
||
}
|
||
|
||
function renderAsciiVariant(block: string, diagramHash: string, preset: AsciiPreset): AsciiVariant {
|
||
const cacheKey = getAsciiCacheKey(diagramHash, preset.key);
|
||
const cached = getCachedVariant(cacheKey);
|
||
if (cached) return cached;
|
||
|
||
const ascii = renderMermaidAscii(block, {
|
||
paddingX: preset.paddingX,
|
||
boxBorderPadding: preset.boxBorderPadding,
|
||
colorMode: "none",
|
||
}).trimEnd();
|
||
const lineCount = countAsciiLines(ascii);
|
||
const maxLineWidth = maxAsciiLineWidth(ascii);
|
||
getCachedAsciiLines(ascii);
|
||
const variant: AsciiVariant = { presetKey: preset.key, ascii, lineCount, maxLineWidth };
|
||
setCachedVariant(cacheKey, variant);
|
||
return variant;
|
||
}
|
||
|
||
function selectAsciiVariant(
|
||
width: number,
|
||
variants: AsciiVariant[] | undefined,
|
||
fallbackAscii: string,
|
||
fallbackLineCount: number,
|
||
): { ascii: string; lineCount: number; maxLineWidth: number; clipped: boolean } {
|
||
const safeWidth = Math.max(1, width);
|
||
if (variants && variants.length > 0) {
|
||
for (const variant of variants) {
|
||
if (variant.maxLineWidth <= safeWidth) {
|
||
return { ...variant, clipped: false };
|
||
}
|
||
}
|
||
const tightest = variants[variants.length - 1];
|
||
return { ...tightest, clipped: tightest.maxLineWidth > safeWidth };
|
||
}
|
||
|
||
const maxLineWidth = maxAsciiLineWidth(fallbackAscii);
|
||
const lineCount = fallbackLineCount || countAsciiLines(fallbackAscii);
|
||
return { ascii: fallbackAscii, lineCount, maxLineWidth, clipped: maxLineWidth > safeWidth };
|
||
}
|
||
|
||
function splitIssuesFromContent(text: string): { ascii: string; issues: MermaidIssue[] } {
|
||
if (!text) return { ascii: "", issues: [] };
|
||
|
||
const lines = text.split(/\r?\n/);
|
||
const issues: MermaidIssue[] = [];
|
||
let current: MermaidIssue | null = null;
|
||
let i = 0;
|
||
let inIssues = false;
|
||
|
||
while (i < lines.length) {
|
||
const line = lines[i];
|
||
const match = line.match(ISSUE_LINE_RE);
|
||
|
||
if (match) {
|
||
inIssues = true;
|
||
if (current) issues.push(current);
|
||
current = { severity: match[1] as MermaidIssue["severity"], message: match[2] };
|
||
i++;
|
||
continue;
|
||
}
|
||
|
||
if (inIssues) {
|
||
if (line.trim() === "") {
|
||
if (current) issues.push(current);
|
||
i++;
|
||
break;
|
||
}
|
||
if (current) {
|
||
current = { ...current, message: `${current.message}\n${line}` };
|
||
}
|
||
i++;
|
||
continue;
|
||
}
|
||
|
||
break;
|
||
}
|
||
|
||
if (current && !issues.includes(current)) issues.push(current);
|
||
|
||
const ascii = lines.slice(i).join("\n");
|
||
if (issues.length > 0) return { ascii, issues };
|
||
return { ascii: ascii || text, issues };
|
||
}
|
||
|
||
function getLastAssistantText(entries: SessionEntry[]): string | null {
|
||
for (let i = entries.length - 1; i >= 0; i--) {
|
||
const entry = entries[i];
|
||
if (entry.type !== "message") continue;
|
||
if (entry.message.role !== "assistant") continue;
|
||
const text = extractText(entry.message.content);
|
||
if (text.trim()) return text;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
async function processBlock(
|
||
block: string,
|
||
blockIndex: number,
|
||
blockLabel: string,
|
||
parser: ((text: string) => Promise<void>) | null,
|
||
warnParserUnavailable: (errorMessage?: string) => void,
|
||
): Promise<ProcessBlockResult> {
|
||
const issues: MermaidIssue[] = [];
|
||
const notifications: MermaidNotification[] = [];
|
||
const diagramHash = hashMermaid(block);
|
||
|
||
const addIssue = (severity: MermaidIssue["severity"], message: string) => {
|
||
notifications.push({ message, type: severity === "error" ? "error" : "warning" });
|
||
const key = `${diagramHash}:${severity}:${message}`;
|
||
if (seenIssueKeys.has(key)) return;
|
||
seenIssueKeys.set(key, true);
|
||
if (seenIssueKeys.size > MAX_SEEN_ISSUES) {
|
||
const oldest = seenIssueKeys.keys().next().value as string | undefined;
|
||
if (oldest) seenIssueKeys.delete(oldest);
|
||
}
|
||
issues.push({ severity, message });
|
||
};
|
||
|
||
let parserFailed = false;
|
||
if (parser) {
|
||
try {
|
||
await parser(block);
|
||
} catch (error) {
|
||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||
if (isDomPurifyError(errorMessage)) {
|
||
warnParserUnavailable(errorMessage);
|
||
} else {
|
||
parserFailed = true;
|
||
const message = `Mermaid parse error${blockLabel}: ${errorMessage}`;
|
||
addIssue("error", message);
|
||
}
|
||
}
|
||
}
|
||
|
||
let ascii = "";
|
||
let lineCount = 0;
|
||
let variants: AsciiVariant[] | undefined;
|
||
if (parserFailed) {
|
||
ascii = "[parse failed]";
|
||
lineCount = 1;
|
||
} else {
|
||
try {
|
||
variants = [];
|
||
for (const preset of ASCII_PRESETS) {
|
||
try {
|
||
variants.push(renderAsciiVariant(block, diagramHash, preset));
|
||
} catch (error) {
|
||
if (preset.key === "squeezed") continue;
|
||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||
const message = `Mermaid render failed${blockLabel} (${preset.key}): ${errorMessage}`;
|
||
notifications.push({ message, type: "warning" });
|
||
}
|
||
}
|
||
if (variants.length === 0) {
|
||
throw new Error("No ASCII variants rendered");
|
||
}
|
||
ascii = variants[0].ascii;
|
||
lineCount = variants[0].lineCount;
|
||
} catch (error) {
|
||
const errorMessage = error instanceof Error ? error.message : String(error);
|
||
const message = `Mermaid render failed${blockLabel}: ${errorMessage}`;
|
||
addIssue("error", message);
|
||
ascii = "[render failed]";
|
||
lineCount = 1;
|
||
variants = undefined;
|
||
}
|
||
}
|
||
|
||
return {
|
||
diagramHash,
|
||
details: {
|
||
source: block,
|
||
index: blockIndex,
|
||
ascii,
|
||
lineCount,
|
||
variants: variants && variants.length > 0 ? variants : undefined,
|
||
issues: issues.length > 0 ? issues : undefined,
|
||
},
|
||
issues,
|
||
notifications,
|
||
};
|
||
}
|
||
|
||
export default function (pi: ExtensionAPI) {
|
||
const renderMermaidMessage: MessageRenderer<MermaidDetails> = (message, { expanded }, theme) => {
|
||
const details = message.details as MermaidDetails | undefined;
|
||
const contentText = extractText(message.content);
|
||
const fallback = splitIssuesFromContent(contentText);
|
||
const fallbackAscii = details?.ascii ?? fallback.ascii;
|
||
const fallbackLineCount = details?.lineCount ?? countAsciiLines(fallbackAscii);
|
||
const variants = details?.variants;
|
||
|
||
const asciiComponent: Component = {
|
||
render: (width) => {
|
||
const contentWidth = Math.max(1, width);
|
||
const label = theme.fg("customMessageLabel", theme.bold("Mermaid (ASCII)"));
|
||
const selection = selectAsciiVariant(contentWidth, variants, fallbackAscii, fallbackLineCount);
|
||
const asciiLines = getCachedAsciiLines(selection.ascii);
|
||
const hasOverflow = selection.lineCount > COLLAPSED_LINES;
|
||
const isExpanded = expanded || !hasOverflow;
|
||
const visibleLines = isExpanded ? asciiLines.lines : asciiLines.previewLines;
|
||
const needsClip = selection.maxLineWidth > contentWidth;
|
||
const clipAsciiLine = needsClip
|
||
? (line: string) => truncateToWidth(line, contentWidth, "")
|
||
: (line: string) => line;
|
||
|
||
const lines: string[] = [];
|
||
lines.push(truncateToWidth(label, contentWidth));
|
||
for (const line of visibleLines) {
|
||
lines.push(clipAsciiLine(line));
|
||
}
|
||
|
||
if (hasOverflow && !isExpanded) {
|
||
const remainingLines = selection.lineCount - COLLAPSED_LINES;
|
||
const hintText = `... (${remainingLines} more lines, ${keyHint("expandTools", "to expand")})`;
|
||
lines.push(truncateToWidth(theme.fg("muted", hintText), contentWidth));
|
||
}
|
||
|
||
if (selection.clipped) {
|
||
const hintText = "... (clipped to fit width; widen terminal to view full diagram)";
|
||
lines.push(truncateToWidth(theme.fg("muted", hintText), contentWidth));
|
||
}
|
||
|
||
return lines;
|
||
},
|
||
invalidate: () => {},
|
||
};
|
||
|
||
const box = new Box(1, 1, (t: string) => theme.bg("customMessageBg", t));
|
||
box.addChild(asciiComponent);
|
||
|
||
if (expanded && details?.source) {
|
||
box.addChild(new Spacer(1));
|
||
const markdownTheme = getMarkdownTheme();
|
||
const indent = markdownTheme.codeBlockIndent ?? " ";
|
||
const normalizedSource = normalizeMermaidSource(details.source);
|
||
const highlighted = markdownTheme.highlightCode?.(normalizedSource, "mermaid");
|
||
const codeLines = highlighted ?? normalizedSource.split("\n").map((line) => markdownTheme.codeBlock(line));
|
||
const renderedLines = [
|
||
markdownTheme.codeBlockBorder("```mermaid"),
|
||
...codeLines.map((line) => `${indent}${line}`),
|
||
markdownTheme.codeBlockBorder("```"),
|
||
].join("\n");
|
||
box.addChild(new Text(renderedLines, 0, 0));
|
||
}
|
||
|
||
return box;
|
||
};
|
||
|
||
pi.registerMessageRenderer(MESSAGE_TYPE, renderMermaidMessage);
|
||
|
||
const renderBlocks = async (
|
||
blocks: string[],
|
||
ctx: ExtensionContext,
|
||
options: { includeSourceInContext?: boolean } = {},
|
||
) => {
|
||
const notify = (message: string, type: "info" | "warning" | "error") => {
|
||
if (ctx.hasUI) ctx.ui.notify(message, type);
|
||
};
|
||
|
||
const warnParserUnavailable = (errorMessage?: string) => {
|
||
if (!ctx.hasUI || mermaidParserWarned) return;
|
||
const suffixSource = errorMessage ?? mermaidParserError;
|
||
const suffix = suffixSource ? ` (${suffixSource})` : "";
|
||
notify(
|
||
`Mermaid parser validation isn’t usable right now${suffix}. Will try again next time; rendering anyway.`,
|
||
"warning",
|
||
);
|
||
mermaidParserWarned = true;
|
||
};
|
||
|
||
let parser = await getMermaidParser();
|
||
if (!parser) warnParserUnavailable();
|
||
|
||
if (blocks.length > MAX_BLOCKS) {
|
||
notify(`Found ${blocks.length} mermaid blocks, rendering first ${MAX_BLOCKS}.`, "warning");
|
||
}
|
||
|
||
for (const [index, block] of blocks.slice(0, MAX_BLOCKS).entries()) {
|
||
const blockIndex = index + 1;
|
||
const blockLabel = blocks.length > 1 ? ` (block ${blockIndex})` : "";
|
||
const sourceLines = block.split(/\r?\n/);
|
||
if (sourceLines.length > MAX_SOURCE_LINES || block.length > MAX_SOURCE_CHARS) {
|
||
notify(
|
||
`Mermaid block ${blockIndex} too large (${sourceLines.length} lines, ${block.length} chars).`,
|
||
"warning",
|
||
);
|
||
continue;
|
||
}
|
||
|
||
const { token, normalized } = getSupportedMermaidType(block);
|
||
if (!normalized) {
|
||
const typeLabel = token ?? "unknown";
|
||
notify(
|
||
`pi-mermaid can't render type "${typeLabel}"${blockLabel}. Supported: ${SUPPORTED_TYPE_LABEL}.`,
|
||
"info",
|
||
);
|
||
continue;
|
||
}
|
||
|
||
const { diagramHash, details, issues, notifications } = await processBlock(
|
||
block,
|
||
blockIndex,
|
||
blockLabel,
|
||
parser,
|
||
warnParserUnavailable,
|
||
);
|
||
|
||
const includeSource = options.includeSourceInContext ?? true;
|
||
const contextContent = buildContextContent(block, diagramHash, issues, includeSource);
|
||
pi.sendMessage({
|
||
customType: MESSAGE_TYPE,
|
||
content: contextContent,
|
||
display: true,
|
||
details,
|
||
});
|
||
|
||
for (const notification of notifications) {
|
||
notify(notification.message, notification.type);
|
||
}
|
||
}
|
||
};
|
||
|
||
pi.on("input", async (event, ctx) => {
|
||
if (event.source === "extension") return { action: "continue" };
|
||
|
||
const text = typeof event.text === "string" ? event.text : "";
|
||
if (!text) return { action: "continue" };
|
||
|
||
const blocks = extractMermaidBlocks(text, MAX_BLOCKS + 1);
|
||
if (blocks.length === 0) return { action: "continue" };
|
||
|
||
await renderBlocks(blocks, ctx, { includeSourceInContext: blocks.length > 1 });
|
||
return { action: "continue" };
|
||
});
|
||
|
||
pi.on("agent_end", async (event, ctx) => {
|
||
let assistantText = "";
|
||
for (let i = event.messages.length - 1; i >= 0; i--) {
|
||
const msg = event.messages[i];
|
||
if (msg.role !== "assistant") continue;
|
||
assistantText = extractText(msg.content);
|
||
if (assistantText.trim()) break;
|
||
}
|
||
|
||
if (!assistantText) return;
|
||
|
||
const blocks = extractMermaidBlocks(assistantText, MAX_BLOCKS + 1);
|
||
if (blocks.length === 0) return;
|
||
|
||
await renderBlocks(blocks, ctx, { includeSourceInContext: blocks.length > 1 });
|
||
});
|
||
|
||
pi.registerCommand("pi-mermaid", {
|
||
description: "Render mermaid in last assistant message as ASCII",
|
||
handler: async (_args, ctx) => {
|
||
const lastAssistant = getLastAssistantText(ctx.sessionManager.getBranch());
|
||
if (!lastAssistant) {
|
||
if (ctx.hasUI) ctx.ui.notify("No assistant message found", "warning");
|
||
return;
|
||
}
|
||
|
||
const blocks = extractMermaidBlocks(lastAssistant, MAX_BLOCKS + 1);
|
||
if (blocks.length === 0) {
|
||
if (ctx.hasUI) ctx.ui.notify("No mermaid blocks found", "warning");
|
||
return;
|
||
}
|
||
|
||
await renderBlocks(blocks, ctx, { includeSourceInContext: true });
|
||
},
|
||
});
|
||
}
|