@f5xc-salesdemos/xcsh 18.86.2 → 18.87.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 +4 -4
- package/src/internal-urls/build-info.generated.ts +8 -8
- package/src/modes/components/bash-execution.ts +48 -16
- package/src/modes/components/tool-execution.ts +2 -2
- package/src/prompts/tools/display-image.md +23 -0
- package/src/prompts/tools/inspect-image.md +2 -2
- package/src/session/streaming-output.ts +11 -5
- package/src/tools/bash-interactive.ts +2 -2
- package/src/tools/bash.ts +2 -2
- package/src/tools/display-image-renderer.ts +96 -0
- package/src/tools/display-image.ts +109 -0
- package/src/tools/index.ts +3 -0
- package/src/tools/inspect-image.ts +4 -1
- package/src/tools/renderers.ts +2 -0
- package/src/tui/output-block.ts +2 -2
- package/src/utils/image-passthrough.ts +118 -0
- package/src/utils/image-viewer.ts +17 -0
- package/src/utils/sixel.ts +0 -69
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.87.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",
|
|
@@ -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.
|
|
54
|
-
"@f5xc-salesdemos/pi-agent-core": "18.
|
|
55
|
-
"@f5xc-salesdemos/pi-ai": "18.
|
|
56
|
-
"@f5xc-salesdemos/pi-natives": "18.
|
|
57
|
-
"@f5xc-salesdemos/pi-tui": "18.
|
|
58
|
-
"@f5xc-salesdemos/pi-utils": "18.
|
|
53
|
+
"@f5xc-salesdemos/xcsh-stats": "18.87.1",
|
|
54
|
+
"@f5xc-salesdemos/pi-agent-core": "18.87.1",
|
|
55
|
+
"@f5xc-salesdemos/pi-ai": "18.87.1",
|
|
56
|
+
"@f5xc-salesdemos/pi-natives": "18.87.1",
|
|
57
|
+
"@f5xc-salesdemos/pi-tui": "18.87.1",
|
|
58
|
+
"@f5xc-salesdemos/pi-utils": "18.87.1",
|
|
59
59
|
"@sinclair/typebox": "^0.34",
|
|
60
60
|
"@xterm/headless": "^6.0",
|
|
61
61
|
"ajv": "^8.18",
|
|
@@ -404,16 +404,16 @@ export const SETTINGS_SCHEMA = {
|
|
|
404
404
|
|
|
405
405
|
"tui.maxInlineImageColumns": {
|
|
406
406
|
type: "number",
|
|
407
|
-
default:
|
|
407
|
+
default: 0,
|
|
408
408
|
description:
|
|
409
|
-
"Maximum width in terminal columns for inline images
|
|
409
|
+
"Maximum width in terminal columns for inline images. Set to 0 for unlimited (bounded only by terminal width).",
|
|
410
410
|
},
|
|
411
411
|
|
|
412
412
|
"tui.maxInlineImageRows": {
|
|
413
413
|
type: "number",
|
|
414
|
-
default:
|
|
414
|
+
default: 0,
|
|
415
415
|
description:
|
|
416
|
-
"Maximum height in terminal rows for inline images
|
|
416
|
+
"Maximum height in terminal rows for inline images. Set to 0 to use only the viewport-based limit (60% of terminal height).",
|
|
417
417
|
},
|
|
418
418
|
// Display rendering
|
|
419
419
|
"display.tabWidth": {
|
|
@@ -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.87.1",
|
|
21
|
+
"commit": "6f18c8b1d535b8d55768fbada66b694660c01ad8",
|
|
22
|
+
"shortCommit": "6f18c8b",
|
|
23
23
|
"branch": "main",
|
|
24
|
-
"tag": "v18.
|
|
25
|
-
"commitDate": "2026-05-
|
|
26
|
-
"buildDate": "2026-05-
|
|
24
|
+
"tag": "v18.87.1",
|
|
25
|
+
"commitDate": "2026-05-29T14:50:21Z",
|
|
26
|
+
"buildDate": "2026-05-29T15:13:18.736Z",
|
|
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/6f18c8b1d535b8d55768fbada66b694660c01ad8",
|
|
32
|
+
"releaseUrl": "https://github.com/f5xc-salesdemos/xcsh/releases/tag/v18.87.1"
|
|
33
33
|
};
|
|
@@ -3,10 +3,16 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { sanitizeText } from "@f5xc-salesdemos/pi-natives";
|
|
6
|
-
import { Container,
|
|
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 {
|
|
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
|
|
206
|
-
|
|
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
|
-
//
|
|
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 =
|
|
230
|
+
const highlightedLines = hasImageOutput ? undefined : highlightIfStructured(availableLines);
|
|
221
231
|
|
|
222
|
-
if (this.#expanded ||
|
|
232
|
+
if (this.#expanded || hasImageOutput) {
|
|
223
233
|
const displayText = highlightedLines
|
|
224
234
|
? highlightedLines.join("\n")
|
|
225
235
|
: availableLines
|
|
226
|
-
.map((line, index) => (
|
|
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 && !
|
|
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
|
|
289
|
-
if (!
|
|
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) => (
|
|
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 =
|
|
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 {
|
|
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
|
|
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
|
|
25
|
-
-
|
|
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 {
|
|
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
|
-
//
|
|
697
|
-
// that were split across
|
|
698
|
-
const
|
|
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 {
|
|
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
|
|
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 {
|
|
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 ?
|
|
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";
|
package/src/tools/index.ts
CHANGED
|
@@ -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: [
|
|
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,
|
package/src/tools/renderers.ts
CHANGED
|
@@ -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 {
|
package/src/tui/output-block.ts
CHANGED
|
@@ -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 {
|
|
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 ?
|
|
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
|
+
}
|
package/src/utils/sixel.ts
DELETED
|
@@ -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
|
-
}
|