pi-image-tools 1.0.11 → 1.2.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 +28 -0
- package/README.md +17 -4
- package/package.json +11 -8
- package/src/clipboard.ts +13 -62
- package/src/commands.ts +1 -1
- package/src/config.ts +1 -1
- package/src/debug-logger.ts +54 -20
- package/src/errors.ts +11 -0
- package/src/image-mime.ts +60 -0
- package/src/image-preview.ts +260 -160
- package/src/image-size.ts +63 -0
- package/src/index.ts +13 -19
- package/src/inline-user-preview.ts +71 -37
- package/src/keybindings.ts +2 -2
- package/src/powershell.ts +222 -0
- package/src/recent-images.ts +14 -49
- package/src/terminal-image-width.ts +4 -2
- package/src/types.ts +20 -20
- package/src/temp-file.ts +0 -82
package/src/image-preview.ts
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
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";
|
|
5
4
|
|
|
6
|
-
import type { ExtensionAPI } from "@
|
|
5
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
7
6
|
import {
|
|
8
7
|
calculateImageRows,
|
|
9
8
|
Container,
|
|
@@ -13,8 +12,14 @@ import {
|
|
|
13
12
|
Spacer,
|
|
14
13
|
Text,
|
|
15
14
|
type Component,
|
|
16
|
-
} from "@
|
|
17
|
-
|
|
15
|
+
} from "@earendil-works/pi-tui";
|
|
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 { runBufferedCommand, runPowerShellCommandAsync, type BufferedCommandResult, type PowerShellCommandResult, type RunPowerShellCommandOptions } from "./powershell.js";
|
|
18
23
|
import { buildSixelRenderLines, ensureCompleteSixelSequence } from "./sixel-protocol.js";
|
|
19
24
|
import {
|
|
20
25
|
DEFAULT_TERMINAL_IMAGE_WIDTH_CELLS,
|
|
@@ -26,6 +31,8 @@ export const IMAGE_PREVIEW_CUSTOM_TYPE = "pi-image-tools-preview";
|
|
|
26
31
|
const MAX_IMAGES_PER_MESSAGE = 3;
|
|
27
32
|
const POWER_SHELL_TIMEOUT_MS = 120_000;
|
|
28
33
|
const POWER_SHELL_MAX_BUFFER_BYTES = 128 * 1024 * 1024;
|
|
34
|
+
const LINUX_SIXEL_TIMEOUT_MS = 120_000;
|
|
35
|
+
const LINUX_SIXEL_MAX_BUFFER_BYTES = 128 * 1024 * 1024;
|
|
29
36
|
const FORCE_SIXEL_ENV_VAR = "PI_IMAGE_TOOLS_FORCE_SIXEL";
|
|
30
37
|
const DISABLE_SIXEL_ENV_VAR = "PI_IMAGE_TOOLS_DISABLE_SIXEL";
|
|
31
38
|
|
|
@@ -35,9 +42,22 @@ export type ImagePayload = {
|
|
|
35
42
|
mimeType: string;
|
|
36
43
|
};
|
|
37
44
|
|
|
45
|
+
type SixelConverter = "powershell-sixel" | "img2sixel";
|
|
46
|
+
type SixelProcessRunner = (
|
|
47
|
+
command: string,
|
|
48
|
+
args: readonly string[],
|
|
49
|
+
options: { timeout: number; maxBuffer: number; windowsHide?: boolean },
|
|
50
|
+
) => Promise<BufferedCommandResult>;
|
|
51
|
+
|
|
52
|
+
type SixelPowerShellRunner = (
|
|
53
|
+
script: string,
|
|
54
|
+
options: RunPowerShellCommandOptions,
|
|
55
|
+
) => Promise<PowerShellCommandResult>;
|
|
56
|
+
|
|
38
57
|
type SixelAvailability = {
|
|
39
58
|
checked: boolean;
|
|
40
59
|
available: boolean;
|
|
60
|
+
converter?: SixelConverter;
|
|
41
61
|
version?: string;
|
|
42
62
|
reason?: string;
|
|
43
63
|
};
|
|
@@ -77,22 +97,6 @@ function normalizeText(value: unknown): string {
|
|
|
77
97
|
return typeof value === "string" ? value.trim() : "";
|
|
78
98
|
}
|
|
79
99
|
|
|
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
100
|
function normalizeEnvValue(value: string | undefined): string {
|
|
97
101
|
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
98
102
|
}
|
|
@@ -102,87 +106,39 @@ function isTruthyEnvFlag(value: string | undefined): boolean {
|
|
|
102
106
|
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
|
|
103
107
|
}
|
|
104
108
|
|
|
105
|
-
function shouldAttemptSixelRendering(
|
|
106
|
-
|
|
109
|
+
function shouldAttemptSixelRendering(
|
|
110
|
+
environment: NodeJS.ProcessEnv = process.env,
|
|
111
|
+
platform: NodeJS.Platform = process.platform,
|
|
112
|
+
): boolean {
|
|
113
|
+
if (isTruthyEnvFlag(environment[DISABLE_SIXEL_ENV_VAR])) {
|
|
107
114
|
return false;
|
|
108
115
|
}
|
|
109
116
|
|
|
110
|
-
if (
|
|
117
|
+
if (platform !== "win32" && platform !== "linux") {
|
|
111
118
|
return false;
|
|
112
119
|
}
|
|
113
120
|
|
|
114
|
-
if (isTruthyEnvFlag(
|
|
121
|
+
if (isTruthyEnvFlag(environment[FORCE_SIXEL_ENV_VAR])) {
|
|
115
122
|
return true;
|
|
116
123
|
}
|
|
117
124
|
|
|
118
125
|
return !getCapabilities().images;
|
|
119
126
|
}
|
|
120
127
|
|
|
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
128
|
const sixelAvailabilityState: SixelAvailability = {
|
|
179
129
|
checked: false,
|
|
180
130
|
available: false,
|
|
181
131
|
};
|
|
182
132
|
|
|
183
|
-
function ensureSixelModuleAvailable(
|
|
184
|
-
|
|
185
|
-
|
|
133
|
+
async function ensureSixelModuleAvailable(
|
|
134
|
+
forceRefresh = false,
|
|
135
|
+
powerShellRunner: SixelPowerShellRunner = runPowerShellCommandAsync,
|
|
136
|
+
): Promise<SixelAvailability> {
|
|
137
|
+
const useCache = powerShellRunner === runPowerShellCommandAsync;
|
|
138
|
+
const state: SixelAvailability = useCache ? sixelAvailabilityState : { checked: false, available: false };
|
|
139
|
+
|
|
140
|
+
if (state.checked && !forceRefresh) {
|
|
141
|
+
return state;
|
|
186
142
|
}
|
|
187
143
|
|
|
188
144
|
const script = `
|
|
@@ -191,78 +147,114 @@ $ProgressPreference = 'SilentlyContinue'
|
|
|
191
147
|
|
|
192
148
|
$module = Get-Module -ListAvailable -Name Sixel | Sort-Object Version -Descending | Select-Object -First 1
|
|
193
149
|
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.'
|
|
150
|
+
Write-Error 'Sixel PowerShell module is unavailable. Install the Sixel module manually to enable Sixel previews.'
|
|
208
151
|
}
|
|
209
152
|
|
|
210
153
|
Write-Output ('Sixel/' + $module.Version.ToString())
|
|
211
154
|
`;
|
|
212
155
|
|
|
213
|
-
const result =
|
|
214
|
-
|
|
156
|
+
const result = await powerShellRunner(script, {
|
|
157
|
+
timeout: POWER_SHELL_TIMEOUT_MS,
|
|
158
|
+
maxBuffer: POWER_SHELL_MAX_BUFFER_BYTES,
|
|
159
|
+
});
|
|
160
|
+
state.checked = true;
|
|
215
161
|
|
|
216
162
|
if (!result.ok) {
|
|
217
163
|
const stderr = normalizeText(result.stderr);
|
|
218
164
|
const stdout = normalizeText(result.stdout);
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
165
|
+
state.available = false;
|
|
166
|
+
state.converter = undefined;
|
|
167
|
+
state.version = undefined;
|
|
168
|
+
state.reason =
|
|
169
|
+
stderr || stdout || result.reason || "Failed to detect the Sixel PowerShell module.";
|
|
170
|
+
return state;
|
|
224
171
|
}
|
|
225
172
|
|
|
226
173
|
const marker = normalizeText(result.stdout)
|
|
227
174
|
.split(/\r?\n/)
|
|
228
175
|
.find((line) => line.startsWith("Sixel/"));
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
176
|
+
state.available = true;
|
|
177
|
+
state.converter = "powershell-sixel";
|
|
178
|
+
state.version = marker ? marker.slice("Sixel/".length) : undefined;
|
|
179
|
+
state.reason = undefined;
|
|
180
|
+
return state;
|
|
233
181
|
}
|
|
234
182
|
|
|
235
|
-
function
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
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";
|
|
183
|
+
async function ensureLinuxSixelConverterAvailable(
|
|
184
|
+
forceRefresh = false,
|
|
185
|
+
processRunner: SixelProcessRunner = runBufferedCommand,
|
|
186
|
+
): Promise<SixelAvailability> {
|
|
187
|
+
const useCache = processRunner === runBufferedCommand;
|
|
188
|
+
const state: SixelAvailability = useCache ? sixelAvailabilityState : { checked: false, available: false };
|
|
189
|
+
|
|
190
|
+
if (state.checked && !forceRefresh) {
|
|
191
|
+
return state;
|
|
252
192
|
}
|
|
193
|
+
|
|
194
|
+
state.checked = true;
|
|
195
|
+
|
|
196
|
+
const result = await processRunner("img2sixel", ["--version"], {
|
|
197
|
+
timeout: 5_000,
|
|
198
|
+
maxBuffer: 1024 * 1024,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
if (result.error) {
|
|
202
|
+
state.available = false;
|
|
203
|
+
state.converter = undefined;
|
|
204
|
+
state.version = undefined;
|
|
205
|
+
state.reason = isErrnoLike(result.error, "ENOENT")
|
|
206
|
+
? "img2sixel is not installed. Install libsixel-bin or an equivalent package to enable Linux Sixel previews."
|
|
207
|
+
: getErrorMessage(result.error);
|
|
208
|
+
return state;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (result.status !== 0) {
|
|
212
|
+
state.available = false;
|
|
213
|
+
state.converter = undefined;
|
|
214
|
+
state.version = undefined;
|
|
215
|
+
state.reason = normalizeText(result.stderr.toString("utf8")) || normalizeText(result.stdout.toString("utf8")) || "img2sixel detection failed.";
|
|
216
|
+
return state;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
state.available = true;
|
|
220
|
+
state.converter = "img2sixel";
|
|
221
|
+
state.version = normalizeText(result.stdout.toString("utf8")).split(/\r?\n/)[0] || undefined;
|
|
222
|
+
state.reason = undefined;
|
|
223
|
+
return state;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function ensureSixelConverterAvailable(
|
|
227
|
+
forceRefresh = false,
|
|
228
|
+
platform: NodeJS.Platform = process.platform,
|
|
229
|
+
processRunner: SixelProcessRunner = runBufferedCommand,
|
|
230
|
+
powerShellRunner: SixelPowerShellRunner = runPowerShellCommandAsync,
|
|
231
|
+
): Promise<SixelAvailability> {
|
|
232
|
+
if (platform === "linux") {
|
|
233
|
+
return ensureLinuxSixelConverterAvailable(forceRefresh, processRunner);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return ensureSixelModuleAvailable(forceRefresh, powerShellRunner);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function isErrnoLike(error: unknown, code: string): boolean {
|
|
240
|
+
return error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === code;
|
|
253
241
|
}
|
|
254
242
|
|
|
255
243
|
function escapePowerShellSingleQuoted(value: string): string {
|
|
256
244
|
return value.replace(/'/g, "''");
|
|
257
245
|
}
|
|
258
246
|
|
|
259
|
-
function convertImageToSixelSequence(
|
|
247
|
+
async function convertImageToSixelSequence(
|
|
260
248
|
image: ImagePayload,
|
|
261
|
-
|
|
249
|
+
converter: SixelConverter,
|
|
250
|
+
processRunner: SixelProcessRunner = runBufferedCommand,
|
|
251
|
+
powerShellRunner: SixelPowerShellRunner = runPowerShellCommandAsync,
|
|
252
|
+
): Promise<{ sequence?: string; error?: string }> {
|
|
262
253
|
const tempBaseDir = mkdtempSync(join(tmpdir(), "pi-image-tools-image-"));
|
|
263
|
-
const imagePath = join(tempBaseDir, `preview.${
|
|
254
|
+
const imagePath = join(tempBaseDir, `preview.${mimeTypeToExtension(image.mimeType)}`);
|
|
264
255
|
|
|
265
256
|
try {
|
|
257
|
+
assertImageWithinByteLimit(getBase64DecodedByteLength(image.data), "Preview image");
|
|
266
258
|
const bytes = Buffer.from(image.data, "base64");
|
|
267
259
|
if (bytes.length === 0) {
|
|
268
260
|
return { error: "Image conversion failed: clipboard payload was empty." };
|
|
@@ -270,6 +262,33 @@ function convertImageToSixelSequence(
|
|
|
270
262
|
|
|
271
263
|
writeFileSync(imagePath, bytes);
|
|
272
264
|
|
|
265
|
+
if (converter === "img2sixel") {
|
|
266
|
+
const result = await processRunner("img2sixel", [imagePath], {
|
|
267
|
+
timeout: LINUX_SIXEL_TIMEOUT_MS,
|
|
268
|
+
maxBuffer: LINUX_SIXEL_MAX_BUFFER_BYTES,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
if (result.error) {
|
|
272
|
+
return { error: `Sixel conversion failed: ${getErrorMessage(result.error)}` };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (result.status !== 0) {
|
|
276
|
+
const detail = normalizeText(result.stderr.toString("utf8")) || normalizeText(result.stdout.toString("utf8"));
|
|
277
|
+
return {
|
|
278
|
+
error: detail
|
|
279
|
+
? `Sixel conversion failed: ${detail}`
|
|
280
|
+
: "Sixel conversion failed for an unknown reason.",
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const normalized = ensureCompleteSixelSequence(result.stdout.toString("utf8"));
|
|
285
|
+
if (!normalized) {
|
|
286
|
+
return { error: "Sixel conversion produced empty output." };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return { sequence: normalized };
|
|
290
|
+
}
|
|
291
|
+
|
|
273
292
|
const escapedPath = escapePowerShellSingleQuoted(imagePath);
|
|
274
293
|
|
|
275
294
|
const script = `
|
|
@@ -292,7 +311,10 @@ if ([string]::IsNullOrWhiteSpace($rendered)) {
|
|
|
292
311
|
Write-Output $rendered
|
|
293
312
|
`;
|
|
294
313
|
|
|
295
|
-
const result =
|
|
314
|
+
const result = await powerShellRunner(script, {
|
|
315
|
+
timeout: POWER_SHELL_TIMEOUT_MS,
|
|
316
|
+
maxBuffer: POWER_SHELL_MAX_BUFFER_BYTES,
|
|
317
|
+
});
|
|
296
318
|
if (!result.ok) {
|
|
297
319
|
const detail = normalizeText(result.stderr) || normalizeText(result.stdout) || result.reason;
|
|
298
320
|
return {
|
|
@@ -328,15 +350,22 @@ function estimateImageRows(image: ImagePayload, maxWidthCells: number): number {
|
|
|
328
350
|
}
|
|
329
351
|
|
|
330
352
|
function parseImagePreviewDetails(value: unknown): ImagePreviewDetails | null {
|
|
331
|
-
|
|
332
|
-
|
|
353
|
+
if (!isRecord(value)) {
|
|
354
|
+
return null;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const itemsRaw = value.items;
|
|
333
358
|
if (!Array.isArray(itemsRaw)) {
|
|
334
359
|
return null;
|
|
335
360
|
}
|
|
336
361
|
|
|
337
362
|
const items: ImagePreviewItem[] = [];
|
|
338
363
|
for (const raw of itemsRaw) {
|
|
339
|
-
|
|
364
|
+
if (!isRecord(raw)) {
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
const itemRecord = raw;
|
|
340
369
|
const protocol = itemRecord.protocol === "sixel" ? "sixel" : "native";
|
|
341
370
|
const mimeType = typeof itemRecord.mimeType === "string" ? itemRecord.mimeType : "image/png";
|
|
342
371
|
const rows =
|
|
@@ -378,47 +407,93 @@ function parseImagePreviewDetails(value: unknown): ImagePreviewDetails | null {
|
|
|
378
407
|
return { items };
|
|
379
408
|
}
|
|
380
409
|
|
|
381
|
-
export type BuildPreviewItemsOptions = TerminalImageWidthOptions
|
|
410
|
+
export type BuildPreviewItemsOptions = TerminalImageWidthOptions & {
|
|
411
|
+
environment?: NodeJS.ProcessEnv;
|
|
412
|
+
logger?: DebugLogger;
|
|
413
|
+
platform?: NodeJS.Platform;
|
|
414
|
+
sixelProcessRunner?: SixelProcessRunner;
|
|
415
|
+
sixelPowerShellRunner?: SixelPowerShellRunner;
|
|
416
|
+
};
|
|
382
417
|
|
|
383
|
-
|
|
418
|
+
function logSixelEvent(
|
|
419
|
+
logger: DebugLogger | undefined,
|
|
420
|
+
event: string,
|
|
421
|
+
fields: Record<string, unknown> = {},
|
|
422
|
+
): void {
|
|
423
|
+
try {
|
|
424
|
+
logger?.log(event, fields);
|
|
425
|
+
} catch {
|
|
426
|
+
// Debug logging is best-effort and must never block image rendering.
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export async function buildPreviewItems(
|
|
384
431
|
images: readonly ImagePayload[],
|
|
385
432
|
options: BuildPreviewItemsOptions = {},
|
|
386
|
-
): ImagePreviewItem[] {
|
|
433
|
+
): Promise<ImagePreviewItem[]> {
|
|
387
434
|
const selectedImages = images.slice(0, MAX_IMAGES_PER_MESSAGE);
|
|
388
435
|
if (selectedImages.length === 0) {
|
|
389
436
|
return [];
|
|
390
437
|
}
|
|
391
438
|
|
|
392
439
|
const maxWidthCells = resolveTerminalImageWidthCells(options);
|
|
393
|
-
const
|
|
394
|
-
const
|
|
440
|
+
const platform = options.platform ?? process.platform;
|
|
441
|
+
const processRunner = options.sixelProcessRunner ?? runBufferedCommand;
|
|
442
|
+
const powerShellRunner = options.sixelPowerShellRunner ?? runPowerShellCommandAsync;
|
|
443
|
+
const attemptSixel = shouldAttemptSixelRendering(options.environment, platform);
|
|
444
|
+
const sixelState = attemptSixel
|
|
445
|
+
? await ensureSixelConverterAvailable(false, platform, processRunner, powerShellRunner)
|
|
446
|
+
: undefined;
|
|
447
|
+
|
|
448
|
+
logSixelEvent(options.logger, "image-preview.sixel.detected", {
|
|
449
|
+
attemptSixel,
|
|
450
|
+
available: sixelState?.available ?? false,
|
|
451
|
+
converter: sixelState?.converter ?? null,
|
|
452
|
+
reason: sixelState?.reason ?? null,
|
|
453
|
+
platform,
|
|
454
|
+
});
|
|
395
455
|
|
|
396
|
-
|
|
456
|
+
const items: ImagePreviewItem[] = [];
|
|
457
|
+
for (const image of selectedImages) {
|
|
397
458
|
const rows = estimateImageRows(image, maxWidthCells);
|
|
398
459
|
|
|
399
|
-
if (attemptSixel && sixelState?.available) {
|
|
400
|
-
const conversion = convertImageToSixelSequence(image);
|
|
460
|
+
if (attemptSixel && sixelState?.available && sixelState.converter) {
|
|
461
|
+
const conversion = await convertImageToSixelSequence(image, sixelState.converter, processRunner, powerShellRunner);
|
|
401
462
|
if (conversion.sequence) {
|
|
402
|
-
|
|
463
|
+
logSixelEvent(options.logger, "image-preview.sixel.converted", {
|
|
464
|
+
converter: sixelState.converter,
|
|
465
|
+
mimeType: image.mimeType,
|
|
466
|
+
rows,
|
|
467
|
+
maxWidthCells,
|
|
468
|
+
});
|
|
469
|
+
items.push({
|
|
403
470
|
protocol: "sixel",
|
|
404
471
|
mimeType: image.mimeType,
|
|
405
472
|
rows,
|
|
406
473
|
maxWidthCells,
|
|
407
474
|
sixelSequence: conversion.sequence,
|
|
408
|
-
};
|
|
475
|
+
});
|
|
476
|
+
continue;
|
|
409
477
|
}
|
|
410
478
|
|
|
411
|
-
|
|
479
|
+
logSixelEvent(options.logger, "image-preview.sixel.conversion_failed", {
|
|
480
|
+
converter: sixelState.converter,
|
|
481
|
+
mimeType: image.mimeType,
|
|
482
|
+
error: conversion.error ?? "unknown",
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
items.push({
|
|
412
486
|
protocol: "native",
|
|
413
487
|
mimeType: image.mimeType,
|
|
414
488
|
rows,
|
|
415
489
|
maxWidthCells,
|
|
416
490
|
data: image.data,
|
|
417
491
|
warning: conversion.error,
|
|
418
|
-
};
|
|
492
|
+
});
|
|
493
|
+
continue;
|
|
419
494
|
}
|
|
420
495
|
|
|
421
|
-
|
|
496
|
+
items.push({
|
|
422
497
|
protocol: "native",
|
|
423
498
|
mimeType: image.mimeType,
|
|
424
499
|
rows,
|
|
@@ -426,13 +501,34 @@ export function buildPreviewItems(
|
|
|
426
501
|
data: image.data,
|
|
427
502
|
warning:
|
|
428
503
|
attemptSixel && sixelState && !sixelState.available
|
|
429
|
-
? `Sixel preview unavailable: ${sixelState.reason || "missing
|
|
504
|
+
? `Sixel preview unavailable: ${sixelState.reason || "missing Sixel converter."}`
|
|
430
505
|
: undefined,
|
|
431
|
-
};
|
|
432
|
-
}
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
return items;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
export interface RegisterImagePreviewDisplayOptions {
|
|
513
|
+
logger?: DebugLogger;
|
|
433
514
|
}
|
|
434
515
|
|
|
435
|
-
|
|
516
|
+
function logPreviewHandlerError(
|
|
517
|
+
logger: DebugLogger | undefined,
|
|
518
|
+
event: string,
|
|
519
|
+
error: unknown,
|
|
520
|
+
): void {
|
|
521
|
+
try {
|
|
522
|
+
logger?.log(event, { error: getErrorMessage(error) });
|
|
523
|
+
} catch {
|
|
524
|
+
// Debug logging is best-effort inside Pi event handlers.
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
export function registerImagePreviewDisplay(
|
|
529
|
+
pi: ExtensionAPI,
|
|
530
|
+
options: RegisterImagePreviewDisplayOptions = {},
|
|
531
|
+
): void {
|
|
436
532
|
let warnedSixelSetup = false;
|
|
437
533
|
|
|
438
534
|
pi.registerMessageRenderer<ImagePreviewDetails>(
|
|
@@ -481,17 +577,21 @@ export function registerImagePreviewDisplay(pi: ExtensionAPI): void {
|
|
|
481
577
|
);
|
|
482
578
|
|
|
483
579
|
pi.on("session_start", async (_event, ctx) => {
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
580
|
+
try {
|
|
581
|
+
if (!shouldAttemptSixelRendering()) {
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
487
584
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
585
|
+
const availability = await ensureSixelConverterAvailable();
|
|
586
|
+
if (!availability.available && !warnedSixelSetup && ctx.hasUI) {
|
|
587
|
+
warnedSixelSetup = true;
|
|
588
|
+
ctx.ui.notify(
|
|
589
|
+
`Image preview fallback active: ${availability.reason || "Sixel module unavailable."}`,
|
|
590
|
+
"warning",
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
} catch (error) {
|
|
594
|
+
logPreviewHandlerError(options.logger, "image-preview.session_start_failed", error);
|
|
495
595
|
}
|
|
496
596
|
});
|
|
497
597
|
}
|
|
@@ -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
|
+
|