@f5xc-salesdemos/xcsh 19.42.2 → 19.43.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 +8 -8
- package/src/cli/chrome-cli.ts +2 -8
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/main.ts +1 -1
- package/src/modes/theme/theme.ts +12 -2
- package/src/services/f5xc-context-command.ts +127 -2
- package/src/services/f5xc-context.ts +50 -4
- package/src/tools/mermaid-renderer.ts +50 -42
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.43.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",
|
|
@@ -52,13 +52,13 @@
|
|
|
52
52
|
"dependencies": {
|
|
53
53
|
"@agentclientprotocol/sdk": "0.16.1",
|
|
54
54
|
"@mozilla/readability": "^0.6",
|
|
55
|
-
"@f5xc-salesdemos/xcsh-stats": "19.
|
|
56
|
-
"@f5xc-salesdemos/pi-agent-core": "19.
|
|
57
|
-
"@f5xc-salesdemos/pi-ai": "19.
|
|
58
|
-
"@f5xc-salesdemos/pi-natives": "19.
|
|
59
|
-
"@f5xc-salesdemos/pi-resource-management": "19.
|
|
60
|
-
"@f5xc-salesdemos/pi-tui": "19.
|
|
61
|
-
"@f5xc-salesdemos/pi-utils": "19.
|
|
55
|
+
"@f5xc-salesdemos/xcsh-stats": "19.43.1",
|
|
56
|
+
"@f5xc-salesdemos/pi-agent-core": "19.43.1",
|
|
57
|
+
"@f5xc-salesdemos/pi-ai": "19.43.1",
|
|
58
|
+
"@f5xc-salesdemos/pi-natives": "19.43.1",
|
|
59
|
+
"@f5xc-salesdemos/pi-resource-management": "19.43.1",
|
|
60
|
+
"@f5xc-salesdemos/pi-tui": "19.43.1",
|
|
61
|
+
"@f5xc-salesdemos/pi-utils": "19.43.1",
|
|
62
62
|
"@sinclair/typebox": "^0.34",
|
|
63
63
|
"@xterm/headless": "^6.0",
|
|
64
64
|
"ajv": "^8.20",
|
package/src/cli/chrome-cli.ts
CHANGED
|
@@ -15,14 +15,8 @@ type Settings = { get(key: string): unknown };
|
|
|
15
15
|
|
|
16
16
|
export type ChromeAction = "status" | "relaunch" | "setup";
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
export const EXTENSION_ID = "khlalklompggpfnmeclpligmcbknkemg";
|
|
21
|
-
// Chrome Web Store build — the store assigns this ID (no `key` field).
|
|
22
|
-
export const EXTENSION_ID_CWS = "klajkjdoehjidngligegnpknogmjjhkc";
|
|
23
|
-
// Both are allowed in the native-host manifest so the bridge works regardless of
|
|
24
|
-
// how the user installed the extension.
|
|
25
|
-
export const EXTENSION_IDS = [EXTENSION_ID, EXTENSION_ID_CWS];
|
|
18
|
+
export const EXTENSION_ID = "klajkjdoehjidngligegnpknogmjjhkc";
|
|
19
|
+
export const EXTENSION_IDS = [EXTENSION_ID];
|
|
26
20
|
|
|
27
21
|
const NATIVE_HOST_NAME = "com.f5xc.xcsh.chrome_host";
|
|
28
22
|
|
|
@@ -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.43.1",
|
|
21
|
+
"commit": "5b36c788a2d9ad0fb4a7da83458621d0e995a05a",
|
|
22
|
+
"shortCommit": "5b36c78",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v19.
|
|
25
|
-
"commitDate": "2026-06-
|
|
26
|
-
"buildDate": "2026-06-
|
|
24
|
+
"tag": "v19.43.1",
|
|
25
|
+
"commitDate": "2026-06-24T15:09:09Z",
|
|
26
|
+
"buildDate": "2026-06-24T15:34:16.139Z",
|
|
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/5b36c788a2d9ad0fb4a7da83458621d0e995a05a",
|
|
32
|
+
"releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v19.43.1"
|
|
33
33
|
};
|
package/src/main.ts
CHANGED
|
@@ -621,7 +621,7 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
|
|
|
621
621
|
const { ContextService } = await import("./services/f5xc-context");
|
|
622
622
|
const { getF5XCConfigDir } = await import("@f5xc-salesdemos/pi-utils");
|
|
623
623
|
const contextService = ContextService.init(getF5XCConfigDir());
|
|
624
|
-
await contextService.loadActive();
|
|
624
|
+
await contextService.loadActive(cwd);
|
|
625
625
|
} catch {
|
|
626
626
|
// F5 XC auth is optional — silently continue if anything fails
|
|
627
627
|
}
|
package/src/modes/theme/theme.ts
CHANGED
|
@@ -10,13 +10,15 @@ import {
|
|
|
10
10
|
supportsLanguage as nativeSupportsLanguage,
|
|
11
11
|
} from "@f5xc-salesdemos/pi-natives";
|
|
12
12
|
import type { EditorTheme, MarkdownTheme, SelectListTheme, SymbolTheme } from "@f5xc-salesdemos/pi-tui";
|
|
13
|
-
import { adjustHsv, getCustomThemesDir, isEnoent, logger } from "@f5xc-salesdemos/pi-utils";
|
|
13
|
+
import { adjustHsv, detectDiagramType, getCustomThemesDir, isEnoent, logger } from "@f5xc-salesdemos/pi-utils";
|
|
14
14
|
import { type Static, Type } from "@sinclair/typebox";
|
|
15
15
|
import { TypeCompiler } from "@sinclair/typebox/compiler";
|
|
16
16
|
import chalk from "chalk";
|
|
17
|
+
import { buildMermaidBlockOptions, diagramTypeLabel } from "../../tools/mermaid-renderer";
|
|
18
|
+
import { renderOutputBlock } from "../../tui";
|
|
17
19
|
// Embed theme JSON files at build time
|
|
18
20
|
import { defaultThemes } from "./defaults";
|
|
19
|
-
import { getMermaidAscii, mermaidThemeSignature } from "./mermaid-cache";
|
|
21
|
+
import { getMermaidAscii, mermaidThemeSignature, renderMermaidThemed } from "./mermaid-cache";
|
|
20
22
|
|
|
21
23
|
export { getLanguageFromPath } from "../../utils/lang-from-path";
|
|
22
24
|
|
|
@@ -2510,6 +2512,14 @@ export function getMarkdownTheme(): MarkdownTheme {
|
|
|
2510
2512
|
strikethrough: (text: string) => chalk.strikethrough(text),
|
|
2511
2513
|
symbols: getSymbolTheme(),
|
|
2512
2514
|
getMermaidAscii: (hash: bigint | number) => getMermaidAscii(hash, mermaidThemeSignature(theme)),
|
|
2515
|
+
renderMermaidBlock: (source: string, width: number): string[] | null => {
|
|
2516
|
+
const diagram = renderMermaidThemed(source, theme);
|
|
2517
|
+
if (!diagram) return null;
|
|
2518
|
+
return renderOutputBlock(
|
|
2519
|
+
buildMermaidBlockOptions(diagram, { width, theme, typeLabel: diagramTypeLabel(detectDiagramType(source)) }),
|
|
2520
|
+
theme,
|
|
2521
|
+
);
|
|
2522
|
+
},
|
|
2513
2523
|
highlightCode: (code: string, lang?: string): string[] => {
|
|
2514
2524
|
const validLang = lang && nativeSupportsLanguage(lang) ? lang : undefined;
|
|
2515
2525
|
try {
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
|
-
import
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
getLocalF5XCActiveContextPath,
|
|
5
|
+
getLocalF5XCContextPath,
|
|
6
|
+
getLocalF5XCContextsDir,
|
|
7
|
+
getProjectDir,
|
|
8
|
+
isSafeContextName,
|
|
9
|
+
t,
|
|
10
|
+
} from "@f5xc-salesdemos/pi-utils";
|
|
3
11
|
import { SECRET_ENV_PATTERNS } from "../secrets/index";
|
|
4
12
|
import { expandTilde } from "../tools/path-utils";
|
|
5
13
|
import { ContextError, ContextService, CURRENT_SCHEMA_VERSION } from "./f5xc-context";
|
|
@@ -39,7 +47,7 @@ export async function handleContextCommand(
|
|
|
39
47
|
): Promise<void> {
|
|
40
48
|
const [sub, ...rest] = command.args.trim().split(/\s+/);
|
|
41
49
|
const arg = rest.join(" ");
|
|
42
|
-
const service = await ContextService.getOrInit();
|
|
50
|
+
const service = await ContextService.getOrInit(undefined, getProjectDir());
|
|
43
51
|
|
|
44
52
|
ctx.editor.setText("");
|
|
45
53
|
|
|
@@ -75,6 +83,10 @@ export async function handleContextCommand(
|
|
|
75
83
|
}
|
|
76
84
|
case "namespace":
|
|
77
85
|
return handleNamespace(ctx, service, arg);
|
|
86
|
+
case "link":
|
|
87
|
+
return handleLink(ctx, service, arg);
|
|
88
|
+
case "unlink":
|
|
89
|
+
return handleUnlink(ctx, service);
|
|
78
90
|
case "env":
|
|
79
91
|
return handleEnvSubcommand(ctx, service, rest);
|
|
80
92
|
case "set":
|
|
@@ -102,6 +114,17 @@ function sanitize(value: string): string {
|
|
|
102
114
|
return value.replace(/[\x00-\x1f\x7f]/g, "");
|
|
103
115
|
}
|
|
104
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Atomic file write: write to a .tmp file then rename.
|
|
119
|
+
* Ensures 0o600 permissions on the resulting file.
|
|
120
|
+
*/
|
|
121
|
+
function atomicWriteLocal(filePath: string, content: string): void {
|
|
122
|
+
const tmpPath = `${filePath}.tmp`;
|
|
123
|
+
fs.writeFileSync(tmpPath, content, { mode: 0o600 });
|
|
124
|
+
fs.renameSync(tmpPath, filePath);
|
|
125
|
+
fs.chmodSync(filePath, 0o600);
|
|
126
|
+
}
|
|
127
|
+
|
|
105
128
|
/**
|
|
106
129
|
* Split a positional+flag argument list into two groups.
|
|
107
130
|
*
|
|
@@ -129,6 +152,58 @@ function splitArgs(args: string[], knownFlags: Set<string>): { positionals: stri
|
|
|
129
152
|
}
|
|
130
153
|
|
|
131
154
|
async function handleList(ctx: CommandContext, service: ContextService): Promise<void> {
|
|
155
|
+
const localContextsDir = getLocalF5XCContextsDir(getProjectDir());
|
|
156
|
+
const hasLocalDir = fs.existsSync(localContextsDir);
|
|
157
|
+
|
|
158
|
+
if (hasLocalDir) {
|
|
159
|
+
// Build local context rows by reading .xcsh/contexts/*.json directly
|
|
160
|
+
const localRows: TableRow[] = [];
|
|
161
|
+
try {
|
|
162
|
+
const files = fs.readdirSync(localContextsDir).filter(f => f.endsWith(".json"));
|
|
163
|
+
for (const file of files.sort()) {
|
|
164
|
+
const name = file.slice(0, -5); // strip .json
|
|
165
|
+
try {
|
|
166
|
+
const raw = fs.readFileSync(path.join(localContextsDir, file), "utf-8");
|
|
167
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
168
|
+
const displayUrl =
|
|
169
|
+
typeof parsed.context === "string"
|
|
170
|
+
? `→ ${sanitize(parsed.context)} (pointer)`
|
|
171
|
+
: sanitize(typeof parsed.apiUrl === "string" ? parsed.apiUrl : "");
|
|
172
|
+
localRows.push({ key: ` ${sanitize(name)}`, value: displayUrl });
|
|
173
|
+
} catch {
|
|
174
|
+
localRows.push({ key: ` ${sanitize(name)}`, value: "(unreadable)" });
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
} catch {
|
|
178
|
+
// skip unreadable dir
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Build global context rows
|
|
182
|
+
const globalContexts = await service.listContexts();
|
|
183
|
+
const status = service.getStatus();
|
|
184
|
+
const globalRows: TableRow[] = globalContexts.map(p => {
|
|
185
|
+
const isActive = p.name === status.activeContextName;
|
|
186
|
+
const marker = isActive ? `${formatStatusIcon("connected")} ` : " ";
|
|
187
|
+
const versionSuffix =
|
|
188
|
+
p.version !== undefined && p.version > CURRENT_SCHEMA_VERSION ? ` (v${p.version} — upgrade required)` : "";
|
|
189
|
+
return { key: `${marker}${sanitize(p.name)}`, value: `${sanitize(p.apiUrl)}${versionSuffix}` };
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Render combined table with group dividers
|
|
193
|
+
const rows: TableRow[] = [];
|
|
194
|
+
const dividers: Array<{ before: number; label: string }> = [];
|
|
195
|
+
|
|
196
|
+
dividers.push({ before: 0, label: "Local contexts (.xcsh/contexts/)" });
|
|
197
|
+
rows.push(...(localRows.length > 0 ? localRows : [{ key: " (none)", value: "" }]));
|
|
198
|
+
|
|
199
|
+
dividers.push({ before: rows.length, label: "Global contexts (~/.config/f5xc/contexts/)" });
|
|
200
|
+
rows.push(...(globalRows.length > 0 ? globalRows : [{ key: " (none)", value: "" }]));
|
|
201
|
+
|
|
202
|
+
ctx.showStatus(renderF5XCTable("contexts", rows, { dividers }), { dim: false });
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// No local dir — use original flat list behavior
|
|
132
207
|
const contexts = await service.listContexts();
|
|
133
208
|
if (contexts.length === 0) {
|
|
134
209
|
const status = service.getStatus();
|
|
@@ -157,6 +232,56 @@ async function handleList(ctx: CommandContext, service: ContextService): Promise
|
|
|
157
232
|
ctx.showStatus(renderF5XCTable("contexts", rows), { dim: false });
|
|
158
233
|
}
|
|
159
234
|
|
|
235
|
+
async function handleLink(ctx: CommandContext, service: ContextService, name: string): Promise<void> {
|
|
236
|
+
if (!name) {
|
|
237
|
+
ctx.showError("Usage: /context link <global-context-name>");
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
if (!isSafeContextName(name)) {
|
|
241
|
+
ctx.showError(
|
|
242
|
+
"Invalid context name. Names must be 1-64 characters using only letters, digits, hyphens, or underscores.",
|
|
243
|
+
);
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
const contexts = await service.listContexts();
|
|
247
|
+
const exists = contexts.some(c => c.name === name);
|
|
248
|
+
if (!exists) {
|
|
249
|
+
ctx.showError(`Global context '${name}' not found. Run /context list to see available contexts.`);
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const localContextsDir = getLocalF5XCContextsDir(getProjectDir());
|
|
253
|
+
if (!fs.existsSync(localContextsDir)) {
|
|
254
|
+
fs.mkdirSync(localContextsDir, { recursive: true, mode: 0o700 });
|
|
255
|
+
}
|
|
256
|
+
const pointerPath = getLocalF5XCContextPath(name, getProjectDir());
|
|
257
|
+
atomicWriteLocal(pointerPath, JSON.stringify({ context: name }, null, 2));
|
|
258
|
+
const activeContextPath = getLocalF5XCActiveContextPath(getProjectDir());
|
|
259
|
+
atomicWriteLocal(activeContextPath, name);
|
|
260
|
+
ctx.showStatus(renderContextMessage(name, `Linked local context '${name}' → global context '${name}'.`), {
|
|
261
|
+
dim: false,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function handleUnlink(ctx: CommandContext, _service: ContextService): Promise<void> {
|
|
266
|
+
const activeContextPath = getLocalF5XCActiveContextPath(getProjectDir());
|
|
267
|
+
if (!fs.existsSync(activeContextPath)) {
|
|
268
|
+
ctx.showError("No local active context found. Run /context link <name> to create one.");
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const name = fs.readFileSync(activeContextPath, "utf-8").trim();
|
|
272
|
+
if (!isSafeContextName(name)) {
|
|
273
|
+
ctx.showError("Corrupt active_context file — invalid context name. Removing the file.");
|
|
274
|
+
fs.unlinkSync(activeContextPath);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
const pointerPath = getLocalF5XCContextPath(name, getProjectDir());
|
|
278
|
+
if (fs.existsSync(pointerPath)) {
|
|
279
|
+
fs.unlinkSync(pointerPath);
|
|
280
|
+
}
|
|
281
|
+
fs.unlinkSync(activeContextPath);
|
|
282
|
+
ctx.showStatus(renderContextMessage(name, `Unlinked local context '${name}'.`), { dim: false });
|
|
283
|
+
}
|
|
284
|
+
|
|
160
285
|
async function handleActivate(ctx: CommandContext, service: ContextService, name: string): Promise<void> {
|
|
161
286
|
if (!name) {
|
|
162
287
|
ctx.showError(t("context.activate.usage"));
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
-
import { getF5XCConfigDir, logger } from "@f5xc-salesdemos/pi-utils";
|
|
3
|
+
import { ContextResolver, getF5XCConfigDir, logger } from "@f5xc-salesdemos/pi-utils";
|
|
4
4
|
import { Settings } from "../config/settings";
|
|
5
5
|
import { SECRET_ENV_PATTERNS } from "../secrets/index";
|
|
6
6
|
import { F5XCApiClient } from "./f5xc-api-client";
|
|
@@ -303,12 +303,14 @@ export class ContextService {
|
|
|
303
303
|
*
|
|
304
304
|
* @param configDir — seed directory when bootstrapping; ignored when an
|
|
305
305
|
* instance already exists.
|
|
306
|
+
* @param cwd — working directory for local context resolution; passed to
|
|
307
|
+
* loadActive(). Ignored when an instance already exists.
|
|
306
308
|
*/
|
|
307
|
-
static async getOrInit(configDir?: string): Promise<ContextService> {
|
|
309
|
+
static async getOrInit(configDir?: string, cwd?: string): Promise<ContextService> {
|
|
308
310
|
if (ContextService.#instance) return ContextService.#instance;
|
|
309
311
|
const dir = configDir ?? getF5XCConfigDir();
|
|
310
312
|
const service = ContextService.init(dir);
|
|
311
|
-
await service.loadActive();
|
|
313
|
+
await service.loadActive(cwd);
|
|
312
314
|
return service;
|
|
313
315
|
}
|
|
314
316
|
|
|
@@ -375,7 +377,7 @@ export class ContextService {
|
|
|
375
377
|
return this.#activeContext?.defaultNamespace ?? null;
|
|
376
378
|
}
|
|
377
379
|
|
|
378
|
-
async loadActive(): Promise<F5XCContext | null> {
|
|
380
|
+
async loadActive(cwd?: string): Promise<F5XCContext | null> {
|
|
379
381
|
// FR-102: F5XC_API_URL is the signal to skip context loading entirely.
|
|
380
382
|
// Subprocesses inherit process.env, so they already see the env vars directly.
|
|
381
383
|
if (process.env[F5XC_API_URL]) {
|
|
@@ -383,6 +385,50 @@ export class ContextService {
|
|
|
383
385
|
return null;
|
|
384
386
|
}
|
|
385
387
|
|
|
388
|
+
// FR-201: Check for project-local context in .xcsh/contexts/
|
|
389
|
+
if (cwd) {
|
|
390
|
+
const resolver = new ContextResolver();
|
|
391
|
+
const localResult = await resolver.resolve(cwd);
|
|
392
|
+
if (localResult && localResult.source === "local") {
|
|
393
|
+
const localContext = localResult.context as F5XCContext;
|
|
394
|
+
// Gate: incompatible schema version
|
|
395
|
+
let versionOk = true;
|
|
396
|
+
try {
|
|
397
|
+
this.#assertCompatibleVersion(localContext);
|
|
398
|
+
} catch (err) {
|
|
399
|
+
versionOk = false;
|
|
400
|
+
logger.warn("F5XC: local context uses incompatible schema version, skipping", {
|
|
401
|
+
sourcePath: localResult.sourcePath,
|
|
402
|
+
error: String(err),
|
|
403
|
+
});
|
|
404
|
+
// Fall through to global resolution below
|
|
405
|
+
}
|
|
406
|
+
if (versionOk) {
|
|
407
|
+
this.#activeContext = localContext;
|
|
408
|
+
this.#applyToSettings(localContext);
|
|
409
|
+
this.#credentialSource = hasEnvOverride() ? "mixed" : "context";
|
|
410
|
+
this.#refreshApiClient(localContext);
|
|
411
|
+
logger.debug("F5XC: using project context", {
|
|
412
|
+
name: localContext.name,
|
|
413
|
+
source: localResult.sourcePath,
|
|
414
|
+
});
|
|
415
|
+
// Run git-tracking safety check asynchronously
|
|
416
|
+
resolver
|
|
417
|
+
.checkGitTracking(localResult.sourcePath)
|
|
418
|
+
.then(tracked => {
|
|
419
|
+
if (tracked) {
|
|
420
|
+
logger.warn(
|
|
421
|
+
`WARNING: ${localResult.sourcePath} is tracked by git! ` +
|
|
422
|
+
`This file may contain credentials. Run: git rm --cached ${localResult.sourcePath}`,
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
})
|
|
426
|
+
.catch(() => {});
|
|
427
|
+
return localContext;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
386
432
|
// Check if config dir exists
|
|
387
433
|
if (!fs.existsSync(this.#configDir)) {
|
|
388
434
|
return null;
|
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
/** TUI renderer for the render_mermaid tool —
|
|
1
|
+
/** TUI renderer for the render_mermaid tool — single F5-framed, themed, colorized diagram. */
|
|
2
2
|
import type { Component } from "@f5xc-salesdemos/pi-tui";
|
|
3
|
-
import { Text } from "@f5xc-salesdemos/pi-tui";
|
|
3
|
+
import { Text, visibleWidth } from "@f5xc-salesdemos/pi-tui";
|
|
4
4
|
import { detectDiagramType, type MermaidDiagramType } from "@f5xc-salesdemos/pi-utils";
|
|
5
5
|
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
|
6
6
|
import { renderMermaidThemed } from "../modes/theme/mermaid-cache";
|
|
7
7
|
import type { Theme } from "../modes/theme/theme";
|
|
8
|
-
import { CachedOutputBlock, F5_TOOL_BORDER_COLOR, renderStatusLine } from "../tui";
|
|
8
|
+
import { CachedOutputBlock, F5_TOOL_BORDER_COLOR, type OutputBlockOptions, renderStatusLine } from "../tui";
|
|
9
9
|
import type { RenderMermaidToolDetails } from "./render-mermaid";
|
|
10
|
-
import {
|
|
10
|
+
import { formatErrorMessage, replaceTabs } from "./render-utils";
|
|
11
11
|
|
|
12
12
|
const TOOL_TITLE = "Mermaid";
|
|
13
13
|
|
|
14
|
-
/** Human-friendly caption for the diagram
|
|
15
|
-
function diagramTypeLabel(type: MermaidDiagramType): string {
|
|
14
|
+
/** Human-friendly caption for the diagram type, shown in the F5 frame header. */
|
|
15
|
+
export function diagramTypeLabel(type: MermaidDiagramType): string {
|
|
16
16
|
switch (type) {
|
|
17
17
|
case "flowchart":
|
|
18
18
|
return "flowchart";
|
|
@@ -31,6 +31,41 @@ function diagramTypeLabel(type: MermaidDiagramType): string {
|
|
|
31
31
|
}
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* Build the F5 output-block options that frame a themed/colored mermaid diagram.
|
|
36
|
+
* Shared by the render_mermaid tool result AND inline ```mermaid markdown blocks, so
|
|
37
|
+
* every mermaid render gets the SAME single F5 frame — and the diagram is clipped
|
|
38
|
+
* (wrapContent: false), never wrapped, so a wide diagram can't reflow or overflow.
|
|
39
|
+
*/
|
|
40
|
+
export function buildMermaidBlockOptions(
|
|
41
|
+
diagramText: string,
|
|
42
|
+
opts: { width: number; theme: Theme; typeLabel: string; artifactId?: string },
|
|
43
|
+
): OutputBlockOptions {
|
|
44
|
+
// Unlabeled section: the header already reads "Mermaid · <type>", so a "Diagram"
|
|
45
|
+
// bar would be redundant. The diagram keeps its own colors; we only normalize tabs.
|
|
46
|
+
const lines = diagramText.split("\n").map(line => replaceTabs(line));
|
|
47
|
+
const meta = opts.artifactId ? [opts.theme.fg("dim", `artifact:${opts.artifactId.slice(0, 8)}`)] : undefined;
|
|
48
|
+
const header = renderStatusLine(
|
|
49
|
+
{ title: TOOL_TITLE, titleColor: "dim", description: opts.theme.fg("muted", opts.typeLabel), meta },
|
|
50
|
+
opts.theme,
|
|
51
|
+
);
|
|
52
|
+
// Size the frame SNUGLY to the diagram (+ one space of padding each side), capped at
|
|
53
|
+
// the available width and kept wide enough for the header caption — so the diagram
|
|
54
|
+
// feels enclosed in a small padded frame instead of spanning the whole terminal
|
|
55
|
+
// (tiny diagram) or clipping against the right border (wide diagram).
|
|
56
|
+
const maxDiagramWidth = lines.reduce((max, line) => Math.max(max, visibleWidth(line)), 0);
|
|
57
|
+
const desired = Math.max(maxDiagramWidth + 4, visibleWidth(header) + 8);
|
|
58
|
+
const width = Math.min(opts.width, Math.max(1, desired));
|
|
59
|
+
return {
|
|
60
|
+
header,
|
|
61
|
+
state: "success",
|
|
62
|
+
sections: [{ lines }],
|
|
63
|
+
width,
|
|
64
|
+
borderColor: F5_TOOL_BORDER_COLOR,
|
|
65
|
+
wrapContent: false,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
34
69
|
type MermaidRenderArgs = {
|
|
35
70
|
mermaid?: string;
|
|
36
71
|
};
|
|
@@ -50,63 +85,36 @@ export const mermaidRenderer = {
|
|
|
50
85
|
details?: RenderMermaidToolDetails;
|
|
51
86
|
isError?: boolean;
|
|
52
87
|
},
|
|
53
|
-
|
|
88
|
+
_options: RenderResultOptions,
|
|
54
89
|
uiTheme: Theme,
|
|
55
90
|
args?: MermaidRenderArgs,
|
|
56
91
|
): Component {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
if (isError) {
|
|
92
|
+
if (result.isError === true) {
|
|
60
93
|
const errorText = result.content?.find(c => c.type === "text")?.text;
|
|
61
94
|
return new Text(formatErrorMessage(errorText, uiTheme), 0, 0);
|
|
62
95
|
}
|
|
63
96
|
|
|
64
|
-
const sections: Array<{ label?: string; lines: string[] }> = [];
|
|
65
|
-
const meta: string[] = [];
|
|
66
97
|
const rawText = result.content?.find(c => c.type === "text")?.text ?? "";
|
|
67
|
-
|
|
68
98
|
// Strip artifact reference from the plain result text (fallback path).
|
|
69
99
|
const artifactIdx = rawText.indexOf("\n\nSaved artifact:");
|
|
70
100
|
const plainText = artifactIdx >= 0 ? rawText.slice(0, artifactIdx) : rawText;
|
|
71
101
|
|
|
72
|
-
// Prefer re-rendering from the original source:
|
|
73
|
-
// per-role-colored, node-tinted diagram. Fall back to the plain result text.
|
|
102
|
+
// Prefer re-rendering from the original source: themed, per-node-tinted, colored.
|
|
74
103
|
const source = args?.mermaid?.trim();
|
|
75
104
|
const diagramText = (source ? renderMermaidThemed(source, uiTheme) : null) ?? plainText;
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
// Keep the diagram's own colors — do NOT recolor each line a flat tool color.
|
|
79
|
-
// Show the FULL diagram: truncating a diagram with "… N more lines" defeats the purpose.
|
|
80
|
-
const diagramLines = diagramText.split("\n").map(line => replaceTabs(line));
|
|
81
|
-
addSection(sections, "Diagram", diagramLines, uiTheme);
|
|
82
|
-
|
|
83
|
-
if (result.details?.artifactId) {
|
|
84
|
-
meta.push(uiTheme.fg("dim", `artifact:${result.details.artifactId.slice(0, 8)}`));
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
const header = renderStatusLine(
|
|
88
|
-
{
|
|
89
|
-
title: TOOL_TITLE,
|
|
90
|
-
titleColor: "dim",
|
|
91
|
-
description: uiTheme.fg("muted", caption),
|
|
92
|
-
meta: meta.length > 0 ? meta : undefined,
|
|
93
|
-
},
|
|
94
|
-
uiTheme,
|
|
95
|
-
);
|
|
105
|
+
const typeLabel = source ? diagramTypeLabel(detectDiagramType(source)) : "diagram";
|
|
106
|
+
const artifactId = result.details?.artifactId;
|
|
96
107
|
|
|
97
|
-
const
|
|
108
|
+
const block = new CachedOutputBlock();
|
|
98
109
|
return {
|
|
99
110
|
render(width: number): string[] {
|
|
100
|
-
|
|
101
|
-
|
|
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 },
|
|
111
|
+
return block.render(
|
|
112
|
+
buildMermaidBlockOptions(diagramText, { width, theme: uiTheme, typeLabel, artifactId }),
|
|
105
113
|
uiTheme,
|
|
106
114
|
);
|
|
107
115
|
},
|
|
108
116
|
invalidate() {
|
|
109
|
-
|
|
117
|
+
block.invalidate();
|
|
110
118
|
},
|
|
111
119
|
};
|
|
112
120
|
},
|