@f5xc-salesdemos/xcsh 18.86.1 → 18.87.0

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.86.1",
4
+ "version": "18.87.0",
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",
@@ -50,12 +50,12 @@
50
50
  "dependencies": {
51
51
  "@agentclientprotocol/sdk": "0.16.1",
52
52
  "@mozilla/readability": "^0.6",
53
- "@f5xc-salesdemos/xcsh-stats": "18.86.1",
54
- "@f5xc-salesdemos/pi-agent-core": "18.86.1",
55
- "@f5xc-salesdemos/pi-ai": "18.86.1",
56
- "@f5xc-salesdemos/pi-natives": "18.86.1",
57
- "@f5xc-salesdemos/pi-tui": "18.86.1",
58
- "@f5xc-salesdemos/pi-utils": "18.86.1",
53
+ "@f5xc-salesdemos/xcsh-stats": "18.87.0",
54
+ "@f5xc-salesdemos/pi-agent-core": "18.87.0",
55
+ "@f5xc-salesdemos/pi-ai": "18.87.0",
56
+ "@f5xc-salesdemos/pi-natives": "18.87.0",
57
+ "@f5xc-salesdemos/pi-tui": "18.87.0",
58
+ "@f5xc-salesdemos/pi-utils": "18.87.0",
59
59
  "@sinclair/typebox": "^0.34",
60
60
  "@xterm/headless": "^6.0",
61
61
  "ajv": "^8.18",
@@ -17,17 +17,17 @@ export interface BuildInfo {
17
17
  }
18
18
 
19
19
  export const BUILD_INFO: BuildInfo = {
20
- "version": "18.86.1",
21
- "commit": "e6c9853663bf3961d2e4d4ea6fd443d1b1c52e57",
22
- "shortCommit": "e6c9853",
20
+ "version": "18.87.0",
21
+ "commit": "501462242a4bc87a7ec923eac5f796e2d7791069",
22
+ "shortCommit": "5014622",
23
23
  "branch": "main",
24
- "tag": "v18.86.1",
25
- "commitDate": "2026-05-28T23:13:26Z",
26
- "buildDate": "2026-05-28T23:35:41.443Z",
24
+ "tag": "v18.87.0",
25
+ "commitDate": "2026-05-29T05:57:38Z",
26
+ "buildDate": "2026-05-29T06:18:32.519Z",
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/e6c9853663bf3961d2e4d4ea6fd443d1b1c52e57",
32
- "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.86.1"
31
+ "commitUrl": "https://github.com/f5xc-salesdemos/xcsh/commit/501462242a4bc87a7ec923eac5f796e2d7791069",
32
+ "releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.87.0"
33
33
  };
@@ -3,10 +3,16 @@
3
3
  */
4
4
 
5
5
  import { sanitizeText } from "@f5xc-salesdemos/pi-natives";
6
- import { Container, ImageProtocol, Loader, Spacer, TERMINAL, Text, type TUI } from "@f5xc-salesdemos/pi-tui";
6
+ import { Container, Image, Loader, Spacer, Text, type TUI } from "@f5xc-salesdemos/pi-tui";
7
7
  import { getSymbolTheme, highlightCode, theme } from "../../modes/theme/theme";
8
8
  import { formatTruncationMetaNotice, type TruncationMeta } from "../../tools/output-meta";
9
- import { getSixelLineMask, isSixelPassthroughEnabled, sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
9
+ import { resolveImageOptions } from "../../tools/render-utils";
10
+ import {
11
+ extractITerm2ImageData,
12
+ getImageLineMask,
13
+ isImagePassthroughEnabled,
14
+ sanitizeWithImagePassthrough,
15
+ } from "../../utils/image-passthrough";
10
16
  import { sanitizeErrorMessage } from "../utils/sanitize-error-message";
11
17
  import { DynamicBorder } from "./dynamic-border";
12
18
  import { truncateToVisualLines } from "./visual-truncate";
@@ -59,6 +65,7 @@ export class BashExecutionComponent extends Container {
59
65
  #chunkGate = false;
60
66
  #contentContainer: Container;
61
67
  #headerText: Text;
68
+ #imageComponent?: Image;
62
69
 
63
70
  constructor(
64
71
  private readonly command: string,
@@ -202,11 +209,8 @@ export class BashExecutionComponent extends Container {
202
209
  // Apply preview truncation based on expanded state
203
210
  const previewLogicalLines = availableLines.slice(-PREVIEW_LINES);
204
211
  const hiddenLineCount = availableLines.length - previewLogicalLines.length;
205
- const sixelLineMask =
206
- TERMINAL.imageProtocol === ImageProtocol.Sixel && isSixelPassthroughEnabled()
207
- ? getSixelLineMask(availableLines)
208
- : undefined;
209
- const hasSixelOutput = sixelLineMask?.some(Boolean) ?? false;
212
+ const imageLineMask = isImagePassthroughEnabled() ? getImageLineMask(availableLines) : undefined;
213
+ const hasImageOutput = imageLineMask?.some(Boolean) ?? false;
210
214
 
211
215
  // Rebuild content container
212
216
  this.#contentContainer.clear();
@@ -214,16 +218,22 @@ export class BashExecutionComponent extends Container {
214
218
  // Command header
215
219
  this.#contentContainer.addChild(this.#headerText);
216
220
 
217
- // Output
221
+ // Render extracted image via the Image component (proper height calculation)
222
+ if (this.#imageComponent && this.#status !== "running") {
223
+ this.#contentContainer.addChild(new Spacer(1));
224
+ this.#contentContainer.addChild(this.#imageComponent);
225
+ }
226
+
227
+ // Output (text lines, excluding image lines which are handled above)
218
228
  if (availableLines.length > 0) {
219
229
  // Try to syntax-highlight structured output (e.g. JSON)
220
- const highlightedLines = hasSixelOutput ? undefined : highlightIfStructured(availableLines);
230
+ const highlightedLines = hasImageOutput ? undefined : highlightIfStructured(availableLines);
221
231
 
222
- if (this.#expanded || hasSixelOutput) {
232
+ if (this.#expanded || hasImageOutput) {
223
233
  const displayText = highlightedLines
224
234
  ? highlightedLines.join("\n")
225
235
  : availableLines
226
- .map((line, index) => (sixelLineMask?.[index] ? line : theme.fg("muted", line)))
236
+ .map((line, index) => (imageLineMask?.[index] ? line : theme.fg("muted", line)))
227
237
  .join("\n");
228
238
  this.#contentContainer.addChild(new Text(`\n${displayText}`, 1, 0));
229
239
  } else {
@@ -249,7 +259,7 @@ export class BashExecutionComponent extends Container {
249
259
  const statusParts: string[] = [];
250
260
 
251
261
  // Show how many lines are hidden (collapsed preview)
252
- if (hiddenLineCount > 0 && !hasSixelOutput) {
262
+ if (hiddenLineCount > 0 && !hasImageOutput) {
253
263
  statusParts.push(theme.fg("dim", `… ${hiddenLineCount} more lines (ctrl+o to expand)`));
254
264
  }
255
265
 
@@ -285,16 +295,38 @@ export class BashExecutionComponent extends Container {
285
295
 
286
296
  #clampLinesPreservingSixel(lines: string[]): string[] {
287
297
  if (lines.length === 0) return [];
288
- const sixelLineMask = getSixelLineMask(lines);
289
- if (!sixelLineMask.some(Boolean)) {
298
+ const imageLineMask = getImageLineMask(lines);
299
+ if (!imageLineMask.some(Boolean)) {
290
300
  return lines.map(line => this.#clampDisplayLine(line));
291
301
  }
292
- return lines.map((line, index) => (sixelLineMask[index] ? line : this.#clampDisplayLine(line)));
302
+ return lines.map((line, index) => (imageLineMask[index] ? line : this.#clampDisplayLine(line)));
293
303
  }
294
304
 
295
305
  #setOutput(output: string): void {
296
- const clean = sanitizeWithOptionalSixelPassthrough(output, sanitizeText);
306
+ const clean = sanitizeWithImagePassthrough(output, sanitizeText);
297
307
  this.#outputLines = clean ? this.#clampLinesPreservingSixel(clean.split("\n")) : [];
308
+
309
+ // If the output contains iTerm2 image data, extract it and create an
310
+ // Image component for proper height calculation instead of raw passthrough.
311
+ this.#imageComponent = undefined;
312
+ if (isImagePassthroughEnabled()) {
313
+ const imageData = extractITerm2ImageData(output);
314
+ if (imageData) {
315
+ this.#imageComponent = new Image(
316
+ imageData.base64,
317
+ imageData.mimeType,
318
+ { fallbackColor: (s: string) => theme.fg("muted", s) },
319
+ resolveImageOptions(),
320
+ );
321
+ // Strip image lines and trailing empty lines so they don't
322
+ // render as extra whitespace below the image
323
+ const mask = getImageLineMask(this.#outputLines);
324
+ this.#outputLines = this.#outputLines.filter((_, i) => !mask[i]);
325
+ while (this.#outputLines.length > 0 && this.#outputLines[this.#outputLines.length - 1] === "") {
326
+ this.#outputLines.pop();
327
+ }
328
+ }
329
+ }
298
330
  }
299
331
 
300
332
  /**
@@ -34,7 +34,7 @@ import { formatExpandHint, replaceTabs, resolveImageOptions, truncateToWidth } f
34
34
  import { toolRenderers } from "../../tools/renderers";
35
35
  import { renderStatusLine } from "../../tui";
36
36
  import { convertToPng } from "../../utils/image-convert";
37
- import { sanitizeWithOptionalSixelPassthrough } from "../../utils/sixel";
37
+ import { sanitizeWithImagePassthrough } from "../../utils/image-passthrough";
38
38
  import { renderDiff } from "./diff";
39
39
 
40
40
  function ensureInvalidate(component: unknown): Component {
@@ -676,7 +676,7 @@ export class ToolExecutionComponent extends Container {
676
676
 
677
677
  let output = textBlocks
678
678
  .map((c: any) => {
679
- return sanitizeWithOptionalSixelPassthrough(c.text || "", sanitizeText);
679
+ return sanitizeWithImagePassthrough(c.text || "", sanitizeText);
680
680
  })
681
681
  .join("\n");
682
682
 
@@ -0,0 +1,23 @@
1
+ Displays an image inline in the terminal conversation. Shows the image visually if the terminal supports it, or opens it in the system image viewer as a fallback.
2
+
3
+ <instruction>
4
+ - Use this when you want to show an image to the user (screenshot, diagram, generated image, photo)
5
+ - Use `inspect_image` instead when you need to analyze or extract information from an image
6
+ - Provide `path` to the local image file (absolute or relative to working directory)
7
+ - Optional `caption` adds descriptive text below the image
8
+ </instruction>
9
+
10
+ <examples>
11
+ - Show a screenshot:
12
+ - `{"path":"screenshots/dashboard.png","caption":"Current dashboard layout"}`
13
+ - Display a generated diagram:
14
+ - `{"path":"output/architecture.png"}`
15
+ - Show a photo with context:
16
+ - `{"path":"assets/logo.png","caption":"Current brand logo"}`
17
+ </examples>
18
+
19
+ <output>
20
+ - Returns the image as an inline image block when the terminal supports image protocols
21
+ - Falls back to opening the image in the system image viewer (Preview.app, xdg-open)
22
+ - Supports PNG, JPEG, GIF, and WEBP formats
23
+ </output>
@@ -21,8 +21,8 @@ Inspects an image file with a vision-capable model and returns compact text anal
21
21
  </examples>
22
22
 
23
23
  <output>
24
- - Returns text-only analysis from the vision model
25
- - No image content blocks are returned in tool output
24
+ - Returns the image inline (when the terminal supports image protocols) alongside the text analysis
25
+ - The image is displayed first, followed by the vision model's text analysis
26
26
  </output>
27
27
 
28
28
  <critical>
@@ -1,6 +1,6 @@
1
1
  import { sanitizeText } from "@f5xc-salesdemos/pi-natives";
2
2
  import { formatBytes } from "../tools/render-utils";
3
- import { sanitizeWithOptionalSixelPassthrough } from "../utils/sixel";
3
+ import { sanitizeWithImagePassthrough } from "../utils/image-passthrough";
4
4
 
5
5
  // =============================================================================
6
6
  // Constants
@@ -565,9 +565,14 @@ export class OutputSink {
565
565
  /**
566
566
  * Push a chunk of output. The buffer management and onChunk callback run
567
567
  * synchronously. File sink writes are deferred and serialized internally.
568
+ *
569
+ * Raw chunks are accumulated unsanitized. Sanitization happens once in
570
+ * dump() over the complete buffer so that image protocol escape sequences
571
+ * split across multiple small chunks (a shell artefact) are preserved.
572
+ * The onChunk streaming callback also receives raw data; setComplete()
573
+ * replaces the streaming display with the sanitized final output.
568
574
  */
569
575
  push(chunk: string): void {
570
- chunk = sanitizeWithOptionalSixelPassthrough(chunk, sanitizeText);
571
576
  if (this.#maskSecrets) chunk = this.#maskSecrets(chunk);
572
577
 
573
578
  // Throttled onChunk: only call the callback when enough time has passed.
@@ -693,9 +698,10 @@ export class OutputSink {
693
698
 
694
699
  if (this.#file) await this.#file.sink.end();
695
700
 
696
- // Safety net: re-mask the concatenated buffer to catch secret values
697
- // that were split across chunk boundaries during streaming.
698
- const raw = `${noticeLine}${this.#buffer}`;
701
+ // Sanitize the complete buffer in one pass so image protocol escape
702
+ // sequences that were split across chunks are correctly detected.
703
+ const sanitized = sanitizeWithImagePassthrough(this.#buffer, sanitizeText);
704
+ const raw = `${noticeLine}${sanitized}`;
699
705
  const output = this.#maskSecrets ? this.#maskSecrets(raw) : raw;
700
706
 
701
707
  return {
@@ -15,7 +15,7 @@ import xterm from "@xterm/headless";
15
15
  import { NON_INTERACTIVE_ENV } from "../exec/non-interactive-env";
16
16
  import type { Theme } from "../modes/theme/theme";
17
17
  import { OutputSink, type OutputSummary } from "../session/streaming-output";
18
- import { sanitizeWithOptionalSixelPassthrough } from "../utils/sixel";
18
+ import { sanitizeWithImagePassthrough } from "../utils/image-passthrough";
19
19
  import { formatStatusIcon, replaceTabs } from "./render-utils";
20
20
 
21
21
  export interface BashInteractiveResult extends OutputSummary {
@@ -26,7 +26,7 @@ export interface BashInteractiveResult extends OutputSummary {
26
26
 
27
27
  function normalizeCaptureChunk(chunk: string): string {
28
28
  const normalized = chunk.replace(/\r\n/gu, "\n").replace(/\r/gu, "\n");
29
- return sanitizeWithOptionalSixelPassthrough(normalized, sanitizeText);
29
+ return sanitizeWithImagePassthrough(normalized, sanitizeText);
30
30
  }
31
31
 
32
32
  const XtermTerminal = xterm.Terminal;
package/src/tools/bash.ts CHANGED
@@ -19,7 +19,7 @@ import { SECRET_ENV_PATTERNS, type SecretObfuscator } from "../secrets";
19
19
  import { DEFAULT_MAX_BYTES, TailBuffer } from "../session/streaming-output";
20
20
  import { renderStatusLine } from "../tui";
21
21
  import { CachedOutputBlock } from "../tui/output-block";
22
- import { getSixelLineMask } from "../utils/sixel";
22
+ import { getImageLineMask } from "../utils/image-passthrough";
23
23
  import type { ToolSession } from ".";
24
24
  import { type BashInteractiveResult, runInteractiveBashPty } from "./bash-interactive";
25
25
  import { checkBashInterception } from "./bash-interceptor";
@@ -807,7 +807,7 @@ export const bashToolRenderer = {
807
807
 
808
808
  const rawOutputLines = displayOutput.split("\n");
809
809
  const sixelLineMask =
810
- TERMINAL.imageProtocol === ImageProtocol.Sixel ? getSixelLineMask(rawOutputLines) : undefined;
810
+ TERMINAL.imageProtocol === ImageProtocol.Sixel ? getImageLineMask(rawOutputLines) : undefined;
811
811
  const hasSixelOutput = sixelLineMask?.some(Boolean) ?? false;
812
812
 
813
813
  // Build truncation warning
@@ -0,0 +1,96 @@
1
+ import type { Component } from "@f5xc-salesdemos/pi-tui";
2
+ import { Text } from "@f5xc-salesdemos/pi-tui";
3
+ import type { RenderResultOptions } from "../extensibility/custom-tools/types";
4
+ import type { Theme } from "../modes/theme/theme";
5
+ import { CachedOutputBlock, F5_TOOL_BORDER_COLOR, renderStatusLine } from "../tui";
6
+ import { addSection, formatErrorMessage, shortenPath } from "./render-utils";
7
+
8
+ const TOOL_TITLE = "Display Image";
9
+
10
+ interface DisplayImageRenderArgs {
11
+ path?: string;
12
+ caption?: string;
13
+ }
14
+
15
+ interface DisplayImageRendererDetails {
16
+ imagePath: string;
17
+ mimeType: string;
18
+ displayMethod: "inline" | "external";
19
+ }
20
+
21
+ interface DisplayImageRendererResult {
22
+ content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
23
+ details?: DisplayImageRendererDetails;
24
+ isError?: boolean;
25
+ }
26
+
27
+ export const displayImageToolRenderer = {
28
+ renderCall(args: DisplayImageRenderArgs, _options: RenderResultOptions, uiTheme: Theme): Component {
29
+ const rawPath = args.path ?? "";
30
+ const pathDisplay = rawPath ? shortenPath(rawPath) : "...";
31
+ const description = uiTheme.fg("muted", pathDisplay);
32
+ const text = renderStatusLine({ icon: "pending", title: TOOL_TITLE, description }, uiTheme);
33
+ return new Text(text, 0, 0);
34
+ },
35
+
36
+ renderResult(
37
+ result: DisplayImageRendererResult,
38
+ options: RenderResultOptions,
39
+ uiTheme: Theme,
40
+ args?: DisplayImageRenderArgs,
41
+ ): Component {
42
+ const details = result.details;
43
+ const isError = result.isError === true;
44
+
45
+ if (isError && !details) {
46
+ const errorText = result.content?.find(c => c.type === "text")?.text;
47
+ return new Text(formatErrorMessage(errorText, uiTheme), 0, 0);
48
+ }
49
+
50
+ const rawPath = details?.imagePath ?? args?.path ?? "";
51
+ const pathDisplay = rawPath ? shortenPath(rawPath) : "image";
52
+ const sections: Array<{ label?: string; lines: string[] }> = [];
53
+ const meta: string[] = [];
54
+
55
+ if (details?.mimeType) meta.push(uiTheme.fg("dim", details.mimeType));
56
+ if (details?.displayMethod === "external") meta.push(uiTheme.fg("dim", "external viewer"));
57
+
58
+ if (isError) {
59
+ const errorText = result.content?.find(c => c.type === "text")?.text ?? "Unknown error";
60
+ addSection(sections, "Error", [uiTheme.fg("error", errorText)], uiTheme);
61
+ } else {
62
+ const textContent = result.content?.filter(c => c.type === "text");
63
+ if (textContent?.length) {
64
+ for (const block of textContent) {
65
+ if (block.text) {
66
+ addSection(sections, "", [uiTheme.fg("toolOutput", ` ${block.text}`)], uiTheme);
67
+ }
68
+ }
69
+ }
70
+ }
71
+
72
+ const header = renderStatusLine(
73
+ {
74
+ title: TOOL_TITLE,
75
+ titleColor: "contentAccent",
76
+ description: pathDisplay,
77
+ meta: meta.length > 0 ? meta : undefined,
78
+ },
79
+ uiTheme,
80
+ );
81
+
82
+ const outputBlock = new CachedOutputBlock();
83
+ return {
84
+ render(width: number): string[] {
85
+ const state = options.isPartial ? "pending" : isError ? "error" : "success";
86
+ return outputBlock.render({ header, state, sections, width, borderColor: F5_TOOL_BORDER_COLOR }, uiTheme);
87
+ },
88
+ invalidate() {
89
+ outputBlock.invalidate();
90
+ },
91
+ };
92
+ },
93
+
94
+ mergeCallAndResult: true,
95
+ inline: true,
96
+ };
@@ -0,0 +1,109 @@
1
+ import type {
2
+ AgentTool,
3
+ AgentToolContext,
4
+ AgentToolResult,
5
+ AgentToolUpdateCallback,
6
+ } from "@f5xc-salesdemos/pi-agent-core";
7
+ import type { ImageContent, TextContent } from "@f5xc-salesdemos/pi-ai";
8
+ import { TERMINAL } from "@f5xc-salesdemos/pi-tui/terminal-capabilities";
9
+ import { prompt } from "@f5xc-salesdemos/pi-utils";
10
+ import { type Static, Type } from "@sinclair/typebox";
11
+ import displayImageDescription from "../prompts/tools/display-image.md" with { type: "text" };
12
+ import {
13
+ ImageInputTooLargeError,
14
+ type LoadedImageInput,
15
+ loadImageInput,
16
+ MAX_IMAGE_INPUT_BYTES,
17
+ } from "../utils/image-loading";
18
+ import { openImageExternal } from "../utils/image-viewer";
19
+ import type { ToolSession } from "./index";
20
+ import { ToolError } from "./tool-errors";
21
+
22
+ const displayImageSchema = Type.Object(
23
+ {
24
+ path: Type.String({ description: "Filesystem path to an image file" }),
25
+ caption: Type.Optional(Type.String({ description: "Caption text shown below the image" })),
26
+ },
27
+ { additionalProperties: false },
28
+ );
29
+
30
+ export type DisplayImageParams = Static<typeof displayImageSchema>;
31
+
32
+ export interface DisplayImageToolDetails {
33
+ imagePath: string;
34
+ mimeType: string;
35
+ displayMethod: "inline" | "external";
36
+ }
37
+
38
+ export class DisplayImageTool implements AgentTool<typeof displayImageSchema, DisplayImageToolDetails> {
39
+ readonly name = "display_image";
40
+ readonly label = "DisplayImage";
41
+ readonly description: string;
42
+ readonly parameters = displayImageSchema;
43
+ readonly strict = true;
44
+
45
+ constructor(private readonly session: ToolSession) {
46
+ this.description = prompt.render(displayImageDescription);
47
+ }
48
+
49
+ async execute(
50
+ _toolCallId: string,
51
+ params: DisplayImageParams,
52
+ _signal?: AbortSignal,
53
+ _onUpdate?: AgentToolUpdateCallback<DisplayImageToolDetails>,
54
+ _context?: AgentToolContext,
55
+ ): Promise<AgentToolResult<DisplayImageToolDetails>> {
56
+ if (this.session.settings.get("images.blockImages")) {
57
+ throw new ToolError("Image display is disabled by settings (images.blockImages=true).");
58
+ }
59
+
60
+ let imageInput: LoadedImageInput | null;
61
+ try {
62
+ imageInput = await loadImageInput({
63
+ path: params.path,
64
+ cwd: this.session.cwd,
65
+ autoResize: this.session.settings.get("images.autoResize"),
66
+ maxBytes: MAX_IMAGE_INPUT_BYTES,
67
+ });
68
+ } catch (error) {
69
+ if (error instanceof ImageInputTooLargeError) {
70
+ throw new ToolError(error.message);
71
+ }
72
+ throw error;
73
+ }
74
+
75
+ if (!imageInput) {
76
+ throw new ToolError("display_image only supports PNG, JPEG, GIF, and WEBP files detected by file content.");
77
+ }
78
+
79
+ const content: (TextContent | ImageContent)[] = [];
80
+ let displayMethod: "inline" | "external";
81
+
82
+ if (TERMINAL.imageProtocol) {
83
+ content.push({ type: "image", data: imageInput.data, mimeType: imageInput.mimeType });
84
+ displayMethod = "inline";
85
+ } else {
86
+ const opened = await openImageExternal(imageInput.resolvedPath);
87
+ const msg = opened
88
+ ? `Opened ${imageInput.resolvedPath} in system image viewer.`
89
+ : `Could not open ${imageInput.resolvedPath} — no inline image protocol and external viewer failed.`;
90
+ content.push({ type: "text", text: msg });
91
+ displayMethod = "external";
92
+ }
93
+
94
+ if (params.caption) {
95
+ content.push({ type: "text", text: params.caption });
96
+ }
97
+
98
+ return {
99
+ content,
100
+ details: {
101
+ imagePath: imageInput.resolvedPath,
102
+ mimeType: imageInput.mimeType,
103
+ displayMethod,
104
+ },
105
+ };
106
+ }
107
+ }
108
+
109
+ export { displayImageToolRenderer } from "./display-image-renderer";
@@ -27,6 +27,7 @@ import { CalculatorTool } from "./calculator";
27
27
  import { CancelJobTool } from "./cancel-job";
28
28
  import { type CheckpointState, CheckpointTool, RewindTool } from "./checkpoint";
29
29
  import { DebugTool } from "./debug";
30
+ import { DisplayImageTool } from "./display-image";
30
31
  import { ExitPlanModeTool } from "./exit-plan-mode";
31
32
  import { FindTool } from "./find";
32
33
  import {
@@ -79,6 +80,7 @@ export * from "./calculator";
79
80
  export * from "./cancel-job";
80
81
  export * from "./checkpoint";
81
82
  export * from "./debug";
83
+ export * from "./display-image";
82
84
  export * from "./exit-plan-mode";
83
85
  export * from "./find";
84
86
  export * from "./gemini-image";
@@ -246,6 +248,7 @@ export const BUILTIN_TOOLS: Record<string, ToolFactory> = {
246
248
  lsp: LspTool.createIf,
247
249
  notebook: s => new NotebookTool(s),
248
250
  read: s => new ReadTool(s),
251
+ display_image: s => new DisplayImageTool(s),
249
252
  inspect_image: s => new InspectImageTool(s),
250
253
  browser: s => new BrowserTool(s),
251
254
  checkpoint: CheckpointTool.createIf,
@@ -160,7 +160,10 @@ export class InspectImageTool implements AgentTool<typeof inspectImageSchema, In
160
160
  }
161
161
 
162
162
  return {
163
- content: [{ type: "text", text }],
163
+ content: [
164
+ { type: "image", data: imageInput.data, mimeType: imageInput.mimeType },
165
+ { type: "text", text },
166
+ ],
164
167
  details: {
165
168
  model: `${model.provider}/${model.id}`,
166
169
  imagePath: imageInput.resolvedPath,
@@ -18,6 +18,7 @@ import { bashToolRenderer } from "./bash";
18
18
  import { browserRenderer } from "./browser-renderer";
19
19
  import { calculatorToolRenderer } from "./calculator";
20
20
  import { debugToolRenderer } from "./debug";
21
+ import { displayImageToolRenderer } from "./display-image-renderer";
21
22
  import { findToolRenderer } from "./find";
22
23
  import { ghRunWatchToolRenderer } from "./gh-renderer";
23
24
  import { ghToolsRenderer } from "./gh-tools-renderer";
@@ -61,6 +62,7 @@ export const toolRenderers: Record<string, ToolRenderer> = {
61
62
  grep: grepToolRenderer as ToolRenderer,
62
63
  lsp: lspToolRenderer as ToolRenderer,
63
64
  notebook: notebookToolRenderer as ToolRenderer,
65
+ display_image: displayImageToolRenderer as ToolRenderer,
64
66
  inspect_image: inspectImageToolRenderer as ToolRenderer,
65
67
  // Lazy getter to break circular dependency: renderers.ts <- read.ts
66
68
  get read(): ToolRenderer {
@@ -3,7 +3,7 @@
3
3
  */
4
4
  import { ImageProtocol, padding, TERMINAL, visibleWidth, wrapTextWithAnsi } from "@f5xc-salesdemos/pi-tui";
5
5
  import type { Theme, ThemeColor } from "../modes/theme/theme";
6
- import { getSixelLineMask } from "../utils/sixel";
6
+ import { getImageLineMask } from "../utils/image-passthrough";
7
7
  import type { State } from "./types";
8
8
  import type { RenderCache } from "./utils";
9
9
  import { getStateBgColor, Hasher, padToWidth, truncateToWidth } from "./utils";
@@ -96,7 +96,7 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
96
96
  );
97
97
  }
98
98
  const allLines = section.lines.flatMap(l => l.split("\n"));
99
- const sixelLineMask = TERMINAL.imageProtocol === ImageProtocol.Sixel ? getSixelLineMask(allLines) : undefined;
99
+ const sixelLineMask = TERMINAL.imageProtocol === ImageProtocol.Sixel ? getImageLineMask(allLines) : undefined;
100
100
  for (let lineIndex = 0; lineIndex < allLines.length; lineIndex++) {
101
101
  const line = allLines[lineIndex]!;
102
102
  if (sixelLineMask?.[lineIndex]) {
@@ -0,0 +1,118 @@
1
+ import { TERMINAL } from "@f5xc-salesdemos/pi-tui/terminal-capabilities";
2
+
3
+ // -- Start sequence detection regexes --
4
+
5
+ const SIXEL_START_REGEX = /\x1bP(?:[0-9;]*)q/u;
6
+ // Match all iTerm2 image sequences: File=, MultipartFile=, FilePart=, FileEnd
7
+ const ITERM2_START_REGEX = /\x1b\]1337;(?:File=|MultipartFile=|FilePart=|FileEnd)/u;
8
+ const KITTY_START_REGEX = /\x1b_G/u;
9
+
10
+ // -- Full sequence matching regexes (for placeholder/restore) --
11
+
12
+ const SIXEL_SEQUENCE_REGEX = /\x1bP(?:[0-9;]*)q[\s\S]*?(?:\x1b\\|\x07)/gu;
13
+ // Match all iTerm2 image OSC sequences through their terminator (BEL or ST).
14
+ // Covers File= (inline), MultipartFile= (multipart start), FilePart= (chunks), FileEnd.
15
+ const ITERM2_SEQUENCE_REGEX = /\x1b\]1337;(?:File=|MultipartFile=|FilePart=|FileEnd)[\s\S]*?(?:\x07|\x1b\\)/gu;
16
+ const KITTY_SEQUENCE_REGEX = /\x1b_G[^\x1b]*\x1b\\/gu;
17
+
18
+ const SIXEL_END_SEQUENCE = "\x1b\\";
19
+ const SIXEL_END_BELL = "\x07";
20
+
21
+ const PLACEHOLDER_PREFIX = "__OMP_IMAGE_SEQUENCE_";
22
+
23
+ export function isImagePassthroughEnabled(): boolean {
24
+ return TERMINAL.imageProtocol !== null;
25
+ }
26
+
27
+ export function containsImageSequence(text: string): boolean {
28
+ return SIXEL_START_REGEX.test(text) || ITERM2_START_REGEX.test(text) || KITTY_START_REGEX.test(text);
29
+ }
30
+
31
+ export function isImageLine(line: string): boolean {
32
+ return containsImageSequence(line);
33
+ }
34
+
35
+ export function getImageLineMask(lines: string[]): boolean[] {
36
+ let inSixelSequence = false;
37
+ return lines.map(line => {
38
+ const hasSixelStart = SIXEL_START_REGEX.test(line);
39
+ const hasIterm2 = ITERM2_START_REGEX.test(line);
40
+ const hasKitty = KITTY_START_REGEX.test(line);
41
+
42
+ if (hasSixelStart) {
43
+ inSixelSequence = true;
44
+ }
45
+
46
+ const isImage = inSixelSequence || hasIterm2 || hasKitty;
47
+
48
+ if (inSixelSequence && (line.includes(SIXEL_END_SEQUENCE) || line.includes(SIXEL_END_BELL))) {
49
+ inSixelSequence = false;
50
+ }
51
+
52
+ return isImage;
53
+ });
54
+ }
55
+
56
+ /**
57
+ * Extracts base64 image data from iTerm2 multipart sequences in raw output.
58
+ * Returns the reassembled base64 string and detected mime type, or null
59
+ * if no multipart image is found.
60
+ */
61
+ export function extractITerm2ImageData(text: string): { base64: string; mimeType: string } | null {
62
+ if (!ITERM2_START_REGEX.test(text)) return null;
63
+
64
+ // Check for single-part File= first
65
+ const singleMatch = text.match(/\x1b\]1337;File=([^:]*):([A-Za-z0-9+/=\s]+)(?:\x07|\x1b\\)/u);
66
+ if (singleMatch) {
67
+ const base64 = singleMatch[2].replace(/\s/g, "");
68
+ const mimeType = detectMimeFromBase64(base64);
69
+ return { base64, mimeType };
70
+ }
71
+
72
+ // Reassemble multipart: collect all FilePart payloads in order
73
+ const partRegex = /\x1b\]1337;FilePart=([A-Za-z0-9+/=\s]+)(?:\x07|\x1b\\)/gu;
74
+ const parts: string[] = [];
75
+ for (;;) {
76
+ const match = partRegex.exec(text);
77
+ if (!match) break;
78
+ parts.push(match[1].replace(/\s/g, ""));
79
+ }
80
+
81
+ if (parts.length === 0) return null;
82
+
83
+ const base64 = parts.join("");
84
+ const mimeType = detectMimeFromBase64(base64);
85
+ return { base64, mimeType };
86
+ }
87
+
88
+ function detectMimeFromBase64(base64: string): string {
89
+ const header = base64.slice(0, 12);
90
+ if (header.startsWith("iVBOR")) return "image/png";
91
+ if (header.startsWith("/9j/")) return "image/jpeg";
92
+ if (header.startsWith("R0lGO")) return "image/gif";
93
+ if (header.startsWith("UklGR")) return "image/webp";
94
+ return "image/png";
95
+ }
96
+
97
+ export function sanitizeWithImagePassthrough(text: string, sanitize: (text: string) => string): string {
98
+ if (!isImagePassthroughEnabled() || !containsImageSequence(text)) {
99
+ return sanitize(text);
100
+ }
101
+
102
+ const preservedSequences: string[] = [];
103
+
104
+ let tokenized = text;
105
+ for (const regex of [SIXEL_SEQUENCE_REGEX, ITERM2_SEQUENCE_REGEX, KITTY_SEQUENCE_REGEX]) {
106
+ tokenized = tokenized.replace(regex, match => {
107
+ const token = `${PLACEHOLDER_PREFIX}${preservedSequences.length}__`;
108
+ preservedSequences.push(match);
109
+ return token;
110
+ });
111
+ }
112
+
113
+ const sanitized = sanitize(tokenized);
114
+ return sanitized.replace(/__OMP_IMAGE_SEQUENCE_(\d+)__/gu, (_, indexText: string) => {
115
+ const index = Number.parseInt(indexText, 10);
116
+ return preservedSequences[index] ?? "";
117
+ });
118
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Opens an image in the system's default image viewer.
3
+ * Returns true if the viewer was launched successfully.
4
+ */
5
+ export async function openImageExternal(path: string): Promise<boolean> {
6
+ const command = process.platform === "darwin" ? "open" : "xdg-open";
7
+ try {
8
+ const proc = Bun.spawn([command, path], {
9
+ stdout: "ignore",
10
+ stderr: "ignore",
11
+ });
12
+ await proc.exited;
13
+ return proc.exitCode === 0;
14
+ } catch {
15
+ return false;
16
+ }
17
+ }
@@ -1,69 +0,0 @@
1
- import { $env, $flag } from "@f5xc-salesdemos/pi-utils";
2
-
3
- const SIXEL_START_REGEX = /\x1bP(?:[0-9;]*)q/u;
4
- const SIXEL_END_SEQUENCE = "\x1b\\";
5
- const SIXEL_END_BELL = "\x07";
6
- const SIXEL_SEQUENCE_REGEX = /\x1bP(?:[0-9;]*)q[\s\S]*?(?:\x1b\\|\x07)/gu;
7
- const SIXEL_PLACEHOLDER_PREFIX = "__OMP_SIXEL_SEQUENCE_";
8
-
9
- /**
10
- * Returns whether SIXEL passthrough is explicitly enabled.
11
- *
12
- * Both gates must be enabled to preserve SIXEL control sequences:
13
- * - PI_FORCE_IMAGE_PROTOCOL=sixel
14
- * - PI_ALLOW_SIXEL_PASSTHROUGH=1
15
- */
16
- export function isSixelPassthroughEnabled(): boolean {
17
- const forcedProtocol = $env.PI_FORCE_IMAGE_PROTOCOL?.trim().toLowerCase();
18
- return forcedProtocol === "sixel" && $flag("PI_ALLOW_SIXEL_PASSTHROUGH");
19
- }
20
- /** Returns true when the text contains a SIXEL start sequence. */
21
- export function containsSixelSequence(text: string): boolean {
22
- return SIXEL_START_REGEX.test(text);
23
- }
24
-
25
- /**
26
- * Returns a boolean mask indicating which lines belong to a SIXEL sequence block.
27
- * Supports multi-line SIXEL payloads generated by libsixel.
28
- */
29
- export function getSixelLineMask(lines: string[]): boolean[] {
30
- let inSequence = false;
31
- return lines.map(line => {
32
- const hasStart = containsSixelSequence(line);
33
- if (hasStart) {
34
- inSequence = true;
35
- }
36
- const isSixelLine = inSequence;
37
- if (inSequence && (line.includes(SIXEL_END_SEQUENCE) || line.includes(SIXEL_END_BELL))) {
38
- inSequence = false;
39
- }
40
- return isSixelLine;
41
- });
42
- }
43
-
44
- /** Returns true when the line contains a SIXEL start sequence. */
45
- export function isSixelLine(line: string): boolean {
46
- return containsSixelSequence(line);
47
- }
48
-
49
- /**
50
- * Sanitizes text while preserving embedded SIXEL sequences when passthrough is enabled.
51
- */
52
- export function sanitizeWithOptionalSixelPassthrough(text: string, sanitize: (text: string) => string): string {
53
- if (!isSixelPassthroughEnabled() || !containsSixelSequence(text)) {
54
- return sanitize(text);
55
- }
56
-
57
- const preservedSequences: string[] = [];
58
- const tokenized = text.replace(SIXEL_SEQUENCE_REGEX, match => {
59
- const token = `${SIXEL_PLACEHOLDER_PREFIX}${preservedSequences.length}__`;
60
- preservedSequences.push(match);
61
- return token;
62
- });
63
-
64
- const sanitized = sanitize(tokenized);
65
- return sanitized.replace(/__OMP_SIXEL_SEQUENCE_(\d+)__/gu, (_, indexText: string) => {
66
- const index = Number.parseInt(indexText, 10);
67
- return preservedSequences[index] ?? "";
68
- });
69
- }