@f5xc-salesdemos/xcsh 19.41.1 → 19.42.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5xc-salesdemos/xcsh",
4
- "version": "19.41.1",
4
+ "version": "19.42.1",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://github.com/f5xc-salesdemos/xcsh",
7
7
  "author": "Can Boluk",
@@ -39,6 +39,7 @@
39
39
  "fix": "biome check --write --unsafe . && bun run format-prompts && bun run generate-docs-index && bun run generate-api-spec-index && bun run generate-build-info",
40
40
  "fmt": "biome format --write . && bun run format-prompts",
41
41
  "format-prompts": "bun scripts/format-prompts.ts",
42
+ "mermaid:gallery": "bun scripts/mermaid-gallery.ts",
42
43
  "generate-docs-index": "bun scripts/generate-docs-index.ts",
43
44
  "generate-api-spec-index": "bun scripts/generate-api-spec-index.ts",
44
45
  "generate-branding-index": "bun scripts/generate-branding-index.ts",
@@ -51,13 +52,13 @@
51
52
  "dependencies": {
52
53
  "@agentclientprotocol/sdk": "0.16.1",
53
54
  "@mozilla/readability": "^0.6",
54
- "@f5xc-salesdemos/xcsh-stats": "19.41.1",
55
- "@f5xc-salesdemos/pi-agent-core": "19.41.1",
56
- "@f5xc-salesdemos/pi-ai": "19.41.1",
57
- "@f5xc-salesdemos/pi-natives": "19.41.1",
58
- "@f5xc-salesdemos/pi-resource-management": "19.41.1",
59
- "@f5xc-salesdemos/pi-tui": "19.41.1",
60
- "@f5xc-salesdemos/pi-utils": "19.41.1",
55
+ "@f5xc-salesdemos/xcsh-stats": "19.42.1",
56
+ "@f5xc-salesdemos/pi-agent-core": "19.42.1",
57
+ "@f5xc-salesdemos/pi-ai": "19.42.1",
58
+ "@f5xc-salesdemos/pi-natives": "19.42.1",
59
+ "@f5xc-salesdemos/pi-resource-management": "19.42.1",
60
+ "@f5xc-salesdemos/pi-tui": "19.42.1",
61
+ "@f5xc-salesdemos/pi-utils": "19.42.1",
61
62
  "@sinclair/typebox": "^0.34",
62
63
  "@xterm/headless": "^6.0",
63
64
  "ajv": "^8.20",
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * F5 XC Mermaid — visual gallery harness for human UAT.
4
+ *
5
+ * Renders the F5 Distributed Cloud mermaid sample library through the real
6
+ * display pipeline (themed per-role palette + per-node tint) so you can visually
7
+ * inspect the colorized output in your own terminal and give feedback.
8
+ *
9
+ * Usage:
10
+ * bun scripts/mermaid-gallery.ts # all samples, dark theme, truecolor
11
+ * bun scripts/mermaid-gallery.ts --theme light
12
+ * bun scripts/mermaid-gallery.ts --color ansi256 # none | ansi256 | truecolor
13
+ * bun scripts/mermaid-gallery.ts --ascii # ASCII box chars instead of Unicode
14
+ * bun scripts/mermaid-gallery.ts --type flowchart # filter by diagram type
15
+ * bun scripts/mermaid-gallery.ts --filter waf # regex over id/category/prompt
16
+ * bun scripts/mermaid-gallery.ts --list # list samples, don't render
17
+ *
18
+ * Tip: pipe through `less -R` to page while keeping color.
19
+ */
20
+ import type { MermaidColorMode } from "@f5xc-salesdemos/pi-utils";
21
+ import { renderMermaidThemed } from "../src/modes/theme/mermaid-cache";
22
+ import { getThemeByName } from "../src/modes/theme/theme";
23
+ import { XC_MERMAID_SAMPLES, type XcMermaidSample } from "../test/fixtures/xc-mermaid-samples";
24
+
25
+ interface Args {
26
+ theme: string;
27
+ color: MermaidColorMode;
28
+ ascii: boolean;
29
+ type?: string;
30
+ filter?: RegExp;
31
+ list: boolean;
32
+ }
33
+
34
+ function parseArgs(argv: string[]): Args {
35
+ const args: Args = { theme: "xcsh-dark", color: "truecolor", ascii: false, list: false };
36
+ for (let i = 0; i < argv.length; i++) {
37
+ const a = argv[i];
38
+ const next = () => argv[++i] ?? "";
39
+ switch (a) {
40
+ case "--theme":
41
+ args.theme = next() === "light" ? "xcsh-light" : "xcsh-dark";
42
+ break;
43
+ case "--color":
44
+ args.color = next() as MermaidColorMode;
45
+ break;
46
+ case "--ascii":
47
+ args.ascii = true;
48
+ break;
49
+ case "--type":
50
+ args.type = next();
51
+ break;
52
+ case "--filter":
53
+ args.filter = new RegExp(next(), "i");
54
+ break;
55
+ case "--list":
56
+ args.list = true;
57
+ break;
58
+ case "--help":
59
+ case "-h":
60
+ console.log(
61
+ "bun scripts/mermaid-gallery.ts [--theme dark|light] [--color none|ansi256|truecolor] [--ascii] [--type <t>] [--filter <regex>] [--list]",
62
+ );
63
+ process.exit(0);
64
+ }
65
+ }
66
+ return args;
67
+ }
68
+
69
+ function selectSamples(args: Args): XcMermaidSample[] {
70
+ return XC_MERMAID_SAMPLES.filter(s => {
71
+ if (args.type && s.type !== args.type) return false;
72
+ if (args.filter && !args.filter.test(`${s.id} ${s.category} ${s.prompt}`)) return false;
73
+ return true;
74
+ });
75
+ }
76
+
77
+ async function main(): Promise<void> {
78
+ const args = parseArgs(process.argv.slice(2));
79
+ const theme = await getThemeByName(args.theme);
80
+ if (!theme) {
81
+ console.error(`Unknown theme: ${args.theme}`);
82
+ process.exit(1);
83
+ }
84
+ const samples = selectSamples(args);
85
+ const width = Math.min(process.stdout.columns ?? 100, 110);
86
+ const rule = (ch: string) => theme.fg("accent", ch.repeat(width));
87
+
88
+ console.log(`\n${rule("━")}`);
89
+ console.log(
90
+ theme.fg("mdHeading", " F5 XC Mermaid Gallery") +
91
+ theme.fg(
92
+ "muted",
93
+ ` theme=${args.theme} color=${args.color} ascii=${args.ascii} samples=${samples.length}`,
94
+ ),
95
+ );
96
+ console.log(rule("━"));
97
+
98
+ if (args.list) {
99
+ for (const [i, s] of samples.entries()) {
100
+ console.log(
101
+ ` ${theme.fg("dim", String(i + 1).padStart(2, "0"))} ` +
102
+ `${theme.fg("accent", s.type.padEnd(10))} ${theme.fg("mdHeading", s.id.padEnd(28))} ${theme.fg("muted", s.category)}`,
103
+ );
104
+ }
105
+ console.log("");
106
+ return;
107
+ }
108
+
109
+ for (const [i, s] of samples.entries()) {
110
+ console.log(
111
+ `\n${theme.fg("accent", "━━━")} ${theme.fg("dim", `[${i + 1}/${samples.length}]`)} ${theme.fg("mdHeading", s.category)} ${theme.fg("muted", "·")} ${theme.fg("success", s.type)} ${theme.fg("accent", "━".repeat(Math.max(0, width - s.category.length - s.type.length - 18)))}`,
112
+ );
113
+ console.log(` ${theme.fg("dim", "▸")} ${theme.fg("mdLink", s.id)}`);
114
+ console.log(` ${theme.fg("muted", `"${s.prompt}"`)}\n`);
115
+
116
+ const out = renderMermaidThemed(s.source, theme, { colorMode: args.color, render: { useAscii: args.ascii } });
117
+ if (out == null) {
118
+ console.log(` ${theme.fg("error", "⚠ failed to render")}\n`);
119
+ continue;
120
+ }
121
+ for (const line of out.split("\n")) console.log(` ${line}`);
122
+ }
123
+
124
+ console.log(`\n${rule("━")}`);
125
+ console.log(
126
+ theme.fg("muted", ` Rendered ${samples.length} sample(s). Inspect the colors above and note anything to tune `) +
127
+ theme.fg("dim", "(borders, labels, edges, arrows, per-node tints, contrast)."),
128
+ );
129
+ console.log(rule("━") + "\n");
130
+ }
131
+
132
+ await main();
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "19.41.1",
21
- "commit": "0744c7f8bafe717d59be1fd3bc46fac5a79ba629",
22
- "shortCommit": "0744c7f",
20
+ "version": "19.42.1",
21
+ "commit": "164a80d621b7e0c5f5e89bf9cd7caac2911eb764",
22
+ "shortCommit": "164a80d",
23
23
  "branch": "main",
24
- "tag": "v19.41.1",
25
- "commitDate": "2026-06-23T18:52:47Z",
26
- "buildDate": "2026-06-23T19:26:09.570Z",
24
+ "tag": "v19.42.1",
25
+ "commitDate": "2026-06-24T02:41:01Z",
26
+ "buildDate": "2026-06-24T03:11:24.436Z",
27
27
  "dirty": true,
28
28
  "prNumber": "",
29
29
  "repoUrl": "https://github.com/f5xc-salesdemos/xcsh",
30
30
  "repoSlug": "f5xc-salesdemos/xcsh",
31
- "commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/0744c7f8bafe717d59be1fd3bc46fac5a79ba629",
32
- "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v19.41.1"
31
+ "commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/164a80d621b7e0c5f5e89bf9cd7caac2911eb764",
32
+ "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v19.42.1"
33
33
  };
@@ -2,7 +2,7 @@ import type { AssistantMessage, ImageContent, Usage } from "@f5xc-salesdemos/pi-
2
2
  import { Container, Image, ImageProtocol, Markdown, Spacer, TERMINAL, Text } from "@f5xc-salesdemos/pi-tui";
3
3
  import { formatNumber, logger } from "@f5xc-salesdemos/pi-utils";
4
4
  import { settings } from "../../config/settings";
5
- import { hasPendingMermaid, prerenderMermaid } from "../../modes/theme/mermaid-cache";
5
+ import { hasPendingMermaid, mermaidThemeSignature, prerenderMermaid } from "../../modes/theme/mermaid-cache";
6
6
  import { getMarkdownTheme, theme } from "../../modes/theme/theme";
7
7
  import { resolveImageOptions } from "../../tools/render-utils";
8
8
 
@@ -86,10 +86,14 @@ export class AssistantMessageComponent extends Container {
86
86
  }
87
87
  }
88
88
  #triggerMermaidPrerender(message: AssistantMessage): void {
89
- if (!TERMINAL.imageProtocol || this.#prerenderInFlight) return;
89
+ // ASCII/Unicode mermaid renders in any terminal — do NOT gate on image protocol.
90
+ if (this.#prerenderInFlight) return;
90
91
 
92
+ const sig = mermaidThemeSignature(theme);
91
93
  // Check if any text content has pending mermaid blocks
92
- const hasPending = message.content.some(c => c.type === "text" && c.text.trim() && hasPendingMermaid(c.text));
94
+ const hasPending = message.content.some(
95
+ c => c.type === "text" && c.text.trim() && hasPendingMermaid(c.text, sig),
96
+ );
93
97
  if (!hasPending) return;
94
98
 
95
99
  this.#prerenderInFlight = true;
@@ -98,8 +102,8 @@ export class AssistantMessageComponent extends Container {
98
102
  void (async () => {
99
103
  try {
100
104
  for (const content of message.content) {
101
- if (content.type === "text" && content.text.trim() && hasPendingMermaid(content.text)) {
102
- prerenderMermaid(content.text);
105
+ if (content.type === "text" && content.text.trim() && hasPendingMermaid(content.text, sig)) {
106
+ prerenderMermaid(content.text, theme);
103
107
  }
104
108
  }
105
109
  } catch (error) {
@@ -1,44 +1,93 @@
1
- import { extractMermaidBlocks, logger, renderMermaidAsciiSafe } from "@f5xc-salesdemos/pi-utils";
1
+ import {
2
+ extractMermaidBlocks,
3
+ logger,
4
+ type MermaidAsciiRenderOptions,
5
+ type MermaidColorMode,
6
+ mermaidSourceExceedsLimit,
7
+ pickColorMode,
8
+ renderMermaidAsciiSafe,
9
+ tintMermaidNodes,
10
+ } from "@f5xc-salesdemos/pi-utils";
11
+ import { buildMermaidAsciiTheme, buildNodeAccents } from "./mermaid-palette";
12
+ import type { Theme } from "./theme";
2
13
 
3
- const cache = new Map<bigint | number, string | null>();
14
+ // Keyed by `${sourceHash}|${themeSignature}` so a theme switch re-renders rather
15
+ // than serving a stale, differently-colored diagram.
16
+ const cache = new Map<string, string | null>();
4
17
 
5
18
  let onRenderNeeded: (() => void) | null = null;
6
19
 
7
- /**
8
- * Set callback to trigger TUI re-render when mermaid ASCII renders become available.
9
- */
20
+ /** Set callback to trigger a TUI re-render when new mermaid renders become available. */
10
21
  export function setMermaidRenderCallback(callback: (() => void) | null): void {
11
22
  onRenderNeeded = callback;
12
23
  }
13
24
 
14
- /**
15
- * Get a pre-rendered mermaid ASCII diagram by hash.
16
- * Returns null if not cached or rendering failed.
17
- */
18
- export function getMermaidAscii(hash: bigint | number): string | null {
19
- return cache.get(hash) ?? null;
25
+ function colorModeFor(theme: Theme, override?: MermaidColorMode): MermaidColorMode {
26
+ if (override) return override;
27
+ return pickColorMode({ noColor: Boolean(Bun.env.NO_COLOR), trueColor: theme.getColorMode() === "truecolor" });
28
+ }
29
+
30
+ function paletteSignature(theme: Theme): string {
31
+ return `${JSON.stringify(buildMermaidAsciiTheme(theme))}|${buildNodeAccents(theme).join(",")}`;
32
+ }
33
+
34
+ /** Stable signature identifying a theme's mermaid appearance — used for cache keying. */
35
+ export function mermaidThemeSignature(theme: Theme): string {
36
+ return `${colorModeFor(theme)}:${paletteSignature(theme)}`;
37
+ }
38
+
39
+ export interface RenderMermaidThemedOptions {
40
+ /** Force a color mode (otherwise derived from the theme + NO_COLOR). */
41
+ colorMode?: MermaidColorMode;
42
+ /** Extra layout options (padding, useAscii, …). */
43
+ render?: MermaidAsciiRenderOptions;
20
44
  }
21
45
 
22
46
  /**
23
- * Render all mermaid blocks in markdown text.
24
- * Caches results and calls render callback when new diagrams are available.
47
+ * Render mermaid source to a themed, per-node-tinted ASCII/Unicode string.
48
+ * Applies the theme's per-role palette, then the best-effort node-tint pass.
49
+ * Result is memoized per (source, theme, colorMode). Returns null on parse failure.
25
50
  */
26
- export function prerenderMermaid(markdown: string): void {
51
+ export function renderMermaidThemed(source: string, theme: Theme, opts?: RenderMermaidThemedOptions): string | null {
52
+ // Oversized graphs can hang the synchronous pathfinder for tens of seconds; skip
53
+ // them so the display falls back to the raw code block instead of freezing the UI.
54
+ if (mermaidSourceExceedsLimit(source)) return null;
55
+
56
+ const mode = colorModeFor(theme, opts?.colorMode);
57
+ // Layout options (useAscii, padding, …) change the output, so they must be part
58
+ // of the key. Omitted when absent so the key matches mermaidThemeSignature() —
59
+ // the no-options form used by the inline-markdown prerender/lookup path.
60
+ const optsSig = opts?.render && Object.keys(opts.render).length > 0 ? `|${JSON.stringify(opts.render)}` : "";
61
+ const key = `${Bun.hash(source.trim())}|${mode}:${paletteSignature(theme)}${optsSig}`;
62
+ const cached = cache.get(key);
63
+ if (cached !== undefined) return cached;
64
+
65
+ const asciiTheme = buildMermaidAsciiTheme(theme);
66
+ const colored = renderMermaidAsciiSafe(source, { ...opts?.render, colorMode: mode, theme: asciiTheme });
67
+ if (colored == null) {
68
+ cache.set(key, null);
69
+ return null;
70
+ }
71
+ const result = mode === "none" ? colored : tintMermaidNodes(colored.split("\n"), buildNodeAccents(theme)).join("\n");
72
+ cache.set(key, result);
73
+ return result;
74
+ }
75
+
76
+ /** Get a pre-rendered mermaid diagram by source hash + theme signature, or null. */
77
+ export function getMermaidAscii(hash: bigint | number, themeSig: string): string | null {
78
+ return cache.get(`${hash}|${themeSig}`) ?? null;
79
+ }
80
+
81
+ /** Render and cache every mermaid block in `markdown` for the given theme. */
82
+ export function prerenderMermaid(markdown: string, theme: Theme): void {
27
83
  const blocks = extractMermaidBlocks(markdown);
28
84
  if (blocks.length === 0) return;
29
85
 
86
+ const sig = mermaidThemeSignature(theme);
30
87
  let hasNew = false;
31
-
32
88
  for (const { source, hash } of blocks) {
33
- if (cache.has(hash)) continue;
34
-
35
- const ascii = renderMermaidAsciiSafe(source);
36
- if (ascii) {
37
- cache.set(hash, ascii);
38
- hasNew = true;
39
- } else {
40
- cache.set(hash, null);
41
- }
89
+ if (cache.has(`${hash}|${sig}`)) continue;
90
+ if (renderMermaidThemed(source, theme) != null) hasNew = true;
42
91
  }
43
92
 
44
93
  if (hasNew && onRenderNeeded) {
@@ -52,17 +101,13 @@ export function prerenderMermaid(markdown: string): void {
52
101
  }
53
102
  }
54
103
 
55
- /**
56
- * Check if markdown contains mermaid blocks that aren't cached yet.
57
- */
58
- export function hasPendingMermaid(markdown: string): boolean {
104
+ /** Check whether `markdown` has mermaid blocks not yet cached for this theme. */
105
+ export function hasPendingMermaid(markdown: string, themeSig: string): boolean {
59
106
  const blocks = extractMermaidBlocks(markdown);
60
- return blocks.some(({ hash }) => !cache.has(hash));
107
+ return blocks.some(({ hash }) => !cache.has(`${hash}|${themeSig}`));
61
108
  }
62
109
 
63
- /**
64
- * Clear the mermaid cache.
65
- */
110
+ /** Clear the mermaid cache. */
66
111
  export function clearMermaidCache(): void {
67
112
  cache.clear();
68
113
  }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Map the active xcsh UI theme onto a mermaid `AsciiTheme` (per-role hues) and a
3
+ * cycling list of node-accent ANSI escapes for the per-node tint pass. Reading the
4
+ * theme keeps diagrams on-brand and dark/light adaptive.
5
+ */
6
+ import type { MermaidAsciiRenderOptions } from "@f5xc-salesdemos/pi-utils";
7
+ import type { Theme } from "./theme";
8
+
9
+ type AsciiTheme = NonNullable<MermaidAsciiRenderOptions["theme"]>;
10
+
11
+ /**
12
+ * Per-role color palette: red node borders, gold labels, blue edges, green
13
+ * arrowheads. These show through wherever the per-node tint pass doesn't apply
14
+ * (edges, arrows, subgraph frames, and as the fallback when tinting is skipped).
15
+ */
16
+ export function buildMermaidAsciiTheme(theme: Theme): AsciiTheme {
17
+ return {
18
+ fg: theme.getFgHex("mdHeading", "#febc38"), // node/edge labels
19
+ border: theme.getFgHex("accent", "#ca260a"), // node + subgraph borders
20
+ line: theme.getFgHex("mdLink", "#0088fa"), // edge lines
21
+ arrow: theme.getFgHex("success", "#00ff88"), // arrowheads
22
+ corner: theme.getFgHex("mdLink", "#0088fa"),
23
+ junction: theme.getFgHex("accent", "#ca260a"),
24
+ };
25
+ }
26
+
27
+ /**
28
+ * ANSI foreground escapes cycled across detected node boxes so each node reads as
29
+ * a distinct hue. Drawn from vivid, well-separated theme roles.
30
+ */
31
+ export function buildNodeAccents(theme: Theme): string[] {
32
+ const roles = ["chromeAccent", "success", "warning", "mdLink", "accent"] as const;
33
+ const seen = new Set<string>();
34
+ const accents: string[] = [];
35
+ for (const role of roles) {
36
+ const ansi = theme.getFgAnsi(role);
37
+ if (!seen.has(ansi)) {
38
+ seen.add(ansi);
39
+ accents.push(ansi);
40
+ }
41
+ }
42
+ return accents;
43
+ }
@@ -16,7 +16,7 @@ import { TypeCompiler } from "@sinclair/typebox/compiler";
16
16
  import chalk from "chalk";
17
17
  // Embed theme JSON files at build time
18
18
  import { defaultThemes } from "./defaults";
19
- import { getMermaidAscii } from "./mermaid-cache";
19
+ import { getMermaidAscii, mermaidThemeSignature } from "./mermaid-cache";
20
20
 
21
21
  export { getLanguageFromPath } from "../../utils/lang-from-path";
22
22
 
@@ -1316,6 +1316,7 @@ const langMap: Record<string, SymbolKey> = {
1316
1316
 
1317
1317
  export class Theme {
1318
1318
  #fgColors: Record<ThemeColor, string>;
1319
+ #fgHex: Partial<Record<ThemeColor, string>>;
1319
1320
  #bgColors: Record<ThemeBg, string>;
1320
1321
  #symbols: SymbolMap;
1321
1322
 
@@ -1327,8 +1328,12 @@ export class Theme {
1327
1328
  symbolOverrides: Partial<Record<SymbolKey, string>>,
1328
1329
  ) {
1329
1330
  this.#fgColors = {} as Record<ThemeColor, string>;
1331
+ this.#fgHex = {};
1330
1332
  for (const [key, value] of Object.entries(fgColors) as [ThemeColor, string | number][]) {
1331
1333
  this.#fgColors[key] = fgAnsi(value, mode);
1334
+ // Retain the raw hex (when the role resolves to one) for consumers that need
1335
+ // a color value rather than an ANSI escape — e.g. the mermaid AsciiTheme.
1336
+ if (typeof value === "string" && value.startsWith("#")) this.#fgHex[key] = value;
1332
1337
  }
1333
1338
  // gutterError remains optional — neither shipped theme carries it
1334
1339
  this.#fgColors.gutterError ??= this.#fgColors.error;
@@ -1386,6 +1391,15 @@ export class Theme {
1386
1391
  return ansi;
1387
1392
  }
1388
1393
 
1394
+ /**
1395
+ * Raw hex (e.g. "#ca260a") for a color role, for consumers that need a color
1396
+ * value rather than an ANSI escape (e.g. the mermaid AsciiTheme). Roles that
1397
+ * resolve to "" (default fg) or an ansi256 index yield `fallback`.
1398
+ */
1399
+ getFgHex(color: ThemeColor, fallback = "#cccccc"): string {
1400
+ return this.#fgHex[color] ?? fallback;
1401
+ }
1402
+
1389
1403
  /** Convert a ThemeColor's fg ANSI code to a bg ANSI code (swap \x1b[38; → \x1b[48;). */
1390
1404
  fgColorAsBg(color: ThemeColor): string {
1391
1405
  return this.getFgAnsi(color).replace("\x1b[38;", "\x1b[48;");
@@ -2495,7 +2509,7 @@ export function getMarkdownTheme(): MarkdownTheme {
2495
2509
  underline: (text: string) => theme.underline(text),
2496
2510
  strikethrough: (text: string) => chalk.strikethrough(text),
2497
2511
  symbols: getSymbolTheme(),
2498
- getMermaidAscii,
2512
+ getMermaidAscii: (hash: bigint | number) => getMermaidAscii(hash, mermaidThemeSignature(theme)),
2499
2513
  highlightCode: (code: string, lang?: string): string[] => {
2500
2514
  const validLang = lang && nativeSupportsLanguage(lang) ? lang : undefined;
2501
2515
  try {
@@ -1,7 +1,9 @@
1
- /** TUI renderer for the render_mermaid tool — bordered ASCII diagram output. */
1
+ /** TUI renderer for the render_mermaid tool — bordered, themed, colorized diagram output. */
2
2
  import type { Component } from "@f5xc-salesdemos/pi-tui";
3
3
  import { Text } from "@f5xc-salesdemos/pi-tui";
4
+ import { detectDiagramType, type MermaidDiagramType } from "@f5xc-salesdemos/pi-utils";
4
5
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
6
+ import { renderMermaidThemed } from "../modes/theme/mermaid-cache";
5
7
  import type { Theme } from "../modes/theme/theme";
6
8
  import { CachedOutputBlock, F5_TOOL_BORDER_COLOR, renderStatusLine } from "../tui";
7
9
  import type { RenderMermaidToolDetails } from "./render-mermaid";
@@ -10,6 +12,26 @@ import { addSection, formatErrorMessage, replaceTabs } from "./render-utils";
10
12
  const TOOL_TITLE = "Mermaid";
11
13
  const MAX_DIAGRAM_LINES = 40;
12
14
 
15
+ /** Human-friendly caption for the diagram-type badge in the block header. */
16
+ function diagramTypeLabel(type: MermaidDiagramType): string {
17
+ switch (type) {
18
+ case "flowchart":
19
+ return "flowchart";
20
+ case "sequence":
21
+ return "sequence diagram";
22
+ case "class":
23
+ return "class diagram";
24
+ case "er":
25
+ return "ER diagram";
26
+ case "state":
27
+ return "state diagram";
28
+ case "xychart":
29
+ return "xychart";
30
+ default:
31
+ return "diagram";
32
+ }
33
+ }
34
+
13
35
  type MermaidRenderArgs = {
14
36
  mermaid?: string;
15
37
  };
@@ -31,7 +53,7 @@ export const mermaidRenderer = {
31
53
  },
32
54
  options: RenderResultOptions,
33
55
  uiTheme: Theme,
34
- _args?: MermaidRenderArgs,
56
+ args?: MermaidRenderArgs,
35
57
  ): Component {
36
58
  const isError = result.isError === true;
37
59
 
@@ -44,11 +66,18 @@ export const mermaidRenderer = {
44
66
  const meta: string[] = [];
45
67
  const rawText = result.content?.find(c => c.type === "text")?.text ?? "";
46
68
 
47
- // Strip artifact reference from display
69
+ // Strip artifact reference from the plain result text (fallback path).
48
70
  const artifactIdx = rawText.indexOf("\n\nSaved artifact:");
49
- const diagramText = artifactIdx >= 0 ? rawText.slice(0, artifactIdx) : rawText;
71
+ const plainText = artifactIdx >= 0 ? rawText.slice(0, artifactIdx) : rawText;
72
+
73
+ // Prefer re-rendering from the original source: this yields the themed,
74
+ // per-role-colored, node-tinted diagram. Fall back to the plain result text.
75
+ const source = args?.mermaid?.trim();
76
+ const diagramText = (source ? renderMermaidThemed(source, uiTheme) : null) ?? plainText;
77
+ const caption = source ? diagramTypeLabel(detectDiagramType(source)) : "diagram";
50
78
 
51
- const diagramLines = diagramText.split("\n").map(line => replaceTabs(uiTheme.fg("toolOutput", line)));
79
+ // Keep the diagram's own colors — do NOT recolor each line a flat tool color.
80
+ const diagramLines = diagramText.split("\n").map(line => replaceTabs(line));
52
81
  addSection(sections, "Diagram", diagramLines, uiTheme, MAX_DIAGRAM_LINES);
53
82
 
54
83
  if (result.details?.artifactId) {
@@ -59,7 +88,7 @@ export const mermaidRenderer = {
59
88
  {
60
89
  title: TOOL_TITLE,
61
90
  titleColor: "dim",
62
- description: uiTheme.fg("muted", "diagram"),
91
+ description: uiTheme.fg("muted", caption),
63
92
  meta: meta.length > 0 ? meta : undefined,
64
93
  },
65
94
  uiTheme,
@@ -69,7 +98,12 @@ export const mermaidRenderer = {
69
98
  return {
70
99
  render(width: number): string[] {
71
100
  const state = options.isPartial ? "pending" : "success";
72
- return outputBlock.render({ header, state, sections, width, borderColor: F5_TOOL_BORDER_COLOR }, uiTheme);
101
+ return outputBlock.render(
102
+ // Clip, don't wrap: a diagram is a fixed grid — word-wrapping reflows it
103
+ // and breaks alignment. Wide diagrams are truncated to the block width.
104
+ { header, state, sections, width, borderColor: F5_TOOL_BORDER_COLOR, wrapContent: false },
105
+ uiTheme,
106
+ );
73
107
  },
74
108
  invalidate() {
75
109
  outputBlock.invalidate();
@@ -4,7 +4,12 @@ import type {
4
4
  AgentToolResult,
5
5
  AgentToolUpdateCallback,
6
6
  } from "@f5xc-salesdemos/pi-agent-core";
7
- import { type MermaidAsciiRenderOptions, prompt, renderMermaidAscii } from "@f5xc-salesdemos/pi-utils";
7
+ import {
8
+ type MermaidAsciiRenderOptions,
9
+ mermaidSourceExceedsLimit,
10
+ prompt,
11
+ renderMermaidAsciiSafe,
12
+ } from "@f5xc-salesdemos/pi-utils";
8
13
  import { type Static, Type } from "@sinclair/typebox";
9
14
  import renderMermaidDescription from "../prompts/tools/render-mermaid.md" with { type: "text" };
10
15
  import type { ToolSession } from "./index";
@@ -55,7 +60,24 @@ export class RenderMermaidTool implements AgentTool<typeof renderMermaidSchema,
55
60
  _onUpdate?: AgentToolUpdateCallback<RenderMermaidToolDetails>,
56
61
  _context?: AgentToolContext,
57
62
  ): Promise<AgentToolResult<RenderMermaidToolDetails>> {
58
- const ascii = renderMermaidAscii(params.mermaid, sanitizeRenderConfig(params.config));
63
+ // Reject oversized graphs up front: beautiful-mermaid's pathfinder can hang
64
+ // for tens of seconds and then throw `RangeError: Out of memory` on them.
65
+ // Throwing here (fast) surfaces a clean tool error the agent can recover from.
66
+ if (mermaidSourceExceedsLimit(params.mermaid)) {
67
+ throw new Error(
68
+ "Mermaid diagram is too large to render safely. Reduce the number of nodes/edges and try again.",
69
+ );
70
+ }
71
+
72
+ // Safe variant: a parse failure or memory blow-up becomes null rather than a
73
+ // raw RangeError, so we can throw a clear, actionable message instead.
74
+ const ascii = renderMermaidAsciiSafe(params.mermaid, sanitizeRenderConfig(params.config));
75
+ if (ascii == null || ascii.trim() === "") {
76
+ throw new Error(
77
+ "Failed to render the Mermaid diagram — the syntax may be unsupported or too complex. Use newline-separated statements (no ';') and try a simpler diagram.",
78
+ );
79
+ }
80
+
59
81
  const { path: artifactPath, id: artifactId } =
60
82
  (await this.session.allocateOutputArtifact?.("render_mermaid")) ?? {};
61
83
  if (artifactPath) {
@@ -23,6 +23,12 @@ export interface OutputBlockOptions {
23
23
  applyBg?: boolean;
24
24
  /** Override the state-derived border color. Always takes precedence, including on error. Use for branded core tools. */
25
25
  borderColor?: ThemeColor;
26
+ /**
27
+ * Word-wrap content lines to fit the inner width (default). Set false to CLIP
28
+ * (truncate) instead — required for pre-formatted grid content like mermaid
29
+ * diagrams, where wrapping reflows characters and destroys alignment.
30
+ */
31
+ wrapContent?: boolean;
26
32
  }
27
33
 
28
34
  export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): string[] {
@@ -34,6 +40,7 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
34
40
  width,
35
41
  applyBg = true,
36
42
  borderColor: borderColorOverride,
43
+ wrapContent = true,
37
44
  } = options;
38
45
  const h = theme.boxSharp.horizontal;
39
46
  const v = theme.boxSharp.vertical;
@@ -100,7 +107,9 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
100
107
  lines.push(line);
101
108
  continue;
102
109
  }
103
- const wrappedLines = wrapTextWithAnsi(line.trimEnd(), contentWidth);
110
+ const wrappedLines = wrapContent
111
+ ? wrapTextWithAnsi(line.trimEnd(), contentWidth)
112
+ : [truncateToWidth(line.trimEnd(), contentWidth)];
104
113
  for (const wrappedLine of wrappedLines) {
105
114
  const innerPadding = padding(Math.max(0, contentWidth - visibleWidth(wrappedLine)));
106
115
  const fullLine = `${contentPrefix}${wrappedLine}${innerPadding}${contentSuffix}`;
@@ -150,6 +159,7 @@ export class CachedOutputBlock {
150
159
  h.optional(options.state);
151
160
  h.bool(options.applyBg ?? true);
152
161
  h.optional(options.borderColor);
162
+ h.bool(options.wrapContent ?? true);
153
163
  if (options.sections) {
154
164
  for (const s of options.sections) {
155
165
  h.optional(s.label);