@f5xc-salesdemos/xcsh 18.82.0 → 18.83.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 +7 -7
- package/src/config/settings-schema.ts +11 -0
- package/src/cursor.ts +0 -2
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/modes/controllers/input-controller.ts +49 -2
- package/src/tools/action-renderer.ts +198 -0
- package/src/tools/browser-renderer.ts +129 -0
- package/src/tools/gh-tools-renderer.ts +284 -0
- package/src/tools/gh.ts +20 -6
- package/src/tools/glab-renderer.ts +244 -0
- package/src/tools/glab.ts +11 -4
- package/src/tools/mermaid-renderer.ts +82 -0
- package/src/tools/renderers.ts +23 -0
- package/src/tools/xcsh-api-renderer.ts +4 -2
- package/src/tools/xcsh-api.ts +15 -2
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@f5xc-salesdemos/xcsh",
|
|
4
|
-
"version": "18.
|
|
4
|
+
"version": "18.83.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",
|
|
@@ -49,12 +49,12 @@
|
|
|
49
49
|
"dependencies": {
|
|
50
50
|
"@agentclientprotocol/sdk": "0.16.1",
|
|
51
51
|
"@mozilla/readability": "^0.6",
|
|
52
|
-
"@f5xc-salesdemos/xcsh-stats": "18.
|
|
53
|
-
"@f5xc-salesdemos/pi-agent-core": "18.
|
|
54
|
-
"@f5xc-salesdemos/pi-ai": "18.
|
|
55
|
-
"@f5xc-salesdemos/pi-natives": "18.
|
|
56
|
-
"@f5xc-salesdemos/pi-tui": "18.
|
|
57
|
-
"@f5xc-salesdemos/pi-utils": "18.
|
|
52
|
+
"@f5xc-salesdemos/xcsh-stats": "18.83.1",
|
|
53
|
+
"@f5xc-salesdemos/pi-agent-core": "18.83.1",
|
|
54
|
+
"@f5xc-salesdemos/pi-ai": "18.83.1",
|
|
55
|
+
"@f5xc-salesdemos/pi-natives": "18.83.1",
|
|
56
|
+
"@f5xc-salesdemos/pi-tui": "18.83.1",
|
|
57
|
+
"@f5xc-salesdemos/pi-utils": "18.83.1",
|
|
58
58
|
"@sinclair/typebox": "^0.34",
|
|
59
59
|
"@xterm/headless": "^6.0",
|
|
60
60
|
"ajv": "^8.18",
|
|
@@ -391,6 +391,17 @@ export const SETTINGS_SCHEMA = {
|
|
|
391
391
|
ui: { tab: "appearance", label: "Block Images", description: "Prevent images from being sent to LLM providers" },
|
|
392
392
|
},
|
|
393
393
|
|
|
394
|
+
"images.pasteDir": {
|
|
395
|
+
type: "string",
|
|
396
|
+
default: ".",
|
|
397
|
+
ui: {
|
|
398
|
+
tab: "tools",
|
|
399
|
+
label: "Clipboard Image Directory",
|
|
400
|
+
description:
|
|
401
|
+
"Directory to save clipboard-pasted images. Defaults to current working directory. Supports ~. Examples: ., ~/Screenshots, ./assets",
|
|
402
|
+
},
|
|
403
|
+
},
|
|
404
|
+
|
|
394
405
|
"tui.maxInlineImageColumns": {
|
|
395
406
|
type: "number",
|
|
396
407
|
default: 100,
|
package/src/cursor.ts
CHANGED
|
@@ -42,7 +42,6 @@ function createToolResultMessage(
|
|
|
42
42
|
function buildToolErrorResult(message: string): AgentToolResult<unknown> {
|
|
43
43
|
return {
|
|
44
44
|
content: [{ type: "text", text: message }],
|
|
45
|
-
details: {},
|
|
46
45
|
};
|
|
47
46
|
}
|
|
48
47
|
|
|
@@ -311,7 +310,6 @@ export class CursorExecHandlers implements ICursorExecHandlers {
|
|
|
311
310
|
toolCallId,
|
|
312
311
|
toolName,
|
|
313
312
|
content: [{ type: "text", text: message }],
|
|
314
|
-
details: {},
|
|
315
313
|
isError: true,
|
|
316
314
|
timestamp: Date.now(),
|
|
317
315
|
};
|
|
@@ -17,17 +17,17 @@ export interface BuildInfo {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
export const BUILD_INFO: BuildInfo = {
|
|
20
|
-
"version": "18.
|
|
21
|
-
"commit": "
|
|
22
|
-
"shortCommit": "
|
|
20
|
+
"version": "18.83.1",
|
|
21
|
+
"commit": "255a105f19a8485d74487ef9914154fdd6ad5bfd",
|
|
22
|
+
"shortCommit": "255a105",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v18.
|
|
25
|
-
"commitDate": "2026-05-
|
|
26
|
-
"buildDate": "2026-05-
|
|
24
|
+
"tag": "v18.83.1",
|
|
25
|
+
"commitDate": "2026-05-27T01:34:26Z",
|
|
26
|
+
"buildDate": "2026-05-27T02:02:08.778Z",
|
|
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/v18.
|
|
31
|
+
"commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/255a105f19a8485d74487ef9914154fdd6ad5bfd",
|
|
32
|
+
"releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.83.1"
|
|
33
33
|
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
2
3
|
import { type AgentMessage, ThinkingLevel } from "@f5xc-salesdemos/pi-agent-core";
|
|
3
4
|
import { sanitizeText } from "@f5xc-salesdemos/pi-natives";
|
|
4
5
|
import { type AutocompleteProvider, ChordDispatcher, type SlashCommand } from "@f5xc-salesdemos/pi-tui";
|
|
@@ -11,6 +12,7 @@ import type { InteractiveModeContext } from "../../modes/types";
|
|
|
11
12
|
import type { AgentSessionEvent } from "../../session/agent-session";
|
|
12
13
|
import { SKILL_PROMPT_MESSAGE_TYPE, type SkillPromptDetails } from "../../session/messages";
|
|
13
14
|
import { executeBuiltinSlashCommand } from "../../slash-commands/builtin-registry";
|
|
15
|
+
import { resolveToCwd } from "../../tools/path-utils";
|
|
14
16
|
import { copyToClipboard, readImageFromClipboard } from "../../utils/clipboard";
|
|
15
17
|
import { getEditorCommand, openInEditor } from "../../utils/external-editor";
|
|
16
18
|
import { ensureSupportedImageInput } from "../../utils/image-loading";
|
|
@@ -25,6 +27,15 @@ function isExpandable(obj: unknown): obj is Expandable {
|
|
|
25
27
|
return typeof obj === "object" && obj !== null && "setExpanded" in obj && typeof obj.setExpanded === "function";
|
|
26
28
|
}
|
|
27
29
|
|
|
30
|
+
async function fileExists(filePath: string): Promise<boolean> {
|
|
31
|
+
try {
|
|
32
|
+
await fs.access(filePath);
|
|
33
|
+
return true;
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
28
39
|
export class InputController {
|
|
29
40
|
#dispatcher: ChordDispatcher | null = null;
|
|
30
41
|
|
|
@@ -599,9 +610,17 @@ export class InputController {
|
|
|
599
610
|
data: imageData.data,
|
|
600
611
|
mimeType: imageData.mimeType,
|
|
601
612
|
});
|
|
602
|
-
|
|
613
|
+
|
|
603
614
|
const imageNum = this.ctx.pendingImages.length;
|
|
604
|
-
|
|
615
|
+
let placeholder = `[Image #${imageNum}]`;
|
|
616
|
+
|
|
617
|
+
const cwd = this.ctx.sessionManager.getCwd();
|
|
618
|
+
const savedPath = await this.saveImageToDisk(imageData.data, cwd);
|
|
619
|
+
if (savedPath) {
|
|
620
|
+
const relativePath = path.relative(cwd, savedPath);
|
|
621
|
+
placeholder = `[Image #${imageNum}] (./${relativePath})`;
|
|
622
|
+
}
|
|
623
|
+
|
|
605
624
|
this.ctx.editor.insertText(`${placeholder} `);
|
|
606
625
|
this.ctx.ui.requestRender();
|
|
607
626
|
return true;
|
|
@@ -615,6 +634,34 @@ export class InputController {
|
|
|
615
634
|
}
|
|
616
635
|
}
|
|
617
636
|
|
|
637
|
+
private async saveImageToDisk(base64Data: string, cwd: string): Promise<string | null> {
|
|
638
|
+
try {
|
|
639
|
+
const pasteDir = settings.get("images.pasteDir") ?? ".";
|
|
640
|
+
const resolvedDir = resolveToCwd(pasteDir, cwd);
|
|
641
|
+
await fs.mkdir(resolvedDir, { recursive: true });
|
|
642
|
+
|
|
643
|
+
const now = new Date();
|
|
644
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
645
|
+
const timestamp = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
|
646
|
+
|
|
647
|
+
let filename = `clipboard-${timestamp}.png`;
|
|
648
|
+
let filePath = path.join(resolvedDir, filename);
|
|
649
|
+
|
|
650
|
+
let counter = 2;
|
|
651
|
+
while (await fileExists(filePath)) {
|
|
652
|
+
filename = `clipboard-${timestamp}-${counter}.png`;
|
|
653
|
+
filePath = path.join(resolvedDir, filename);
|
|
654
|
+
counter++;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
const buffer = Buffer.from(base64Data, "base64");
|
|
658
|
+
await fs.writeFile(filePath, buffer);
|
|
659
|
+
return filePath;
|
|
660
|
+
} catch {
|
|
661
|
+
return null;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
618
665
|
createAutocompleteProvider(commands: SlashCommand[], basePath: string): AutocompleteProvider {
|
|
619
666
|
return createPromptActionAutocompleteProvider({
|
|
620
667
|
commands,
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/** TUI renderer for lightweight action tools — checkpoint, rewind, cancel_job, poll. */
|
|
2
|
+
import type { Component } from "@f5xc-salesdemos/pi-tui";
|
|
3
|
+
import { Text } from "@f5xc-salesdemos/pi-tui";
|
|
4
|
+
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
|
5
|
+
import type { Theme, ThemeColor } from "../modes/theme/theme";
|
|
6
|
+
import { CachedOutputBlock, F5_TOOL_BORDER_COLOR, renderStatusLine } from "../tui";
|
|
7
|
+
import type { CancelJobToolDetails } from "./cancel-job";
|
|
8
|
+
import type { CheckpointToolDetails, RewindToolDetails } from "./checkpoint";
|
|
9
|
+
import type { PollToolDetails } from "./poll-tool";
|
|
10
|
+
import { addSection, formatErrorMessage, replaceTabs } from "./render-utils";
|
|
11
|
+
|
|
12
|
+
type ActionDetails = CheckpointToolDetails | RewindToolDetails | CancelJobToolDetails | PollToolDetails;
|
|
13
|
+
|
|
14
|
+
type ActionRenderArgs = {
|
|
15
|
+
goal?: string;
|
|
16
|
+
report?: string;
|
|
17
|
+
job_id?: string;
|
|
18
|
+
jobs?: string[];
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
const CANCEL_STATUS_COLORS: Record<string, ThemeColor> = {
|
|
22
|
+
cancelled: "success",
|
|
23
|
+
not_found: "warning",
|
|
24
|
+
already_completed: "dim",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const POLL_STATUS_COLORS: Record<string, ThemeColor> = {
|
|
28
|
+
completed: "success",
|
|
29
|
+
failed: "error",
|
|
30
|
+
running: "chromeAccent",
|
|
31
|
+
cancelled: "warning",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
function formatDuration(ms: number): string {
|
|
35
|
+
if (ms < 1_000) return `${ms}ms`;
|
|
36
|
+
const seconds = Math.floor(ms / 1_000);
|
|
37
|
+
if (seconds < 60) return `${seconds}s`;
|
|
38
|
+
const minutes = Math.floor(seconds / 60);
|
|
39
|
+
const remainingSeconds = seconds % 60;
|
|
40
|
+
return `${minutes}m${remainingSeconds}s`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function isCheckpointDetails(d: ActionDetails): d is CheckpointToolDetails {
|
|
44
|
+
return "goal" in d && "startedAt" in d;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function isRewindDetails(d: ActionDetails): d is RewindToolDetails {
|
|
48
|
+
return "report" in d && "rewound" in d;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isCancelJobDetails(d: ActionDetails): d is CancelJobToolDetails {
|
|
52
|
+
return "status" in d && "jobId" in d;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isPollDetails(d: ActionDetails): d is PollToolDetails {
|
|
56
|
+
return "jobs" in d && Array.isArray((d as PollToolDetails).jobs);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function buildPollJobTable(details: PollToolDetails, uiTheme: Theme): string[] {
|
|
60
|
+
const jobs = details.jobs;
|
|
61
|
+
if (jobs.length === 0) return [uiTheme.fg("dim", " No jobs.")];
|
|
62
|
+
|
|
63
|
+
return jobs.map(job => {
|
|
64
|
+
const statusColor = POLL_STATUS_COLORS[job.status] ?? "muted";
|
|
65
|
+
const status = uiTheme.fg(statusColor, job.status.padEnd(10));
|
|
66
|
+
const id = uiTheme.fg("toolOutput", job.id);
|
|
67
|
+
const typeBadge = uiTheme.fg("dim", `[${job.type}]`);
|
|
68
|
+
const label = uiTheme.fg("muted", job.label.length > 50 ? `${job.label.slice(0, 47)}…` : job.label);
|
|
69
|
+
const duration = uiTheme.fg("dim", formatDuration(job.durationMs));
|
|
70
|
+
return ` ${status} ${id} ${typeBadge} ${label} ${duration}`;
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const actionRenderer = {
|
|
75
|
+
renderCall(args: ActionRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
|
|
76
|
+
let title: string;
|
|
77
|
+
let description: string | undefined;
|
|
78
|
+
|
|
79
|
+
if (args.goal !== undefined) {
|
|
80
|
+
title = "Checkpoint";
|
|
81
|
+
description = uiTheme.fg("muted", args.goal.length > 60 ? `${args.goal.slice(0, 57)}…` : args.goal);
|
|
82
|
+
} else if (args.report !== undefined) {
|
|
83
|
+
title = "Rewind";
|
|
84
|
+
} else if (args.job_id !== undefined) {
|
|
85
|
+
title = "Cancel Job";
|
|
86
|
+
description = uiTheme.fg("muted", args.job_id);
|
|
87
|
+
} else if (args.jobs !== undefined) {
|
|
88
|
+
title = "Poll";
|
|
89
|
+
description =
|
|
90
|
+
args.jobs.length > 0
|
|
91
|
+
? uiTheme.fg("muted", `${args.jobs.length} job${args.jobs.length !== 1 ? "s" : ""}`)
|
|
92
|
+
: undefined;
|
|
93
|
+
} else {
|
|
94
|
+
title = "Poll";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const text = renderStatusLine({ icon: "pending", title, description }, uiTheme);
|
|
98
|
+
return new Text(text, 0, 0);
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
renderResult(
|
|
102
|
+
result: { content: Array<{ type: string; text?: string }>; details?: ActionDetails; isError?: boolean },
|
|
103
|
+
options: RenderResultOptions,
|
|
104
|
+
uiTheme: Theme,
|
|
105
|
+
_args?: ActionRenderArgs,
|
|
106
|
+
): Component {
|
|
107
|
+
const details = result.details;
|
|
108
|
+
const isError = result.isError === true;
|
|
109
|
+
|
|
110
|
+
if (isError || !details) {
|
|
111
|
+
const errorText = result.content?.find(c => c.type === "text")?.text;
|
|
112
|
+
return new Text(formatErrorMessage(errorText, uiTheme), 0, 0);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const sections: Array<{ label?: string; lines: string[] }> = [];
|
|
116
|
+
const meta: string[] = [];
|
|
117
|
+
let title: string;
|
|
118
|
+
let badgeLabel: string;
|
|
119
|
+
let badgeColor: ThemeColor;
|
|
120
|
+
|
|
121
|
+
if (isCheckpointDetails(details)) {
|
|
122
|
+
title = "Checkpoint";
|
|
123
|
+
badgeLabel = "created";
|
|
124
|
+
badgeColor = "chromeAccent";
|
|
125
|
+
addSection(sections, "Goal", [uiTheme.fg("toolOutput", ` ${details.goal}`)], uiTheme);
|
|
126
|
+
} else if (isRewindDetails(details)) {
|
|
127
|
+
title = "Rewind";
|
|
128
|
+
badgeLabel = details.rewound ? "rewound" : "no-op";
|
|
129
|
+
badgeColor = details.rewound ? "warning" : "dim";
|
|
130
|
+
addSection(
|
|
131
|
+
sections,
|
|
132
|
+
"Report",
|
|
133
|
+
details.report.split("\n").map(line => replaceTabs(uiTheme.fg("toolOutput", ` ${line}`))),
|
|
134
|
+
uiTheme,
|
|
135
|
+
);
|
|
136
|
+
} else if (isCancelJobDetails(details)) {
|
|
137
|
+
title = "Cancel Job";
|
|
138
|
+
badgeLabel = details.status;
|
|
139
|
+
badgeColor = CANCEL_STATUS_COLORS[details.status] ?? "muted";
|
|
140
|
+
meta.push(uiTheme.fg("dim", details.jobId));
|
|
141
|
+
const text = result.content?.find(c => c.type === "text")?.text ?? "";
|
|
142
|
+
addSection(sections, "Result", [uiTheme.fg("toolOutput", ` ${text}`)], uiTheme);
|
|
143
|
+
} else if (isPollDetails(details)) {
|
|
144
|
+
title = "Poll";
|
|
145
|
+
const completed = details.jobs.filter(j => j.status !== "running");
|
|
146
|
+
const running = details.jobs.filter(j => j.status === "running");
|
|
147
|
+
badgeLabel = `${details.jobs.length} job${details.jobs.length !== 1 ? "s" : ""}`;
|
|
148
|
+
badgeColor = running.length > 0 ? "chromeAccent" : "success";
|
|
149
|
+
if (completed.length > 0) meta.push(uiTheme.fg("success", `${completed.length} done`));
|
|
150
|
+
if (running.length > 0) meta.push(uiTheme.fg("chromeAccent", `${running.length} running`));
|
|
151
|
+
addSection(sections, "Jobs", buildPollJobTable(details, uiTheme), uiTheme);
|
|
152
|
+
for (const job of completed) {
|
|
153
|
+
if (job.resultText) {
|
|
154
|
+
const outputLines = job.resultText.split("\n").map(line => replaceTabs(uiTheme.fg("toolOutput", line)));
|
|
155
|
+
addSection(sections, `Output: ${job.id}`, outputLines, uiTheme, 30);
|
|
156
|
+
}
|
|
157
|
+
if (job.errorText) {
|
|
158
|
+
addSection(sections, `Error: ${job.id}`, [uiTheme.fg("error", job.errorText)], uiTheme);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
} else {
|
|
162
|
+
title = "Action";
|
|
163
|
+
badgeLabel = "done";
|
|
164
|
+
badgeColor = "muted";
|
|
165
|
+
const text = result.content?.find(c => c.type === "text")?.text ?? "";
|
|
166
|
+
addSection(
|
|
167
|
+
sections,
|
|
168
|
+
"Result",
|
|
169
|
+
text.split("\n").map(line => replaceTabs(uiTheme.fg("toolOutput", line))),
|
|
170
|
+
uiTheme,
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const header = renderStatusLine(
|
|
175
|
+
{
|
|
176
|
+
title,
|
|
177
|
+
titleColor: "muted",
|
|
178
|
+
badge: { label: badgeLabel, color: badgeColor },
|
|
179
|
+
meta: meta.length > 0 ? meta : undefined,
|
|
180
|
+
},
|
|
181
|
+
uiTheme,
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const outputBlock = new CachedOutputBlock();
|
|
185
|
+
return {
|
|
186
|
+
render(width: number): string[] {
|
|
187
|
+
const state = options.isPartial ? "pending" : "success";
|
|
188
|
+
return outputBlock.render({ header, state, sections, width, borderColor: F5_TOOL_BORDER_COLOR }, uiTheme);
|
|
189
|
+
},
|
|
190
|
+
invalidate() {
|
|
191
|
+
outputBlock.invalidate();
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
mergeCallAndResult: true,
|
|
197
|
+
inline: true,
|
|
198
|
+
};
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/** TUI renderer for the browser (puppeteer) tool — lightweight action-aware display. */
|
|
2
|
+
import type { Component } from "@f5xc-salesdemos/pi-tui";
|
|
3
|
+
import { Text } from "@f5xc-salesdemos/pi-tui";
|
|
4
|
+
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
|
5
|
+
import type { Theme, ThemeColor } from "../modes/theme/theme";
|
|
6
|
+
import { CachedOutputBlock, F5_TOOL_BORDER_COLOR, renderStatusLine } from "../tui";
|
|
7
|
+
import type { BrowserToolDetails } from "./browser";
|
|
8
|
+
import { addSection, formatErrorMessage, replaceTabs, shortenPath } from "./render-utils";
|
|
9
|
+
|
|
10
|
+
const TOOL_TITLE = "Browser";
|
|
11
|
+
const MAX_CONTENT_LINES = 30;
|
|
12
|
+
|
|
13
|
+
type BrowserRenderArgs = {
|
|
14
|
+
action?: string;
|
|
15
|
+
url?: string;
|
|
16
|
+
selector?: string;
|
|
17
|
+
text?: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const ACTION_COLORS: Partial<Record<string, ThemeColor>> = {
|
|
21
|
+
open: "muted",
|
|
22
|
+
goto: "muted",
|
|
23
|
+
close: "dim",
|
|
24
|
+
click: "chromeAccent",
|
|
25
|
+
click_id: "chromeAccent",
|
|
26
|
+
type: "chromeAccent",
|
|
27
|
+
type_id: "chromeAccent",
|
|
28
|
+
fill: "chromeAccent",
|
|
29
|
+
fill_id: "chromeAccent",
|
|
30
|
+
press: "chromeAccent",
|
|
31
|
+
scroll: "muted",
|
|
32
|
+
drag: "chromeAccent",
|
|
33
|
+
wait_for_selector: "dim",
|
|
34
|
+
evaluate: "contentAccent",
|
|
35
|
+
get_text: "contentAccent",
|
|
36
|
+
get_html: "contentAccent",
|
|
37
|
+
get_attribute: "contentAccent",
|
|
38
|
+
extract_readable: "contentAccent",
|
|
39
|
+
screenshot: "warning",
|
|
40
|
+
observe: "accent",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export const browserRenderer = {
|
|
44
|
+
renderCall(args: BrowserRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
|
|
45
|
+
const action = args.action ?? "browse";
|
|
46
|
+
const description = args.url
|
|
47
|
+
? uiTheme.fg("muted", args.url)
|
|
48
|
+
: args.selector
|
|
49
|
+
? uiTheme.fg("dim", args.selector)
|
|
50
|
+
: undefined;
|
|
51
|
+
const badgeColor = ACTION_COLORS[action] ?? "muted";
|
|
52
|
+
const text = renderStatusLine(
|
|
53
|
+
{ icon: "pending", title: TOOL_TITLE, badge: { label: action, color: badgeColor }, description },
|
|
54
|
+
uiTheme,
|
|
55
|
+
);
|
|
56
|
+
return new Text(text, 0, 0);
|
|
57
|
+
},
|
|
58
|
+
|
|
59
|
+
renderResult(
|
|
60
|
+
result: { content: Array<{ type: string; text?: string }>; details?: BrowserToolDetails; isError?: boolean },
|
|
61
|
+
options: RenderResultOptions,
|
|
62
|
+
uiTheme: Theme,
|
|
63
|
+
args?: BrowserRenderArgs,
|
|
64
|
+
): Component {
|
|
65
|
+
const details = result.details;
|
|
66
|
+
const isError = result.isError === true;
|
|
67
|
+
|
|
68
|
+
if (isError && !details) {
|
|
69
|
+
const errorText = result.content?.find(c => c.type === "text")?.text;
|
|
70
|
+
return new Text(formatErrorMessage(errorText, uiTheme), 0, 0);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const action = details?.action ?? args?.action ?? "browse";
|
|
74
|
+
const badgeColor = ACTION_COLORS[action] ?? "muted";
|
|
75
|
+
const sections: Array<{ label?: string; lines: string[] }> = [];
|
|
76
|
+
const meta: string[] = [];
|
|
77
|
+
|
|
78
|
+
if (isError) {
|
|
79
|
+
const errorText = result.content?.find(c => c.type === "text")?.text ?? "Unknown error";
|
|
80
|
+
addSection(sections, "Error", [uiTheme.fg("error", errorText)], uiTheme);
|
|
81
|
+
} else {
|
|
82
|
+
if (details?.url)
|
|
83
|
+
meta.push(uiTheme.fg("dim", details.url.length > 50 ? `${details.url.slice(0, 47)}…` : details.url));
|
|
84
|
+
|
|
85
|
+
if (details?.screenshotPath) {
|
|
86
|
+
addSection(
|
|
87
|
+
sections,
|
|
88
|
+
"Screenshot",
|
|
89
|
+
[uiTheme.fg("toolOutput", ` ${shortenPath(details.screenshotPath)}`)],
|
|
90
|
+
uiTheme,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (details?.viewport) {
|
|
95
|
+
meta.push(uiTheme.fg("dim", `${details.viewport.width}×${details.viewport.height}`));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const textContent = result.content?.find(c => c.type === "text")?.text;
|
|
99
|
+
if (textContent) {
|
|
100
|
+
const contentLines = textContent.split("\n").map(line => replaceTabs(uiTheme.fg("toolOutput", line)));
|
|
101
|
+
addSection(sections, "Result", contentLines, uiTheme, MAX_CONTENT_LINES);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const header = renderStatusLine(
|
|
106
|
+
{
|
|
107
|
+
title: TOOL_TITLE,
|
|
108
|
+
titleColor: "contentAccent",
|
|
109
|
+
badge: { label: action, color: isError ? "error" : badgeColor },
|
|
110
|
+
meta: meta.length > 0 ? meta : undefined,
|
|
111
|
+
},
|
|
112
|
+
uiTheme,
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const outputBlock = new CachedOutputBlock();
|
|
116
|
+
return {
|
|
117
|
+
render(width: number): string[] {
|
|
118
|
+
const state = options.isPartial ? "pending" : isError ? "error" : "success";
|
|
119
|
+
return outputBlock.render({ header, state, sections, width, borderColor: F5_TOOL_BORDER_COLOR }, uiTheme);
|
|
120
|
+
},
|
|
121
|
+
invalidate() {
|
|
122
|
+
outputBlock.invalidate();
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
mergeCallAndResult: true,
|
|
128
|
+
inline: true,
|
|
129
|
+
};
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
/** TUI renderer for GitHub tools — rich visual output for issue/PR/repo/search/diff/checkout/push. */
|
|
2
|
+
import type { Component } from "@f5xc-salesdemos/pi-tui";
|
|
3
|
+
import { Text } from "@f5xc-salesdemos/pi-tui";
|
|
4
|
+
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
|
5
|
+
import type { Theme, ThemeColor } from "../modes/theme/theme";
|
|
6
|
+
import { CachedOutputBlock, F5_TOOL_BORDER_COLOR, renderStatusLine } from "../tui";
|
|
7
|
+
import type { GhToolDetails } from "./gh";
|
|
8
|
+
import { addSection, formatErrorMessage, replaceTabs } from "./render-utils";
|
|
9
|
+
|
|
10
|
+
const TOOL_TITLE = "GitHub";
|
|
11
|
+
const MAX_BODY_LINES = 30;
|
|
12
|
+
const MAX_DIFF_LINES = 80;
|
|
13
|
+
const MAX_SECTION_LINES = 40;
|
|
14
|
+
|
|
15
|
+
type GhRenderArgs = {
|
|
16
|
+
repo?: string;
|
|
17
|
+
branch?: string;
|
|
18
|
+
issue?: string;
|
|
19
|
+
pr?: string;
|
|
20
|
+
query?: string;
|
|
21
|
+
nameOnly?: boolean;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
function ghStateColor(state: string | undefined): ThemeColor {
|
|
25
|
+
if (!state) return "dim";
|
|
26
|
+
const upper = state.toUpperCase();
|
|
27
|
+
if (upper === "OPEN" || upper === "OPENED") return "success";
|
|
28
|
+
if (upper === "CLOSED") return "dim";
|
|
29
|
+
if (upper === "MERGED") return "chromeAccent";
|
|
30
|
+
return "muted";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Extract key-value pairs from lines like "Key: Value". */
|
|
34
|
+
function extractKV(lines: string[]): Array<{ key: string; value: string }> {
|
|
35
|
+
const pairs: Array<{ key: string; value: string }> = [];
|
|
36
|
+
for (const line of lines) {
|
|
37
|
+
const match = line.match(/^(\s*\S[^:]*?):\s+(.+)$/);
|
|
38
|
+
if (match) {
|
|
39
|
+
pairs.push({ key: match[1]!.trim(), value: match[2]!.trim() });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return pairs;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Split markdown text into sections by ## headings. */
|
|
46
|
+
function splitSections(text: string): Array<{ heading: string; body: string }> {
|
|
47
|
+
const result: Array<{ heading: string; body: string }> = [];
|
|
48
|
+
const parts = text.split(/^## /m);
|
|
49
|
+
for (const part of parts) {
|
|
50
|
+
if (!part.trim()) continue;
|
|
51
|
+
const newlineIdx = part.indexOf("\n");
|
|
52
|
+
if (newlineIdx < 0) {
|
|
53
|
+
result.push({ heading: part.trim(), body: "" });
|
|
54
|
+
} else {
|
|
55
|
+
result.push({ heading: part.slice(0, newlineIdx).trim(), body: part.slice(newlineIdx + 1).trim() });
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return result;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Build themed KV section from extracted pairs. */
|
|
62
|
+
function buildKVSection(pairs: Array<{ key: string; value: string }>, uiTheme: Theme): string[] {
|
|
63
|
+
const maxKeyLen = Math.max(...pairs.map(p => p.key.length), 8);
|
|
64
|
+
return pairs.map(p => {
|
|
65
|
+
const stateKeys = new Set(["State", "Draft", "Review decision", "Merge state", "Visibility"]);
|
|
66
|
+
const valueColor: ThemeColor = stateKeys.has(p.key) ? ghStateColor(p.value) : "toolOutput";
|
|
67
|
+
return ` ${uiTheme.fg("dim", p.key.padEnd(maxKeyLen + 2))}${uiTheme.fg(valueColor, p.value)}`;
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Color diff lines using theme diff tokens. */
|
|
72
|
+
function colorDiffLines(lines: string[], uiTheme: Theme): string[] {
|
|
73
|
+
return lines.map(line => {
|
|
74
|
+
if (line.startsWith("+") && !line.startsWith("+++")) return uiTheme.fg("toolDiffAdded", replaceTabs(line));
|
|
75
|
+
if (line.startsWith("-") && !line.startsWith("---")) return uiTheme.fg("toolDiffRemoved", replaceTabs(line));
|
|
76
|
+
if (line.startsWith("@@")) return uiTheme.fg("chromeAccent", replaceTabs(line));
|
|
77
|
+
if (line.startsWith("diff ") || line.startsWith("index ")) return uiTheme.fg("dim", replaceTabs(line));
|
|
78
|
+
return uiTheme.fg("muted", replaceTabs(line));
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function renderMarkdownSections(
|
|
83
|
+
text: string,
|
|
84
|
+
uiTheme: Theme,
|
|
85
|
+
sections: Array<{ label?: string; lines: string[] }>,
|
|
86
|
+
): void {
|
|
87
|
+
// Extract the H1 title line and preamble KV
|
|
88
|
+
const lines = text.split("\n");
|
|
89
|
+
let titleLine: string | undefined;
|
|
90
|
+
const preambleLines: string[] = [];
|
|
91
|
+
let bodyStart = 0;
|
|
92
|
+
|
|
93
|
+
for (let i = 0; i < lines.length; i++) {
|
|
94
|
+
const line = lines[i]!;
|
|
95
|
+
if (line.startsWith("# ") && !titleLine) {
|
|
96
|
+
titleLine = line.slice(2).trim();
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (line.startsWith("## ")) {
|
|
100
|
+
bodyStart = i;
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
if (line.trim()) preambleLines.push(line);
|
|
104
|
+
bodyStart = i + 1;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Build themed KV from preamble, and preserve non-KV prose lines
|
|
108
|
+
const kvPairs = extractKV(preambleLines);
|
|
109
|
+
const nonKVLines = preambleLines.filter(line => !line.match(/^(\s*\S[^:]*?):\s+(.+)$/));
|
|
110
|
+
if (kvPairs.length > 0) {
|
|
111
|
+
addSection(sections, "Summary", buildKVSection(kvPairs, uiTheme), uiTheme);
|
|
112
|
+
}
|
|
113
|
+
if (nonKVLines.length > 0) {
|
|
114
|
+
const proseLines = nonKVLines.map(line => replaceTabs(uiTheme.fg("toolOutput", line)));
|
|
115
|
+
addSection(sections, "Details", proseLines, uiTheme, MAX_BODY_LINES);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Process ## sections
|
|
119
|
+
const remainingText = lines.slice(bodyStart).join("\n");
|
|
120
|
+
const mdSections = splitSections(remainingText);
|
|
121
|
+
for (const section of mdSections) {
|
|
122
|
+
const sectionLines = section.body
|
|
123
|
+
.split("\n")
|
|
124
|
+
.filter(l => l.trim())
|
|
125
|
+
.map(line => replaceTabs(uiTheme.fg("toolOutput", line)));
|
|
126
|
+
if (sectionLines.length > 0) {
|
|
127
|
+
const maxLines = section.heading.toLowerCase().includes("comment") ? MAX_SECTION_LINES : MAX_BODY_LINES;
|
|
128
|
+
addSection(sections, section.heading, sectionLines, uiTheme, maxLines);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export const ghToolsRenderer = {
|
|
134
|
+
renderCall(args: GhRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
|
|
135
|
+
let description: string | undefined;
|
|
136
|
+
if (args.issue) {
|
|
137
|
+
description = uiTheme.fg("muted", `issue ${args.issue}`);
|
|
138
|
+
} else if (args.pr) {
|
|
139
|
+
description = uiTheme.fg("muted", `PR ${args.pr}`);
|
|
140
|
+
} else if (args.query) {
|
|
141
|
+
description = uiTheme.fg("muted", `search: ${args.query}`);
|
|
142
|
+
} else if (args.repo) {
|
|
143
|
+
description = uiTheme.fg("muted", args.repo);
|
|
144
|
+
}
|
|
145
|
+
const text = renderStatusLine({ icon: "pending", title: TOOL_TITLE, description }, uiTheme);
|
|
146
|
+
return new Text(text, 0, 0);
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
renderResult(
|
|
150
|
+
result: { content: Array<{ type: string; text?: string }>; details?: GhToolDetails; isError?: boolean },
|
|
151
|
+
options: RenderResultOptions,
|
|
152
|
+
uiTheme: Theme,
|
|
153
|
+
args?: GhRenderArgs,
|
|
154
|
+
): Component {
|
|
155
|
+
const details = result.details;
|
|
156
|
+
const isError = result.isError === true;
|
|
157
|
+
|
|
158
|
+
if (isError && !details) {
|
|
159
|
+
const errorText = result.content?.find(c => c.type === "text")?.text;
|
|
160
|
+
return new Text(formatErrorMessage(errorText, uiTheme), 0, 0);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const tool = details?.tool;
|
|
164
|
+
const sections: Array<{ label?: string; lines: string[] }> = [];
|
|
165
|
+
const meta: string[] = [];
|
|
166
|
+
let description: string | undefined;
|
|
167
|
+
let badgeLabel: string | undefined;
|
|
168
|
+
let badgeColor: ThemeColor = "muted";
|
|
169
|
+
const textContent = result.content?.find(c => c.type === "text")?.text ?? "";
|
|
170
|
+
|
|
171
|
+
if (isError) {
|
|
172
|
+
addSection(sections, "Error", [uiTheme.fg("error", textContent || "Unknown error")], uiTheme);
|
|
173
|
+
const header = renderStatusLine(
|
|
174
|
+
{ title: TOOL_TITLE, titleColor: "contentAccent", badge: { label: "error", color: "error" } },
|
|
175
|
+
uiTheme,
|
|
176
|
+
);
|
|
177
|
+
const outputBlock = new CachedOutputBlock();
|
|
178
|
+
return {
|
|
179
|
+
render(width: number): string[] {
|
|
180
|
+
return outputBlock.render({ header, state: "error", sections, width }, uiTheme);
|
|
181
|
+
},
|
|
182
|
+
invalidate() {
|
|
183
|
+
outputBlock.invalidate();
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
if (tool === "gh_pr_diff") {
|
|
189
|
+
description = args?.pr ? `PR ${args.pr} diff` : "diff";
|
|
190
|
+
if (args?.nameOnly) {
|
|
191
|
+
badgeLabel = "files";
|
|
192
|
+
badgeColor = "contentAccent";
|
|
193
|
+
}
|
|
194
|
+
const diffLines = textContent.split("\n");
|
|
195
|
+
const themed = args?.nameOnly
|
|
196
|
+
? diffLines.map(line => uiTheme.fg("toolOutput", ` ${replaceTabs(line)}`))
|
|
197
|
+
: colorDiffLines(diffLines, uiTheme);
|
|
198
|
+
addSection(sections, args?.nameOnly ? "Files" : "Diff", themed, uiTheme, MAX_DIFF_LINES);
|
|
199
|
+
} else if (tool === "gh_pr_checkout") {
|
|
200
|
+
// Extract title from first markdown header
|
|
201
|
+
const titleMatch = textContent.match(/^# (.+)/m);
|
|
202
|
+
description = titleMatch ? titleMatch[1] : "checkout";
|
|
203
|
+
if (details?.branch) meta.push(uiTheme.fg("dim", details.branch));
|
|
204
|
+
if (details?.worktreePath) meta.push(uiTheme.fg("dim", details.worktreePath));
|
|
205
|
+
renderMarkdownSections(textContent, uiTheme, sections);
|
|
206
|
+
} else if (tool === "gh_pr_push") {
|
|
207
|
+
description = "push";
|
|
208
|
+
if (details?.branch) meta.push(uiTheme.fg("dim", details.branch));
|
|
209
|
+
if (details?.remote) meta.push(uiTheme.fg("dim", `→ ${details.remote}:${details.remoteBranch ?? ""}`));
|
|
210
|
+
renderMarkdownSections(textContent, uiTheme, sections);
|
|
211
|
+
} else if (tool === "gh_search_issues" || tool === "gh_search_prs") {
|
|
212
|
+
const kind = tool === "gh_search_issues" ? "issues" : "PRs";
|
|
213
|
+
description = `search ${kind}: ${args?.query ?? ""}`;
|
|
214
|
+
// Count results from markdown list items
|
|
215
|
+
const resultCount = (textContent.match(/^- #\d+/gm) ?? []).length;
|
|
216
|
+
meta.push(uiTheme.fg("dim", `${resultCount} result${resultCount !== 1 ? "s" : ""}`));
|
|
217
|
+
renderMarkdownSections(textContent, uiTheme, sections);
|
|
218
|
+
} else if (tool === "gh_repo_view") {
|
|
219
|
+
const titleMatch = textContent.match(/^# (.+)/m);
|
|
220
|
+
description = titleMatch ? titleMatch[1] : (details?.repo ?? args?.repo ?? "repo");
|
|
221
|
+
renderMarkdownSections(textContent, uiTheme, sections);
|
|
222
|
+
} else if (tool === "gh_issue_view") {
|
|
223
|
+
const titleMatch = textContent.match(/^# Issue #(\d+): (.+)/m);
|
|
224
|
+
if (titleMatch) {
|
|
225
|
+
description = `#${titleMatch[1]}: ${titleMatch[2]}`;
|
|
226
|
+
} else {
|
|
227
|
+
description = args?.issue ? `issue ${args.issue}` : "issue";
|
|
228
|
+
}
|
|
229
|
+
// Extract state from KV
|
|
230
|
+
const stateMatch = textContent.match(/^State:\s+(.+)/m);
|
|
231
|
+
if (stateMatch) meta.push(uiTheme.fg(ghStateColor(stateMatch[1]), stateMatch[1]!));
|
|
232
|
+
renderMarkdownSections(textContent, uiTheme, sections);
|
|
233
|
+
} else if (tool === "gh_pr_view") {
|
|
234
|
+
const titleMatch = textContent.match(/^# Pull Request #(\d+): (.+)/m);
|
|
235
|
+
if (titleMatch) {
|
|
236
|
+
description = `#${titleMatch[1]}: ${titleMatch[2]}`;
|
|
237
|
+
} else {
|
|
238
|
+
description = args?.pr ? `PR ${args.pr}` : "PR";
|
|
239
|
+
}
|
|
240
|
+
const stateMatch = textContent.match(/^State:\s+(.+)/m);
|
|
241
|
+
if (stateMatch) meta.push(uiTheme.fg(ghStateColor(stateMatch[1]), stateMatch[1]!));
|
|
242
|
+
const draftMatch = textContent.match(/^Draft:\s+(.+)/m);
|
|
243
|
+
if (draftMatch?.[1] === "true") meta.push(uiTheme.fg("warning", "DRAFT"));
|
|
244
|
+
renderMarkdownSections(textContent, uiTheme, sections);
|
|
245
|
+
} else {
|
|
246
|
+
// Fallback for unknown tool
|
|
247
|
+
renderMarkdownSections(textContent, uiTheme, sections);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const header = description
|
|
251
|
+
? renderStatusLine(
|
|
252
|
+
{
|
|
253
|
+
title: TOOL_TITLE,
|
|
254
|
+
titleColor: "contentAccent",
|
|
255
|
+
description,
|
|
256
|
+
meta: meta.length > 0 ? meta : undefined,
|
|
257
|
+
},
|
|
258
|
+
uiTheme,
|
|
259
|
+
)
|
|
260
|
+
: renderStatusLine(
|
|
261
|
+
{
|
|
262
|
+
title: TOOL_TITLE,
|
|
263
|
+
titleColor: "contentAccent",
|
|
264
|
+
badge: badgeLabel ? { label: badgeLabel, color: badgeColor } : undefined,
|
|
265
|
+
meta: meta.length > 0 ? meta : undefined,
|
|
266
|
+
},
|
|
267
|
+
uiTheme,
|
|
268
|
+
);
|
|
269
|
+
|
|
270
|
+
const outputBlock = new CachedOutputBlock();
|
|
271
|
+
return {
|
|
272
|
+
render(width: number): string[] {
|
|
273
|
+
const state = options.isPartial ? "pending" : "success";
|
|
274
|
+
return outputBlock.render({ header, state, sections, width, borderColor: F5_TOOL_BORDER_COLOR }, uiTheme);
|
|
275
|
+
},
|
|
276
|
+
invalidate() {
|
|
277
|
+
outputBlock.invalidate();
|
|
278
|
+
},
|
|
279
|
+
};
|
|
280
|
+
},
|
|
281
|
+
|
|
282
|
+
mergeCallAndResult: true,
|
|
283
|
+
inline: true,
|
|
284
|
+
};
|
package/src/tools/gh.ts
CHANGED
|
@@ -259,6 +259,7 @@ type GhSearchPrsInput = Static<typeof ghSearchPrsSchema>;
|
|
|
259
259
|
type GhRunWatchInput = Static<typeof ghRunWatchSchema>;
|
|
260
260
|
|
|
261
261
|
export interface GhToolDetails {
|
|
262
|
+
tool?: string;
|
|
262
263
|
meta?: OutputMeta;
|
|
263
264
|
artifactId?: string;
|
|
264
265
|
repo?: string;
|
|
@@ -1939,7 +1940,10 @@ export class GhRepoViewTool implements AgentTool<typeof ghRepoViewSchema, GhTool
|
|
|
1939
1940
|
const data = await git.github.json<GhRepoViewData>(this.session.cwd, args, signal, {
|
|
1940
1941
|
repoProvided: Boolean(repo),
|
|
1941
1942
|
});
|
|
1942
|
-
return buildTextResult(formatRepoView(data, { repo, branch }), data.url
|
|
1943
|
+
return buildTextResult(formatRepoView(data, { repo, branch }), data.url, {
|
|
1944
|
+
tool: "gh_repo_view",
|
|
1945
|
+
repo: repo ?? data.nameWithOwner,
|
|
1946
|
+
});
|
|
1943
1947
|
});
|
|
1944
1948
|
}
|
|
1945
1949
|
}
|
|
@@ -1976,7 +1980,9 @@ export class GhIssueViewTool implements AgentTool<typeof ghIssueViewSchema, GhTo
|
|
|
1976
1980
|
const data = await git.github.json<GhIssueViewData>(this.session.cwd, args, signal, {
|
|
1977
1981
|
repoProvided: Boolean(repo),
|
|
1978
1982
|
});
|
|
1979
|
-
return buildTextResult(formatIssueView(data, { issue, repo, comments: includeComments }), data.url
|
|
1983
|
+
return buildTextResult(formatIssueView(data, { issue, repo, comments: includeComments }), data.url, {
|
|
1984
|
+
tool: "gh_issue_view",
|
|
1985
|
+
});
|
|
1980
1986
|
});
|
|
1981
1987
|
}
|
|
1982
1988
|
}
|
|
@@ -2020,7 +2026,9 @@ export class GhPrViewTool implements AgentTool<typeof ghPrViewSchema, GhToolDeta
|
|
|
2020
2026
|
if (includeComments && resolvedRepo && typeof data.number === "number") {
|
|
2021
2027
|
data.reviewComments = await fetchPrReviewComments(this.session.cwd, resolvedRepo, data.number, signal);
|
|
2022
2028
|
}
|
|
2023
|
-
return buildTextResult(formatPrView(data, { pr, repo, comments: includeComments }), data.url
|
|
2029
|
+
return buildTextResult(formatPrView(data, { pr, repo, comments: includeComments }), data.url, {
|
|
2030
|
+
tool: "gh_pr_view",
|
|
2031
|
+
});
|
|
2024
2032
|
});
|
|
2025
2033
|
}
|
|
2026
2034
|
}
|
|
@@ -2069,7 +2077,7 @@ export class GhPrDiffTool implements AgentTool<typeof ghPrDiffSchema, GhToolDeta
|
|
|
2069
2077
|
});
|
|
2070
2078
|
const title = params.nameOnly ? "# Pull Request Files" : "# Pull Request Diff";
|
|
2071
2079
|
const body = output.length > 0 ? output : params.nameOnly ? "No changed files." : "No diff output.";
|
|
2072
|
-
return buildTextResult(`${title}\n\n${body}
|
|
2080
|
+
return buildTextResult(`${title}\n\n${body}`, undefined, { tool: "gh_pr_diff" });
|
|
2073
2081
|
});
|
|
2074
2082
|
}
|
|
2075
2083
|
}
|
|
@@ -2194,6 +2202,7 @@ export class GhPrCheckoutTool implements AgentTool<typeof ghPrCheckoutSchema, Gh
|
|
|
2194
2202
|
}),
|
|
2195
2203
|
data.url,
|
|
2196
2204
|
{
|
|
2205
|
+
tool: "gh_pr_checkout",
|
|
2197
2206
|
repo: repo ?? data.headRepository?.nameWithOwner,
|
|
2198
2207
|
branch: localBranch,
|
|
2199
2208
|
worktreePath: resolvedWorktreePath,
|
|
@@ -2257,6 +2266,7 @@ export class GhPrPushTool implements AgentTool<typeof ghPrPushSchema, GhToolDeta
|
|
|
2257
2266
|
}),
|
|
2258
2267
|
target.prUrl,
|
|
2259
2268
|
{
|
|
2269
|
+
tool: "gh_pr_push",
|
|
2260
2270
|
branch: localBranch,
|
|
2261
2271
|
remote: target.remoteName,
|
|
2262
2272
|
remoteBranch: target.remoteBranch,
|
|
@@ -2296,7 +2306,9 @@ export class GhSearchIssuesTool implements AgentTool<typeof ghSearchIssuesSchema
|
|
|
2296
2306
|
const items = await git.github.json<GhSearchResult[]>(this.session.cwd, args, signal, {
|
|
2297
2307
|
repoProvided: Boolean(repo),
|
|
2298
2308
|
});
|
|
2299
|
-
return buildTextResult(formatSearchResults("issues", query, repo, items)
|
|
2309
|
+
return buildTextResult(formatSearchResults("issues", query, repo, items), undefined, {
|
|
2310
|
+
tool: "gh_search_issues",
|
|
2311
|
+
});
|
|
2300
2312
|
});
|
|
2301
2313
|
}
|
|
2302
2314
|
}
|
|
@@ -2331,7 +2343,9 @@ export class GhSearchPrsTool implements AgentTool<typeof ghSearchPrsSchema, GhTo
|
|
|
2331
2343
|
const items = await git.github.json<GhSearchResult[]>(this.session.cwd, args, signal, {
|
|
2332
2344
|
repoProvided: Boolean(repo),
|
|
2333
2345
|
});
|
|
2334
|
-
return buildTextResult(formatSearchResults("pull requests", query, repo, items)
|
|
2346
|
+
return buildTextResult(formatSearchResults("pull requests", query, repo, items), undefined, {
|
|
2347
|
+
tool: "gh_search_prs",
|
|
2348
|
+
});
|
|
2335
2349
|
});
|
|
2336
2350
|
}
|
|
2337
2351
|
}
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/** TUI renderer for GitLab tools — rich visual output at full parity with XC-API. */
|
|
2
|
+
import type { Component } from "@f5xc-salesdemos/pi-tui";
|
|
3
|
+
import { Text } from "@f5xc-salesdemos/pi-tui";
|
|
4
|
+
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
|
5
|
+
import type { Theme, ThemeColor } from "../modes/theme/theme";
|
|
6
|
+
import { CachedOutputBlock, F5_TOOL_BORDER_COLOR, renderStatusLine } from "../tui";
|
|
7
|
+
import type { GlabToolDetails } from "./glab";
|
|
8
|
+
import type { GlabIssue } from "./glab/types";
|
|
9
|
+
import { addSection, formatErrorMessage, replaceTabs } from "./render-utils";
|
|
10
|
+
|
|
11
|
+
const TOOL_TITLE = "GitLab";
|
|
12
|
+
const MAX_TITLE_WIDTH = 50;
|
|
13
|
+
const MAX_DESCRIPTION_LINES = 20;
|
|
14
|
+
const MAX_COMMENT_LINES = 40;
|
|
15
|
+
|
|
16
|
+
type GlabRenderArgs = {
|
|
17
|
+
action?: string;
|
|
18
|
+
project?: string;
|
|
19
|
+
issue?: number;
|
|
20
|
+
query?: string;
|
|
21
|
+
state?: string;
|
|
22
|
+
labels?: string[];
|
|
23
|
+
limit?: number;
|
|
24
|
+
search?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
function issueStateColor(state: string): ThemeColor {
|
|
28
|
+
return state === "opened" ? "success" : "dim";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function formatDate(iso: string): string {
|
|
32
|
+
return iso.slice(0, 10);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function truncateTitle(title: string): string {
|
|
36
|
+
if (title.length <= MAX_TITLE_WIDTH) return title;
|
|
37
|
+
return `${title.slice(0, MAX_TITLE_WIDTH - 1)}…`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function truncateLabels(labels: string[], maxLen = 30): string {
|
|
41
|
+
if (!labels.length) return "";
|
|
42
|
+
const joined = labels.join(", ");
|
|
43
|
+
if (joined.length <= maxLen) return joined;
|
|
44
|
+
const truncated = labels.slice(0, 3).join(", ");
|
|
45
|
+
const remaining = labels.length - 3;
|
|
46
|
+
return remaining > 0 ? `${truncated}, +${remaining}` : truncated;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function buildIssueTable(issues: GlabIssue[], uiTheme: Theme): string[] {
|
|
50
|
+
if (issues.length === 0) return [uiTheme.fg("dim", " No issues found.")];
|
|
51
|
+
|
|
52
|
+
return issues.map(issue => {
|
|
53
|
+
const iid = uiTheme.fg("toolOutput", `#${issue.iid}`);
|
|
54
|
+
const title = uiTheme.fg("toolOutput", truncateTitle(issue.title));
|
|
55
|
+
const state = uiTheme.fg(issueStateColor(issue.state), issue.state);
|
|
56
|
+
const labels = issue.labels.length > 0 ? uiTheme.fg("muted", truncateLabels(issue.labels)) : "";
|
|
57
|
+
const assignee =
|
|
58
|
+
issue.assignees.length > 0
|
|
59
|
+
? uiTheme.fg("dim", `@${issue.assignees[0]!.username}`)
|
|
60
|
+
: uiTheme.fg("dim", "unassigned");
|
|
61
|
+
const updated = uiTheme.fg("dim", formatDate(issue.updated_at));
|
|
62
|
+
|
|
63
|
+
const parts = [` ${iid} ${title} ${state}`];
|
|
64
|
+
if (labels) parts.push(labels);
|
|
65
|
+
parts.push(assignee, updated);
|
|
66
|
+
return parts.join(" ");
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function buildIssueDetail(issue: GlabIssue, uiTheme: Theme): Array<{ label?: string; lines: string[] }> {
|
|
71
|
+
const sections: Array<{ label?: string; lines: string[] }> = [];
|
|
72
|
+
const kv = (label: string, value: string, valueColor: ThemeColor = "toolOutput") =>
|
|
73
|
+
` ${uiTheme.fg("dim", label.padEnd(12))}${uiTheme.fg(valueColor, value)}`;
|
|
74
|
+
|
|
75
|
+
const summaryLines: string[] = [];
|
|
76
|
+
summaryLines.push(kv("state:", issue.state, issueStateColor(issue.state)));
|
|
77
|
+
summaryLines.push(kv("author:", `@${issue.author.username}`));
|
|
78
|
+
summaryLines.push(kv("created:", formatDate(issue.created_at)));
|
|
79
|
+
summaryLines.push(kv("updated:", formatDate(issue.updated_at)));
|
|
80
|
+
if (issue.labels.length > 0) summaryLines.push(kv("labels:", issue.labels.join(", ")));
|
|
81
|
+
const assigneeStr =
|
|
82
|
+
issue.assignees.length > 0 ? issue.assignees.map(a => `@${a.username}`).join(", ") : "unassigned";
|
|
83
|
+
summaryLines.push(kv("assignee:", assigneeStr));
|
|
84
|
+
if (issue.milestone) summaryLines.push(kv("milestone:", issue.milestone.title));
|
|
85
|
+
addSection(sections, "Summary", summaryLines, uiTheme);
|
|
86
|
+
|
|
87
|
+
if (issue.description) {
|
|
88
|
+
const descLines = issue.description.split("\n").map(line => replaceTabs(uiTheme.fg("toolOutput", line)));
|
|
89
|
+
addSection(sections, "Description", descLines, uiTheme, MAX_DESCRIPTION_LINES);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const humanNotes = (issue.notes ?? []).filter(n => !n.system);
|
|
93
|
+
if (humanNotes.length > 0) {
|
|
94
|
+
const commentLines: string[] = [];
|
|
95
|
+
for (const note of humanNotes) {
|
|
96
|
+
commentLines.push(
|
|
97
|
+
` ${uiTheme.fg("chromeAccent", `@${note.author.username}`)} ${uiTheme.fg("dim", formatDate(note.created_at))}`,
|
|
98
|
+
);
|
|
99
|
+
for (const line of note.body.split("\n")) {
|
|
100
|
+
commentLines.push(` ${uiTheme.fg("toolOutput", replaceTabs(line))}`);
|
|
101
|
+
}
|
|
102
|
+
commentLines.push("");
|
|
103
|
+
}
|
|
104
|
+
addSection(sections, `Comments (${humanNotes.length})`, commentLines, uiTheme, MAX_COMMENT_LINES);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return sections;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const glabRenderer = {
|
|
111
|
+
renderCall(args: GlabRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
|
|
112
|
+
let description: string;
|
|
113
|
+
if (args.query) {
|
|
114
|
+
description = uiTheme.fg("muted", `search: ${args.query}`);
|
|
115
|
+
} else if (args.issue !== undefined) {
|
|
116
|
+
description = uiTheme.fg("muted", `#${args.issue}`);
|
|
117
|
+
} else if (args.action) {
|
|
118
|
+
description = uiTheme.fg("muted", args.action);
|
|
119
|
+
} else if (args.search) {
|
|
120
|
+
description = uiTheme.fg("muted", `issues: ${args.search}`);
|
|
121
|
+
} else {
|
|
122
|
+
description = uiTheme.fg("muted", "issues");
|
|
123
|
+
}
|
|
124
|
+
const text = renderStatusLine({ icon: "pending", title: TOOL_TITLE, description }, uiTheme);
|
|
125
|
+
return new Text(text, 0, 0);
|
|
126
|
+
},
|
|
127
|
+
|
|
128
|
+
renderResult(
|
|
129
|
+
result: { content: Array<{ type: string; text?: string }>; details?: GlabToolDetails; isError?: boolean },
|
|
130
|
+
options: RenderResultOptions,
|
|
131
|
+
uiTheme: Theme,
|
|
132
|
+
args?: GlabRenderArgs,
|
|
133
|
+
): Component {
|
|
134
|
+
const details = result.details;
|
|
135
|
+
const isError = result.isError === true;
|
|
136
|
+
|
|
137
|
+
if (isError && !details) {
|
|
138
|
+
const errorText = result.content?.find(c => c.type === "text")?.text;
|
|
139
|
+
return new Text(formatErrorMessage(errorText, uiTheme), 0, 0);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const tool = details?.tool;
|
|
143
|
+
const sections: Array<{ label?: string; lines: string[] }> = [];
|
|
144
|
+
const meta: string[] = [];
|
|
145
|
+
let description: string | undefined;
|
|
146
|
+
let badgeLabel: string | undefined;
|
|
147
|
+
let badgeColor: ThemeColor = "muted";
|
|
148
|
+
|
|
149
|
+
if (isError) {
|
|
150
|
+
const errorText = result.content?.find(c => c.type === "text")?.text ?? "Unknown error";
|
|
151
|
+
addSection(sections, "Error", [uiTheme.fg("error", errorText)], uiTheme);
|
|
152
|
+
const header = renderStatusLine(
|
|
153
|
+
{ title: TOOL_TITLE, titleColor: "accent", badge: { label: "error", color: "error" } },
|
|
154
|
+
uiTheme,
|
|
155
|
+
);
|
|
156
|
+
const outputBlock = new CachedOutputBlock();
|
|
157
|
+
return {
|
|
158
|
+
render(width: number): string[] {
|
|
159
|
+
return outputBlock.render({ header, state: "error", sections, width }, uiTheme);
|
|
160
|
+
},
|
|
161
|
+
invalidate() {
|
|
162
|
+
outputBlock.invalidate();
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
if (tool === "glab_search" || tool === "glab_issue_list") {
|
|
168
|
+
const items = details?.items ?? [];
|
|
169
|
+
const count = details?.total ?? items.length;
|
|
170
|
+
description = details?.query ? `search: ${details.query}` : "issues";
|
|
171
|
+
meta.push(uiTheme.fg("dim", `${count} issue${count !== 1 ? "s" : ""}`));
|
|
172
|
+
if (details?.project) meta.push(uiTheme.fg("muted", details.project));
|
|
173
|
+
addSection(sections, "Results", buildIssueTable(items, uiTheme), uiTheme);
|
|
174
|
+
} else if (tool === "glab_issue_view") {
|
|
175
|
+
const issue = details?.issue;
|
|
176
|
+
if (issue) {
|
|
177
|
+
description = `#${issue.iid}: ${truncateTitle(issue.title)}`;
|
|
178
|
+
meta.push(uiTheme.fg(issueStateColor(issue.state), issue.state));
|
|
179
|
+
if (details?.project) meta.push(uiTheme.fg("muted", details.project));
|
|
180
|
+
sections.push(...buildIssueDetail(issue, uiTheme));
|
|
181
|
+
} else {
|
|
182
|
+
const text = result.content?.find(c => c.type === "text")?.text ?? "";
|
|
183
|
+
addSection(
|
|
184
|
+
sections,
|
|
185
|
+
"Result",
|
|
186
|
+
text.split("\n").map(line => replaceTabs(uiTheme.fg("toolOutput", line))),
|
|
187
|
+
uiTheme,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
} else if (tool === "glab_setup") {
|
|
191
|
+
badgeLabel = args?.action ?? "setup";
|
|
192
|
+
badgeColor = "chromeAccent";
|
|
193
|
+
const text = result.content?.find(c => c.type === "text")?.text ?? "";
|
|
194
|
+
addSection(
|
|
195
|
+
sections,
|
|
196
|
+
"Result",
|
|
197
|
+
text.split("\n").map(line => replaceTabs(uiTheme.fg("toolOutput", line))),
|
|
198
|
+
uiTheme,
|
|
199
|
+
);
|
|
200
|
+
} else {
|
|
201
|
+
const text = result.content?.find(c => c.type === "text")?.text ?? "";
|
|
202
|
+
addSection(
|
|
203
|
+
sections,
|
|
204
|
+
"Result",
|
|
205
|
+
text.split("\n").map(line => replaceTabs(uiTheme.fg("toolOutput", line))),
|
|
206
|
+
uiTheme,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const header = description
|
|
211
|
+
? renderStatusLine(
|
|
212
|
+
{
|
|
213
|
+
title: TOOL_TITLE,
|
|
214
|
+
titleColor: "accent",
|
|
215
|
+
description,
|
|
216
|
+
meta: meta.length > 0 ? meta : undefined,
|
|
217
|
+
},
|
|
218
|
+
uiTheme,
|
|
219
|
+
)
|
|
220
|
+
: renderStatusLine(
|
|
221
|
+
{
|
|
222
|
+
title: TOOL_TITLE,
|
|
223
|
+
titleColor: "accent",
|
|
224
|
+
badge: badgeLabel ? { label: badgeLabel, color: badgeColor } : undefined,
|
|
225
|
+
meta: meta.length > 0 ? meta : undefined,
|
|
226
|
+
},
|
|
227
|
+
uiTheme,
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
const outputBlock = new CachedOutputBlock();
|
|
231
|
+
return {
|
|
232
|
+
render(width: number): string[] {
|
|
233
|
+
const state = options.isPartial ? "pending" : "success";
|
|
234
|
+
return outputBlock.render({ header, state, sections, width, borderColor: F5_TOOL_BORDER_COLOR }, uiTheme);
|
|
235
|
+
},
|
|
236
|
+
invalidate() {
|
|
237
|
+
outputBlock.invalidate();
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
},
|
|
241
|
+
|
|
242
|
+
mergeCallAndResult: true,
|
|
243
|
+
inline: true,
|
|
244
|
+
};
|
package/src/tools/glab.ts
CHANGED
|
@@ -113,7 +113,8 @@ type GlabIssueListInput = Static<typeof glabIssueListSchema>;
|
|
|
113
113
|
type GlabIssueViewInput = Static<typeof glabIssueViewSchema>;
|
|
114
114
|
type GlabSearchInput = Static<typeof glabSearchSchema>;
|
|
115
115
|
|
|
116
|
-
interface GlabToolDetails {
|
|
116
|
+
export interface GlabToolDetails {
|
|
117
|
+
tool?: "glab_setup" | "glab_issue_list" | "glab_issue_view" | "glab_search";
|
|
117
118
|
items?: GlabIssue[];
|
|
118
119
|
issue?: GlabIssue;
|
|
119
120
|
projects?: GlabProject[];
|
|
@@ -195,7 +196,7 @@ export class GlabSetupTool implements AgentTool<typeof glabSetupSchema, GlabTool
|
|
|
195
196
|
.join("\n");
|
|
196
197
|
return textResult(
|
|
197
198
|
`Found ${projects.length} projects:\n\n${list}\n\nWhich project do you want to use for GitLab issue tracking? Reply with the number or full path.`,
|
|
198
|
-
{ projects },
|
|
199
|
+
{ tool: "glab_setup", projects },
|
|
199
200
|
);
|
|
200
201
|
}
|
|
201
202
|
|
|
@@ -260,7 +261,12 @@ export class GlabIssueListTool implements AgentTool<typeof glabIssueListSchema,
|
|
|
260
261
|
|
|
261
262
|
try {
|
|
262
263
|
const issues = await execGlabJson<GlabIssue[]>(api, args, signal);
|
|
263
|
-
return textResult(formatIssueTable(issues), {
|
|
264
|
+
return textResult(formatIssueTable(issues), {
|
|
265
|
+
tool: "glab_issue_list",
|
|
266
|
+
items: issues,
|
|
267
|
+
total: issues.length,
|
|
268
|
+
project,
|
|
269
|
+
});
|
|
264
270
|
} catch (err) {
|
|
265
271
|
if (err instanceof GlabAuthError) return textResult((err as Error).message);
|
|
266
272
|
throw err;
|
|
@@ -302,7 +308,7 @@ export class GlabIssueViewTool implements AgentTool<typeof glabIssueViewSchema,
|
|
|
302
308
|
|
|
303
309
|
try {
|
|
304
310
|
const issue = await execGlabJson<GlabIssue>(api, args, signal);
|
|
305
|
-
return textResult(formatIssueDetail(issue), { issue, project });
|
|
311
|
+
return textResult(formatIssueDetail(issue), { tool: "glab_issue_view", issue, project });
|
|
306
312
|
} catch (err) {
|
|
307
313
|
if (err instanceof GlabAuthError) return textResult((err as Error).message);
|
|
308
314
|
throw err;
|
|
@@ -401,6 +407,7 @@ export class GlabSearchTool implements AgentTool<typeof glabSearchSchema, GlabTo
|
|
|
401
407
|
}
|
|
402
408
|
|
|
403
409
|
return textResult(formatIssueTable(issues), {
|
|
410
|
+
tool: "glab_search",
|
|
404
411
|
items: issues,
|
|
405
412
|
total: issues.length,
|
|
406
413
|
project,
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/** TUI renderer for the render_mermaid tool — bordered ASCII diagram output. */
|
|
2
|
+
import type { Component } from "@f5xc-salesdemos/pi-tui";
|
|
3
|
+
import { Text } from "@f5xc-salesdemos/pi-tui";
|
|
4
|
+
import type { RenderResultOptions } from "../extensibility/custom-tools/types";
|
|
5
|
+
import type { Theme } from "../modes/theme/theme";
|
|
6
|
+
import { CachedOutputBlock, F5_TOOL_BORDER_COLOR, renderStatusLine } from "../tui";
|
|
7
|
+
import type { RenderMermaidToolDetails } from "./render-mermaid";
|
|
8
|
+
import { addSection, formatErrorMessage, replaceTabs } from "./render-utils";
|
|
9
|
+
|
|
10
|
+
const TOOL_TITLE = "Mermaid";
|
|
11
|
+
const MAX_DIAGRAM_LINES = 40;
|
|
12
|
+
|
|
13
|
+
type MermaidRenderArgs = {
|
|
14
|
+
mermaid?: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const mermaidRenderer = {
|
|
18
|
+
renderCall(_args: MermaidRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
|
|
19
|
+
const text = renderStatusLine(
|
|
20
|
+
{ icon: "pending", title: TOOL_TITLE, description: uiTheme.fg("muted", "rendering diagram") },
|
|
21
|
+
uiTheme,
|
|
22
|
+
);
|
|
23
|
+
return new Text(text, 0, 0);
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
renderResult(
|
|
27
|
+
result: {
|
|
28
|
+
content: Array<{ type: string; text?: string }>;
|
|
29
|
+
details?: RenderMermaidToolDetails;
|
|
30
|
+
isError?: boolean;
|
|
31
|
+
},
|
|
32
|
+
options: RenderResultOptions,
|
|
33
|
+
uiTheme: Theme,
|
|
34
|
+
_args?: MermaidRenderArgs,
|
|
35
|
+
): Component {
|
|
36
|
+
const isError = result.isError === true;
|
|
37
|
+
|
|
38
|
+
if (isError) {
|
|
39
|
+
const errorText = result.content?.find(c => c.type === "text")?.text;
|
|
40
|
+
return new Text(formatErrorMessage(errorText, uiTheme), 0, 0);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const sections: Array<{ label?: string; lines: string[] }> = [];
|
|
44
|
+
const meta: string[] = [];
|
|
45
|
+
const rawText = result.content?.find(c => c.type === "text")?.text ?? "";
|
|
46
|
+
|
|
47
|
+
// Strip artifact reference from display
|
|
48
|
+
const artifactIdx = rawText.indexOf("\n\nSaved artifact:");
|
|
49
|
+
const diagramText = artifactIdx >= 0 ? rawText.slice(0, artifactIdx) : rawText;
|
|
50
|
+
|
|
51
|
+
const diagramLines = diagramText.split("\n").map(line => replaceTabs(uiTheme.fg("toolOutput", line)));
|
|
52
|
+
addSection(sections, "Diagram", diagramLines, uiTheme, MAX_DIAGRAM_LINES);
|
|
53
|
+
|
|
54
|
+
if (result.details?.artifactId) {
|
|
55
|
+
meta.push(uiTheme.fg("dim", `artifact:${result.details.artifactId.slice(0, 8)}`));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const header = renderStatusLine(
|
|
59
|
+
{
|
|
60
|
+
title: TOOL_TITLE,
|
|
61
|
+
titleColor: "dim",
|
|
62
|
+
description: uiTheme.fg("muted", "diagram"),
|
|
63
|
+
meta: meta.length > 0 ? meta : undefined,
|
|
64
|
+
},
|
|
65
|
+
uiTheme,
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const outputBlock = new CachedOutputBlock();
|
|
69
|
+
return {
|
|
70
|
+
render(width: number): string[] {
|
|
71
|
+
const state = options.isPartial ? "pending" : "success";
|
|
72
|
+
return outputBlock.render({ header, state, sections, width, borderColor: F5_TOOL_BORDER_COLOR }, uiTheme);
|
|
73
|
+
},
|
|
74
|
+
invalidate() {
|
|
75
|
+
outputBlock.invalidate();
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
mergeCallAndResult: true,
|
|
81
|
+
inline: true,
|
|
82
|
+
};
|
package/src/tools/renderers.ts
CHANGED
|
@@ -10,16 +10,21 @@ import { lspToolRenderer } from "../lsp/render";
|
|
|
10
10
|
import type { Theme } from "../modes/theme/theme";
|
|
11
11
|
import { taskToolRenderer } from "../task/render";
|
|
12
12
|
import { webSearchToolRenderer } from "../web/search/render";
|
|
13
|
+
import { actionRenderer } from "./action-renderer";
|
|
13
14
|
import { askToolRenderer } from "./ask";
|
|
14
15
|
import { astEditToolRenderer } from "./ast-edit";
|
|
15
16
|
import { astGrepToolRenderer } from "./ast-grep";
|
|
16
17
|
import { bashToolRenderer } from "./bash";
|
|
18
|
+
import { browserRenderer } from "./browser-renderer";
|
|
17
19
|
import { calculatorToolRenderer } from "./calculator";
|
|
18
20
|
import { debugToolRenderer } from "./debug";
|
|
19
21
|
import { findToolRenderer } from "./find";
|
|
20
22
|
import { ghRunWatchToolRenderer } from "./gh-renderer";
|
|
23
|
+
import { ghToolsRenderer } from "./gh-tools-renderer";
|
|
24
|
+
import { glabRenderer } from "./glab-renderer";
|
|
21
25
|
import { grepToolRenderer } from "./grep";
|
|
22
26
|
import { inspectImageToolRenderer } from "./inspect-image-renderer";
|
|
27
|
+
import { mermaidRenderer } from "./mermaid-renderer";
|
|
23
28
|
import { notebookToolRenderer } from "./notebook";
|
|
24
29
|
import { pythonToolRenderer } from "./python";
|
|
25
30
|
import { resolveToolRenderer } from "./resolve";
|
|
@@ -68,6 +73,24 @@ export const toolRenderers: Record<string, ToolRenderer> = {
|
|
|
68
73
|
task: taskToolRenderer as ToolRenderer,
|
|
69
74
|
todo_write: todoWriteToolRenderer as ToolRenderer,
|
|
70
75
|
gh_run_watch: ghRunWatchToolRenderer as ToolRenderer,
|
|
76
|
+
gh_repo_view: ghToolsRenderer as ToolRenderer,
|
|
77
|
+
gh_issue_view: ghToolsRenderer as ToolRenderer,
|
|
78
|
+
gh_pr_view: ghToolsRenderer as ToolRenderer,
|
|
79
|
+
gh_pr_diff: ghToolsRenderer as ToolRenderer,
|
|
80
|
+
gh_pr_checkout: ghToolsRenderer as ToolRenderer,
|
|
81
|
+
gh_pr_push: ghToolsRenderer as ToolRenderer,
|
|
82
|
+
gh_search_issues: ghToolsRenderer as ToolRenderer,
|
|
83
|
+
gh_search_prs: ghToolsRenderer as ToolRenderer,
|
|
84
|
+
glab_setup: glabRenderer as ToolRenderer,
|
|
85
|
+
glab_issue_list: glabRenderer as ToolRenderer,
|
|
86
|
+
glab_issue_view: glabRenderer as ToolRenderer,
|
|
87
|
+
glab_search: glabRenderer as ToolRenderer,
|
|
88
|
+
checkpoint: actionRenderer as ToolRenderer,
|
|
89
|
+
rewind: actionRenderer as ToolRenderer,
|
|
90
|
+
cancel_job: actionRenderer as ToolRenderer,
|
|
91
|
+
poll: actionRenderer as ToolRenderer,
|
|
92
|
+
puppeteer: browserRenderer as ToolRenderer,
|
|
93
|
+
render_mermaid: mermaidRenderer as ToolRenderer,
|
|
71
94
|
web_search: webSearchToolRenderer as ToolRenderer,
|
|
72
95
|
write: writeToolRenderer as ToolRenderer,
|
|
73
96
|
xcsh_api: xcshApiToolRenderer as ToolRenderer,
|
|
@@ -83,9 +83,11 @@ function buildResourceSummary(
|
|
|
83
83
|
* in xcsh-api.ts), so splitting on `\n\n` is reliable.
|
|
84
84
|
*/
|
|
85
85
|
function splitResultContent(textContent: string, isError: boolean): { json?: string; guidance?: string; raw: string } {
|
|
86
|
-
// Strip status line prefix (e.g. "200 OK\n\n")
|
|
86
|
+
// Strip status line prefix (e.g. "200 OK\n\n") only when the first line looks like an HTTP status
|
|
87
87
|
const bodyStart = textContent.indexOf("\n\n");
|
|
88
|
-
const
|
|
88
|
+
const firstLine = textContent.slice(0, bodyStart >= 0 ? bodyStart : textContent.length).trim();
|
|
89
|
+
const isStatusLine = /^\d{3}(\s|$)/.test(firstLine);
|
|
90
|
+
const body = isStatusLine && bodyStart >= 0 ? textContent.slice(bodyStart + 2) : textContent;
|
|
89
91
|
|
|
90
92
|
if (!isError) {
|
|
91
93
|
const pretty = tryPrettyJson(body);
|
package/src/tools/xcsh-api.ts
CHANGED
|
@@ -58,7 +58,9 @@ const xcshApiSchema = Type.Object({
|
|
|
58
58
|
[Type.Literal("GET"), Type.Literal("POST"), Type.Literal("PUT"), Type.Literal("PATCH"), Type.Literal("DELETE")],
|
|
59
59
|
{ description: "HTTP method" },
|
|
60
60
|
),
|
|
61
|
-
path: Type.
|
|
61
|
+
path: Type.Optional(
|
|
62
|
+
Type.String({ description: "API path, e.g. /api/config/namespaces/{namespace}/http_loadbalancers" }),
|
|
63
|
+
),
|
|
62
64
|
paths: Type.Optional(
|
|
63
65
|
Type.Array(Type.String(), {
|
|
64
66
|
description:
|
|
@@ -663,7 +665,8 @@ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolD
|
|
|
663
665
|
const batchPaths = params.paths?.filter(p => p.trim().length > 0);
|
|
664
666
|
if (batchPaths && batchPaths.length > 0) {
|
|
665
667
|
// Wildcard "*" auto-discovers all namespace-scoped list paths from the catalog
|
|
666
|
-
const
|
|
668
|
+
const isWildcard = batchPaths.length === 1 && batchPaths[0] === "*";
|
|
669
|
+
const resolved = isWildcard ? this.#loadListablePaths() : batchPaths;
|
|
667
670
|
if (resolved.length > 0) {
|
|
668
671
|
const batchNs = params.params?.namespace ?? this.#contextEnv.get("F5XC_NAMESPACE") ?? "";
|
|
669
672
|
// Wildcard namespace: batch ALL non-system namespaces in one tool call.
|
|
@@ -674,6 +677,16 @@ export class XcshApiTool implements AgentTool<typeof xcshApiSchema, XcshApiToolD
|
|
|
674
677
|
if (batchNs) this.#expandedNamespaces.add(batchNs);
|
|
675
678
|
return this.#executeBatch(resolved, params.params, apiBase, apiToken, signal);
|
|
676
679
|
}
|
|
680
|
+
if (isWildcard) {
|
|
681
|
+
return this.#errorResult(
|
|
682
|
+
"Error: Wildcard namespace discovery found no listable API paths. The API catalog may not be loaded.",
|
|
683
|
+
);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
if (!params.path) {
|
|
687
|
+
return this.#errorResult(
|
|
688
|
+
'Error: `path` is required for single-resource operations. Use `paths: ["*"]` for namespace discovery.',
|
|
689
|
+
);
|
|
677
690
|
}
|
|
678
691
|
// Per-namespace auto-expand: when the model GETs a namespace list endpoint,
|
|
679
692
|
// batch ALL types for that namespace on first access. Each namespace expands once.
|