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.
@@ -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
- try {
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/install the Sixel PowerShell module.";
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.${extensionForImageMimeType(image.mimeType)}`);
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
- const record = toRecord(value);
332
- const itemsRaw = record.items;
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
- const itemRecord = toRecord(raw);
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 function registerImagePreviewDisplay(pi: ExtensionAPI): void {
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
- if (!shouldAttemptSixelRendering()) {
485
- return;
486
- }
416
+ try {
417
+ if (!shouldAttemptSixelRendering()) {
418
+ return;
419
+ }
487
420
 
488
- const availability = ensureSixelModuleAvailable();
489
- if (!availability.available && !warnedSixelSetup && ctx.hasUI) {
490
- warnedSixelSetup = true;
491
- ctx.ui.notify(
492
- `Image preview fallback active: ${availability.reason || "Sixel module unavailable."}`,
493
- "warning",
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
- registerInlineUserImagePreview(pi);
150
- registerImagePreviewDisplay(pi);
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
- if (!value || typeof value !== "object" || Array.isArray(value)) {
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 (!value || typeof value !== "object" || Array.isArray(value)) {
118
+ if (!isRecord(value)) {
120
119
  return null;
121
120
  }
122
121
 
123
- const record = value as Record<string, unknown>;
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 patchInteractiveMode(): void {
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 function registerInlineUserImagePreview(pi: ExtensionAPI): void {
307
- const schedulePatch = (): void => {
308
- setTimeout(() => {
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
- patchInteractiveMode();
315
- patchUserMessageRender();
316
- }, 25);
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
- setActiveTerminalImageSettingsCwd(ctx.cwd);
321
- schedulePatch();
352
+ handleSessionEvent("session_start", ctx.cwd);
322
353
  });
323
354
 
324
355
  pi.on("before_agent_start", async (_event, ctx) => {
325
- setActiveTerminalImageSettingsCwd(ctx.cwd);
326
- schedulePatch();
356
+ handleSessionEvent("before_agent_start", ctx.cwd);
327
357
  });
328
358
 
329
- pi.on("session_switch", async (_event, ctx) => {
330
- setActiveTerminalImageSettingsCwd(ctx.cwd);
331
- schedulePatch();
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
  }