@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 +9 -8
- package/scripts/mermaid-gallery.ts +132 -0
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/modes/components/assistant-message.ts +9 -5
- package/src/modes/theme/mermaid-cache.ts +77 -32
- package/src/modes/theme/mermaid-palette.ts +43 -0
- package/src/modes/theme/theme.ts +16 -2
- package/src/tools/mermaid-renderer.ts +41 -7
- package/src/tools/render-mermaid.ts +24 -2
- package/src/tui/output-block.ts +11 -1
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5xc-salesdemos/xcsh",
|
|
4
|
-
"version": "19.
|
|
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.
|
|
55
|
-
"@f5xc-salesdemos/pi-agent-core": "19.
|
|
56
|
-
"@f5xc-salesdemos/pi-ai": "19.
|
|
57
|
-
"@f5xc-salesdemos/pi-natives": "19.
|
|
58
|
-
"@f5xc-salesdemos/pi-resource-management": "19.
|
|
59
|
-
"@f5xc-salesdemos/pi-tui": "19.
|
|
60
|
-
"@f5xc-salesdemos/pi-utils": "19.
|
|
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.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "19.42.1",
|
|
21
|
+
"commit": "164a80d621b7e0c5f5e89bf9cd7caac2911eb764",
|
|
22
|
+
"shortCommit": "164a80d",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v19.
|
|
25
|
-
"commitDate": "2026-06-
|
|
26
|
-
"buildDate": "2026-06-
|
|
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/
|
|
32
|
-
"releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v19.
|
|
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
|
-
|
|
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(
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
24
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|
package/src/modes/theme/theme.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
69
|
+
// Strip artifact reference from the plain result text (fallback path).
|
|
48
70
|
const artifactIdx = rawText.indexOf("\n\nSaved artifact:");
|
|
49
|
-
const
|
|
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
|
-
|
|
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",
|
|
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(
|
|
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 {
|
|
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
|
-
|
|
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) {
|
package/src/tui/output-block.ts
CHANGED
|
@@ -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 =
|
|
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);
|