@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@f5xc-salesdemos/xcsh",
4
- "version": "18.82.0",
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.82.0",
53
- "@f5xc-salesdemos/pi-agent-core": "18.82.0",
54
- "@f5xc-salesdemos/pi-ai": "18.82.0",
55
- "@f5xc-salesdemos/pi-natives": "18.82.0",
56
- "@f5xc-salesdemos/pi-tui": "18.82.0",
57
- "@f5xc-salesdemos/pi-utils": "18.82.0",
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.82.0",
21
- "commit": "25dbb133a016e072790449ae4338a3b963723ca4",
22
- "shortCommit": "25dbb13",
20
+ "version": "18.83.1",
21
+ "commit": "255a105f19a8485d74487ef9914154fdd6ad5bfd",
22
+ "shortCommit": "255a105",
23
23
  "branch": "main",
24
- "tag": "v18.82.0",
25
- "commitDate": "2026-05-25T23:16:56Z",
26
- "buildDate": "2026-05-25T23:41:19.829Z",
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/25dbb133a016e072790449ae4338a3b963723ca4",
32
- "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.82.0"
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
- // Insert placeholder at cursor like Claude does
613
+
603
614
  const imageNum = this.ctx.pendingImages.length;
604
- const placeholder = `[Image #${imageNum}]`;
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), { items: issues, total: issues.length, project });
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
+ };
@@ -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 body = bodyStart >= 0 ? textContent.slice(bodyStart + 2) : textContent;
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);
@@ -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.String({ description: "API path, e.g. /api/config/namespaces/{namespace}/http_loadbalancers" }),
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 resolved = batchPaths.length === 1 && batchPaths[0] === "*" ? this.#loadListablePaths() : batchPaths;
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.