pi-image-tools 1.0.10 → 1.1.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/CHANGELOG.md +17 -20
- package/README.md +100 -9
- package/config/config.example.json +8 -3
- package/package.json +4 -5
- package/src/clipboard.ts +12 -61
- package/src/config.ts +146 -0
- package/src/debug-logger.ts +41 -0
- package/src/errors.ts +11 -0
- package/src/image-mime.ts +60 -0
- package/src/image-preview.ts +62 -126
- package/src/image-size.ts +63 -0
- package/src/index.ts +15 -11
- package/src/inline-user-preview.ts +59 -25
- package/src/keybindings.ts +258 -5
- package/src/powershell.ts +71 -0
- package/src/recent-images.ts +14 -49
- package/src/sixel-protocol.ts +57 -57
- package/src/terminal-image-width.ts +92 -90
- package/asset/pi-image-tools.png +0 -0
- package/src/temp-file.ts +0 -82
package/src/image-preview.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { spawnSync } from "node:child_process";
|
|
2
1
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
2
|
import { tmpdir } from "node:os";
|
|
4
3
|
import { join } from "node:path";
|
|
@@ -15,6 +14,12 @@ import {
|
|
|
15
14
|
type Component,
|
|
16
15
|
} from "@mariozechner/pi-tui";
|
|
17
16
|
|
|
17
|
+
import { isRecord } from "./config.js";
|
|
18
|
+
import type { DebugLogger } from "./debug-logger.js";
|
|
19
|
+
import { getErrorMessage } from "./errors.js";
|
|
20
|
+
import { getBase64DecodedByteLength, assertImageWithinByteLimit } from "./image-size.js";
|
|
21
|
+
import { mimeTypeToExtension } from "./image-mime.js";
|
|
22
|
+
import { runPowerShellCommand } from "./powershell.js";
|
|
18
23
|
import { buildSixelRenderLines, ensureCompleteSixelSequence } from "./sixel-protocol.js";
|
|
19
24
|
import {
|
|
20
25
|
DEFAULT_TERMINAL_IMAGE_WIDTH_CELLS,
|
|
@@ -77,22 +82,6 @@ function normalizeText(value: unknown): string {
|
|
|
77
82
|
return typeof value === "string" ? value.trim() : "";
|
|
78
83
|
}
|
|
79
84
|
|
|
80
|
-
function getErrorMessage(error: unknown): string {
|
|
81
|
-
if (error instanceof Error && error.message.trim()) {
|
|
82
|
-
return error.message;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
return "Unknown error";
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function toRecord(value: unknown): Record<string, unknown> {
|
|
89
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
90
|
-
return {};
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
return value as Record<string, unknown>;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
85
|
function normalizeEnvValue(value: string | undefined): string {
|
|
97
86
|
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
98
87
|
}
|
|
@@ -118,63 +107,6 @@ function shouldAttemptSixelRendering(): boolean {
|
|
|
118
107
|
return !getCapabilities().images;
|
|
119
108
|
}
|
|
120
109
|
|
|
121
|
-
function runPowerShellCommand(
|
|
122
|
-
script: string,
|
|
123
|
-
args: string[] = [],
|
|
124
|
-
): { ok: boolean; stdout: string; stderr: string; reason?: string } {
|
|
125
|
-
if (process.platform !== "win32") {
|
|
126
|
-
return {
|
|
127
|
-
ok: false,
|
|
128
|
-
stdout: "",
|
|
129
|
-
stderr: "",
|
|
130
|
-
reason: "PowerShell-based Sixel rendering is only available on Windows.",
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
const result = spawnSync(
|
|
135
|
-
"powershell.exe",
|
|
136
|
-
[
|
|
137
|
-
"-NoProfile",
|
|
138
|
-
"-NonInteractive",
|
|
139
|
-
"-ExecutionPolicy",
|
|
140
|
-
"Bypass",
|
|
141
|
-
"-Command",
|
|
142
|
-
script,
|
|
143
|
-
...args,
|
|
144
|
-
],
|
|
145
|
-
{
|
|
146
|
-
encoding: "utf8",
|
|
147
|
-
timeout: POWER_SHELL_TIMEOUT_MS,
|
|
148
|
-
maxBuffer: POWER_SHELL_MAX_BUFFER_BYTES,
|
|
149
|
-
windowsHide: true,
|
|
150
|
-
},
|
|
151
|
-
);
|
|
152
|
-
|
|
153
|
-
if (result.error) {
|
|
154
|
-
return {
|
|
155
|
-
ok: false,
|
|
156
|
-
stdout: result.stdout ?? "",
|
|
157
|
-
stderr: result.stderr ?? "",
|
|
158
|
-
reason: getErrorMessage(result.error),
|
|
159
|
-
};
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
if (result.status !== 0) {
|
|
163
|
-
return {
|
|
164
|
-
ok: false,
|
|
165
|
-
stdout: result.stdout ?? "",
|
|
166
|
-
stderr: result.stderr ?? "",
|
|
167
|
-
reason: `PowerShell exited with code ${result.status}`,
|
|
168
|
-
};
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
return {
|
|
172
|
-
ok: true,
|
|
173
|
-
stdout: result.stdout ?? "",
|
|
174
|
-
stderr: result.stderr ?? "",
|
|
175
|
-
};
|
|
176
|
-
}
|
|
177
|
-
|
|
178
110
|
const sixelAvailabilityState: SixelAvailability = {
|
|
179
111
|
checked: false,
|
|
180
112
|
available: false,
|
|
@@ -191,26 +123,16 @@ $ProgressPreference = 'SilentlyContinue'
|
|
|
191
123
|
|
|
192
124
|
$module = Get-Module -ListAvailable -Name Sixel | Sort-Object Version -Descending | Select-Object -First 1
|
|
193
125
|
if ($null -eq $module) {
|
|
194
|
-
|
|
195
|
-
if (Get-Command Install-Module -ErrorAction SilentlyContinue) {
|
|
196
|
-
Install-Module -Name Sixel -Scope CurrentUser -Force -AllowClobber -Repository PSGallery -ErrorAction Stop | Out-Null
|
|
197
|
-
} elseif (Get-Command Install-PSResource -ErrorAction SilentlyContinue) {
|
|
198
|
-
Install-PSResource -Name Sixel -Scope CurrentUser -TrustRepository -Reinstall -Force -ErrorAction Stop | Out-Null
|
|
199
|
-
}
|
|
200
|
-
} catch {
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
$module = Get-Module -ListAvailable -Name Sixel | Sort-Object Version -Descending | Select-Object -First 1
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
if ($null -eq $module) {
|
|
207
|
-
Write-Error 'Sixel PowerShell module is unavailable.'
|
|
126
|
+
Write-Error 'Sixel PowerShell module is unavailable. Install the Sixel module manually to enable Sixel previews.'
|
|
208
127
|
}
|
|
209
128
|
|
|
210
129
|
Write-Output ('Sixel/' + $module.Version.ToString())
|
|
211
130
|
`;
|
|
212
131
|
|
|
213
|
-
const result = runPowerShellCommand(script
|
|
132
|
+
const result = runPowerShellCommand(script, {
|
|
133
|
+
timeout: POWER_SHELL_TIMEOUT_MS,
|
|
134
|
+
maxBuffer: POWER_SHELL_MAX_BUFFER_BYTES,
|
|
135
|
+
});
|
|
214
136
|
sixelAvailabilityState.checked = true;
|
|
215
137
|
|
|
216
138
|
if (!result.ok) {
|
|
@@ -219,7 +141,7 @@ Write-Output ('Sixel/' + $module.Version.ToString())
|
|
|
219
141
|
sixelAvailabilityState.available = false;
|
|
220
142
|
sixelAvailabilityState.version = undefined;
|
|
221
143
|
sixelAvailabilityState.reason =
|
|
222
|
-
stderr || stdout || result.reason || "Failed to detect
|
|
144
|
+
stderr || stdout || result.reason || "Failed to detect the Sixel PowerShell module.";
|
|
223
145
|
return sixelAvailabilityState;
|
|
224
146
|
}
|
|
225
147
|
|
|
@@ -232,26 +154,6 @@ Write-Output ('Sixel/' + $module.Version.ToString())
|
|
|
232
154
|
return sixelAvailabilityState;
|
|
233
155
|
}
|
|
234
156
|
|
|
235
|
-
function extensionForImageMimeType(mimeType: string): string {
|
|
236
|
-
const normalized = mimeType.split(";")[0]?.trim().toLowerCase() ?? mimeType.toLowerCase();
|
|
237
|
-
switch (normalized) {
|
|
238
|
-
case "image/png":
|
|
239
|
-
return "png";
|
|
240
|
-
case "image/jpeg":
|
|
241
|
-
return "jpg";
|
|
242
|
-
case "image/webp":
|
|
243
|
-
return "webp";
|
|
244
|
-
case "image/gif":
|
|
245
|
-
return "gif";
|
|
246
|
-
case "image/bmp":
|
|
247
|
-
return "bmp";
|
|
248
|
-
case "image/tiff":
|
|
249
|
-
return "tiff";
|
|
250
|
-
default:
|
|
251
|
-
return "png";
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
157
|
function escapePowerShellSingleQuoted(value: string): string {
|
|
256
158
|
return value.replace(/'/g, "''");
|
|
257
159
|
}
|
|
@@ -260,9 +162,10 @@ function convertImageToSixelSequence(
|
|
|
260
162
|
image: ImagePayload,
|
|
261
163
|
): { sequence?: string; error?: string } {
|
|
262
164
|
const tempBaseDir = mkdtempSync(join(tmpdir(), "pi-image-tools-image-"));
|
|
263
|
-
const imagePath = join(tempBaseDir, `preview.${
|
|
165
|
+
const imagePath = join(tempBaseDir, `preview.${mimeTypeToExtension(image.mimeType)}`);
|
|
264
166
|
|
|
265
167
|
try {
|
|
168
|
+
assertImageWithinByteLimit(getBase64DecodedByteLength(image.data), "Preview image");
|
|
266
169
|
const bytes = Buffer.from(image.data, "base64");
|
|
267
170
|
if (bytes.length === 0) {
|
|
268
171
|
return { error: "Image conversion failed: clipboard payload was empty." };
|
|
@@ -292,7 +195,10 @@ if ([string]::IsNullOrWhiteSpace($rendered)) {
|
|
|
292
195
|
Write-Output $rendered
|
|
293
196
|
`;
|
|
294
197
|
|
|
295
|
-
const result = runPowerShellCommand(script
|
|
198
|
+
const result = runPowerShellCommand(script, {
|
|
199
|
+
timeout: POWER_SHELL_TIMEOUT_MS,
|
|
200
|
+
maxBuffer: POWER_SHELL_MAX_BUFFER_BYTES,
|
|
201
|
+
});
|
|
296
202
|
if (!result.ok) {
|
|
297
203
|
const detail = normalizeText(result.stderr) || normalizeText(result.stdout) || result.reason;
|
|
298
204
|
return {
|
|
@@ -328,15 +234,22 @@ function estimateImageRows(image: ImagePayload, maxWidthCells: number): number {
|
|
|
328
234
|
}
|
|
329
235
|
|
|
330
236
|
function parseImagePreviewDetails(value: unknown): ImagePreviewDetails | null {
|
|
331
|
-
|
|
332
|
-
|
|
237
|
+
if (!isRecord(value)) {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const itemsRaw = value.items;
|
|
333
242
|
if (!Array.isArray(itemsRaw)) {
|
|
334
243
|
return null;
|
|
335
244
|
}
|
|
336
245
|
|
|
337
246
|
const items: ImagePreviewItem[] = [];
|
|
338
247
|
for (const raw of itemsRaw) {
|
|
339
|
-
|
|
248
|
+
if (!isRecord(raw)) {
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const itemRecord = raw;
|
|
340
253
|
const protocol = itemRecord.protocol === "sixel" ? "sixel" : "native";
|
|
341
254
|
const mimeType = typeof itemRecord.mimeType === "string" ? itemRecord.mimeType : "image/png";
|
|
342
255
|
const rows =
|
|
@@ -432,7 +345,26 @@ export function buildPreviewItems(
|
|
|
432
345
|
});
|
|
433
346
|
}
|
|
434
347
|
|
|
435
|
-
export
|
|
348
|
+
export interface RegisterImagePreviewDisplayOptions {
|
|
349
|
+
logger?: DebugLogger;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function logPreviewHandlerError(
|
|
353
|
+
logger: DebugLogger | undefined,
|
|
354
|
+
event: string,
|
|
355
|
+
error: unknown,
|
|
356
|
+
): void {
|
|
357
|
+
try {
|
|
358
|
+
logger?.log(event, { error: getErrorMessage(error) });
|
|
359
|
+
} catch {
|
|
360
|
+
// Debug logging is best-effort inside Pi event handlers.
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export function registerImagePreviewDisplay(
|
|
365
|
+
pi: ExtensionAPI,
|
|
366
|
+
options: RegisterImagePreviewDisplayOptions = {},
|
|
367
|
+
): void {
|
|
436
368
|
let warnedSixelSetup = false;
|
|
437
369
|
|
|
438
370
|
pi.registerMessageRenderer<ImagePreviewDetails>(
|
|
@@ -481,17 +413,21 @@ export function registerImagePreviewDisplay(pi: ExtensionAPI): void {
|
|
|
481
413
|
);
|
|
482
414
|
|
|
483
415
|
pi.on("session_start", async (_event, ctx) => {
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
416
|
+
try {
|
|
417
|
+
if (!shouldAttemptSixelRendering()) {
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
487
420
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
421
|
+
const availability = ensureSixelModuleAvailable();
|
|
422
|
+
if (!availability.available && !warnedSixelSetup && ctx.hasUI) {
|
|
423
|
+
warnedSixelSetup = true;
|
|
424
|
+
ctx.ui.notify(
|
|
425
|
+
`Image preview fallback active: ${availability.reason || "Sixel module unavailable."}`,
|
|
426
|
+
"warning",
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
} catch (error) {
|
|
430
|
+
logPreviewHandlerError(options.logger, "image-preview.session_start_failed", error);
|
|
495
431
|
}
|
|
496
432
|
});
|
|
497
433
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export const IMAGE_TOOLS_MAX_IMAGE_BYTES_ENV_VAR = "PI_IMAGE_TOOLS_MAX_IMAGE_BYTES";
|
|
2
|
+
export const DEFAULT_MAX_IMAGE_BYTES = 20 * 1024 * 1024;
|
|
3
|
+
|
|
4
|
+
function parseMaxImageBytes(environment: NodeJS.ProcessEnv): number {
|
|
5
|
+
const rawValue = environment[IMAGE_TOOLS_MAX_IMAGE_BYTES_ENV_VAR]?.trim();
|
|
6
|
+
if (!rawValue) {
|
|
7
|
+
return DEFAULT_MAX_IMAGE_BYTES;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const parsed = Number(rawValue);
|
|
11
|
+
if (!Number.isFinite(parsed) || parsed < 1) {
|
|
12
|
+
throw new Error(
|
|
13
|
+
`${IMAGE_TOOLS_MAX_IMAGE_BYTES_ENV_VAR} must be a positive byte count when set.`,
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return Math.floor(parsed);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getMaxImageBytes(environment: NodeJS.ProcessEnv = process.env): number {
|
|
21
|
+
return parseMaxImageBytes(environment);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function formatByteLimit(bytes: number): string {
|
|
25
|
+
if (bytes < 1024) {
|
|
26
|
+
return `${bytes} B`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const units = ["KB", "MB", "GB"] as const;
|
|
30
|
+
let value = bytes / 1024;
|
|
31
|
+
let unitIndex = 0;
|
|
32
|
+
|
|
33
|
+
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
34
|
+
value /= 1024;
|
|
35
|
+
unitIndex += 1;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function assertImageWithinByteLimit(
|
|
42
|
+
sizeBytes: number,
|
|
43
|
+
label: string,
|
|
44
|
+
environment: NodeJS.ProcessEnv = process.env,
|
|
45
|
+
): void {
|
|
46
|
+
const maxImageBytes = getMaxImageBytes(environment);
|
|
47
|
+
if (sizeBytes > maxImageBytes) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`${label} is too large (${formatByteLimit(sizeBytes)}). The pi-image-tools limit is ${formatByteLimit(maxImageBytes)}. Set ${IMAGE_TOOLS_MAX_IMAGE_BYTES_ENV_VAR} to a larger byte count if needed.`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function getBase64DecodedByteLength(base64Data: string): number {
|
|
55
|
+
const normalized = base64Data.trim().replace(/^data:[^,]*,/, "").replace(/\s/g, "");
|
|
56
|
+
if (normalized.length === 0) {
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const padding = normalized.endsWith("==") ? 2 : normalized.endsWith("=") ? 1 : 0;
|
|
61
|
+
return Math.max(0, Math.floor((normalized.length * 3) / 4) - padding);
|
|
62
|
+
}
|
|
63
|
+
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,10 @@ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
|
2
2
|
|
|
3
3
|
import { readClipboardImage } from "./clipboard.js";
|
|
4
4
|
import { registerPasteImageCommand } from "./commands.js";
|
|
5
|
+
import { loadImageToolsConfig } from "./config.js";
|
|
6
|
+
import { DebugLogger } from "./debug-logger.js";
|
|
7
|
+
import { getErrorMessage } from "./errors.js";
|
|
8
|
+
import { assertImageWithinByteLimit } from "./image-size.js";
|
|
5
9
|
import {
|
|
6
10
|
IMAGE_PREVIEW_CUSTOM_TYPE,
|
|
7
11
|
buildPreviewItems,
|
|
@@ -25,15 +29,8 @@ const IMAGE_ATTACHMENT_INDICATOR = "[ Image Attached]";
|
|
|
25
29
|
|
|
26
30
|
interface PendingImage extends ImagePayload {}
|
|
27
31
|
|
|
28
|
-
function getErrorMessage(error: unknown): string {
|
|
29
|
-
if (error instanceof Error && error.message.trim().length > 0) {
|
|
30
|
-
return error.message;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
return "Unknown error";
|
|
34
|
-
}
|
|
35
|
-
|
|
36
32
|
function imageToBase64(image: ClipboardImage): string {
|
|
33
|
+
assertImageWithinByteLimit(image.bytes.length, "Image attachment");
|
|
37
34
|
return Buffer.from(image.bytes).toString("base64");
|
|
38
35
|
}
|
|
39
36
|
|
|
@@ -144,10 +141,17 @@ function showRecentSelectionPreview(
|
|
|
144
141
|
}
|
|
145
142
|
|
|
146
143
|
export default function imageToolsExtension(pi: ExtensionAPI): void {
|
|
144
|
+
const config = loadImageToolsConfig();
|
|
145
|
+
const logger = DebugLogger.create(config);
|
|
147
146
|
const pendingImages: PendingImage[] = [];
|
|
148
147
|
|
|
149
|
-
|
|
150
|
-
|
|
148
|
+
logger.log("extension.initialize", {
|
|
149
|
+
debug: config.debug,
|
|
150
|
+
pasteImageShortcutsConfigured: config.shortcuts.pasteImage !== undefined,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
registerInlineUserImagePreview(pi, { logger });
|
|
154
|
+
registerImagePreviewDisplay(pi, { logger });
|
|
151
155
|
|
|
152
156
|
const pasteImageFromClipboard = async (ctx: PasteContext): Promise<void> => {
|
|
153
157
|
if (!ctx.hasUI) {
|
|
@@ -249,7 +253,7 @@ export default function imageToolsExtension(pi: ExtensionAPI): void {
|
|
|
249
253
|
};
|
|
250
254
|
});
|
|
251
255
|
|
|
252
|
-
registerImagePasteKeybindings(pi, pasteImageFromClipboard);
|
|
256
|
+
registerImagePasteKeybindings(pi, pasteImageFromClipboard, { config, logger });
|
|
253
257
|
registerPasteImageCommand(pi, {
|
|
254
258
|
fromClipboard: pasteImageFromClipboard,
|
|
255
259
|
fromRecent: pasteImageFromRecent,
|
|
@@ -5,6 +5,9 @@ import {
|
|
|
5
5
|
} from "@mariozechner/pi-coding-agent";
|
|
6
6
|
import { Image, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
7
7
|
|
|
8
|
+
import { isRecord } from "./config.js";
|
|
9
|
+
import type { DebugLogger } from "./debug-logger.js";
|
|
10
|
+
import { getErrorMessage } from "./errors.js";
|
|
8
11
|
import { buildPreviewItems, type ImagePayload, type ImagePreviewItem } from "./image-preview.js";
|
|
9
12
|
import { buildSixelRenderLines, isInlineImageProtocolLine } from "./sixel-protocol.js";
|
|
10
13
|
import { setActiveTerminalImageSettingsCwd } from "./terminal-image-width.js";
|
|
@@ -108,19 +111,15 @@ function renderPreviewLines(items: readonly ImagePreviewItem[], width: number):
|
|
|
108
111
|
}
|
|
109
112
|
|
|
110
113
|
function toUserMessage(value: unknown): UserMessageLike {
|
|
111
|
-
|
|
112
|
-
return {};
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
return value as UserMessageLike;
|
|
114
|
+
return isRecord(value) ? value : {};
|
|
116
115
|
}
|
|
117
116
|
|
|
118
117
|
function toImageContent(value: unknown): UserImageContent | null {
|
|
119
|
-
if (!
|
|
118
|
+
if (!isRecord(value)) {
|
|
120
119
|
return null;
|
|
121
120
|
}
|
|
122
121
|
|
|
123
|
-
const record = value
|
|
122
|
+
const record = value;
|
|
124
123
|
if (record.type !== "image") {
|
|
125
124
|
return null;
|
|
126
125
|
}
|
|
@@ -237,7 +236,19 @@ function assignPreviewItemsToLatestUserMessage(
|
|
|
237
236
|
}
|
|
238
237
|
}
|
|
239
238
|
|
|
240
|
-
function
|
|
239
|
+
function logInlinePreviewError(
|
|
240
|
+
logger: DebugLogger | undefined,
|
|
241
|
+
event: string,
|
|
242
|
+
error: unknown,
|
|
243
|
+
): void {
|
|
244
|
+
try {
|
|
245
|
+
logger?.log(event, { error: getErrorMessage(error) });
|
|
246
|
+
} catch {
|
|
247
|
+
// Debug logging is best-effort inside Pi event handlers.
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function patchInteractiveMode(logger?: DebugLogger): void {
|
|
241
252
|
const prototype = (InteractiveMode as unknown as { prototype: InteractiveModePrototype }).prototype;
|
|
242
253
|
if (!prototype) {
|
|
243
254
|
return;
|
|
@@ -281,7 +292,8 @@ function patchInteractiveMode(): void {
|
|
|
281
292
|
if (imagePayloads.length > 0) {
|
|
282
293
|
try {
|
|
283
294
|
previewItems = buildPreviewItems(imagePayloads);
|
|
284
|
-
} catch {
|
|
295
|
+
} catch (error) {
|
|
296
|
+
logInlinePreviewError(logger, "inline-user-preview.build_preview_failed", error);
|
|
285
297
|
previewItems = [];
|
|
286
298
|
}
|
|
287
299
|
}
|
|
@@ -303,31 +315,53 @@ function patchInteractiveMode(): void {
|
|
|
303
315
|
prototype.__piImageToolsPreviewPatched = true;
|
|
304
316
|
}
|
|
305
317
|
|
|
306
|
-
export
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
patchInteractiveMode();
|
|
310
|
-
patchUserMessageRender();
|
|
311
|
-
}, 0);
|
|
318
|
+
export interface RegisterInlineUserImagePreviewOptions {
|
|
319
|
+
logger?: DebugLogger;
|
|
320
|
+
}
|
|
312
321
|
|
|
322
|
+
export function registerInlineUserImagePreview(
|
|
323
|
+
pi: ExtensionAPI,
|
|
324
|
+
options: RegisterInlineUserImagePreviewOptions = {},
|
|
325
|
+
): void {
|
|
326
|
+
const runPatch = (delayMs: number): void => {
|
|
313
327
|
setTimeout(() => {
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
328
|
+
try {
|
|
329
|
+
patchInteractiveMode(options.logger);
|
|
330
|
+
patchUserMessageRender();
|
|
331
|
+
} catch (error) {
|
|
332
|
+
logInlinePreviewError(options.logger, "inline-user-preview.patch_failed", error);
|
|
333
|
+
}
|
|
334
|
+
}, delayMs);
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const schedulePatch = (): void => {
|
|
338
|
+
runPatch(0);
|
|
339
|
+
runPatch(25);
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
const handleSessionEvent = (eventName: string, cwd: string | undefined): void => {
|
|
343
|
+
try {
|
|
344
|
+
setActiveTerminalImageSettingsCwd(cwd);
|
|
345
|
+
schedulePatch();
|
|
346
|
+
} catch (error) {
|
|
347
|
+
logInlinePreviewError(options.logger, `inline-user-preview.${eventName}_failed`, error);
|
|
348
|
+
}
|
|
317
349
|
};
|
|
318
350
|
|
|
319
351
|
pi.on("session_start", async (_event, ctx) => {
|
|
320
|
-
|
|
321
|
-
schedulePatch();
|
|
352
|
+
handleSessionEvent("session_start", ctx.cwd);
|
|
322
353
|
});
|
|
323
354
|
|
|
324
355
|
pi.on("before_agent_start", async (_event, ctx) => {
|
|
325
|
-
|
|
326
|
-
schedulePatch();
|
|
356
|
+
handleSessionEvent("before_agent_start", ctx.cwd);
|
|
327
357
|
});
|
|
328
358
|
|
|
329
|
-
pi.on
|
|
330
|
-
|
|
331
|
-
|
|
359
|
+
const onSessionSwitch = pi.on as unknown as (
|
|
360
|
+
event: "session_switch",
|
|
361
|
+
handler: (_event: unknown, ctx: { cwd?: string }) => Promise<void>,
|
|
362
|
+
) => void;
|
|
363
|
+
|
|
364
|
+
onSessionSwitch("session_switch", async (_event, ctx) => {
|
|
365
|
+
handleSessionEvent("session_switch", ctx.cwd);
|
|
332
366
|
});
|
|
333
367
|
}
|