pi-image-tools 1.2.0 → 1.3.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.
@@ -12,17 +12,11 @@ import { buildPreviewItems, type ImagePayload, type ImagePreviewItem } from "./i
12
12
  import { buildSixelRenderLines, isInlineImageProtocolLine } from "./sixel-protocol.js";
13
13
  import { setActiveTerminalImageSettingsCwd } from "./terminal-image-width.js";
14
14
 
15
- type UserMessageRenderFn = (width: number) => string[];
16
-
17
- type UserMessagePrototype = {
18
- render: UserMessageRenderFn;
19
- __piImageToolsInlineOriginalRender?: UserMessageRenderFn;
20
- __piImageToolsInlinePatched?: boolean;
21
- };
15
+ const INLINE_PREVIEW_PATCH_VERSION = "pi-image-tools-inline-preview-chat-component-v4";
16
+ const PREPARE_SUBMITTED_PREVIEW_TIMEOUT_MS = 2_500;
22
17
 
23
18
  type UserMessageInstance = {
24
- __piImageToolsInlineAssigned?: boolean;
25
- __piImageToolsInlineItems?: ImagePreviewItem[];
19
+ render?: (width: number) => string[];
26
20
  };
27
21
 
28
22
  type InteractiveModePrototype = {
@@ -31,6 +25,7 @@ type InteractiveModePrototype = {
31
25
  __piImageToolsOriginalAddMessageToChat?: (message: unknown, options?: unknown) => void;
32
26
  __piImageToolsOriginalGetUserMessageText?: (message: unknown) => string;
33
27
  __piImageToolsPreviewPatched?: boolean;
28
+ __piImageToolsPreviewPatchVersion?: string;
34
29
  };
35
30
 
36
31
  interface UserImageContent {
@@ -46,8 +41,22 @@ interface UserMessageLike {
46
41
 
47
42
  interface InteractiveModeLike {
48
43
  chatContainer?: {
44
+ addChild?: (component: unknown) => void;
49
45
  children?: unknown[];
50
46
  };
47
+ ui?: {
48
+ requestRender?: () => void;
49
+ };
50
+ }
51
+
52
+ class ImagePreviewChatComponent {
53
+ constructor(private readonly items: readonly ImagePreviewItem[]) {}
54
+
55
+ invalidate(): void {}
56
+
57
+ render(width: number): string[] {
58
+ return renderPreviewLines(this.items, width);
59
+ }
51
60
  }
52
61
 
53
62
  function buildNativeLines(item: ImagePreviewItem, width: number): string[] {
@@ -137,18 +146,13 @@ function toImageContent(value: unknown): UserImageContent | null {
137
146
  };
138
147
  }
139
148
 
140
- function extractImagePayloads(message: unknown): ImagePayload[] {
141
- const userMessage = toUserMessage(message);
142
- if (userMessage.role !== "user") {
143
- return [];
144
- }
145
-
146
- if (!Array.isArray(userMessage.content)) {
149
+ function extractImagePayloadsFromContent(content: unknown): ImagePayload[] {
150
+ if (!Array.isArray(content)) {
147
151
  return [];
148
152
  }
149
153
 
150
154
  const payloads: ImagePayload[] = [];
151
- for (const part of userMessage.content) {
155
+ for (const part of content) {
152
156
  const image = toImageContent(part);
153
157
  if (!image) {
154
158
  continue;
@@ -164,76 +168,171 @@ function extractImagePayloads(message: unknown): ImagePayload[] {
164
168
  return payloads;
165
169
  }
166
170
 
167
- function imagePlaceholderText(count: number): string {
168
- if (count <= 1) {
169
- return "[󰈟 1 image attached]";
171
+ function extractImagePayloads(message: unknown): ImagePayload[] {
172
+ const userMessage = toUserMessage(message);
173
+ if (userMessage.role !== "user") {
174
+ return [];
170
175
  }
171
176
 
172
- return `[󰈟 ${count} images attached]`;
177
+ return extractImagePayloadsFromContent(userMessage.content);
178
+ }
179
+
180
+ function getImagePayloadSignature(images: readonly ImagePayload[]): string {
181
+ return images
182
+ .map((image) => `${image.mimeType}:${image.data.length}:${image.data.slice(0, 32)}:${image.data.slice(-32)}`)
183
+ .join("|");
184
+ }
185
+
186
+ interface PreparedPreviewItems {
187
+ signature: string;
188
+ items: ImagePreviewItem[];
173
189
  }
174
190
 
175
- function patchUserMessageRender(): void {
176
- const prototype = (UserMessageComponent as unknown as { prototype: UserMessagePrototype }).prototype;
177
- if (typeof prototype.render !== "function") {
191
+ const preparedPreviewItemsQueue: PreparedPreviewItems[] = [];
192
+ const inFlightPreviewItems = new Map<string, Promise<ImagePreviewItem[]>>();
193
+
194
+ function queuePreparedPreviewItems(images: readonly ImagePayload[], items: ImagePreviewItem[]): void {
195
+ if (images.length === 0 || items.length === 0) {
178
196
  return;
179
197
  }
180
198
 
181
- if (!prototype.__piImageToolsInlineOriginalRender) {
182
- prototype.__piImageToolsInlineOriginalRender = prototype.render;
199
+ preparedPreviewItemsQueue.push({
200
+ signature: getImagePayloadSignature(images),
201
+ items,
202
+ });
203
+
204
+ if (preparedPreviewItemsQueue.length > 8) {
205
+ preparedPreviewItemsQueue.splice(0, preparedPreviewItemsQueue.length - 8);
183
206
  }
207
+ }
184
208
 
185
- if (prototype.__piImageToolsInlinePatched) {
186
- return;
209
+ function consumePreparedPreviewItems(images: readonly ImagePayload[]): ImagePreviewItem[] | undefined {
210
+ if (images.length === 0 || preparedPreviewItemsQueue.length === 0) {
211
+ return undefined;
187
212
  }
188
213
 
189
- prototype.render = function renderWithInlineImagePreview(width: number): string[] {
190
- const originalRender = prototype.__piImageToolsInlineOriginalRender;
191
- if (!originalRender) {
192
- return [];
193
- }
214
+ const signature = getImagePayloadSignature(images);
215
+ const index = preparedPreviewItemsQueue.findIndex((entry) => entry.signature === signature);
216
+ if (index === -1) {
217
+ return undefined;
218
+ }
194
219
 
195
- const instance = this as unknown as UserMessageInstance;
196
- if (!instance.__piImageToolsInlineAssigned) {
197
- instance.__piImageToolsInlineAssigned = true;
198
- if (!Array.isArray(instance.__piImageToolsInlineItems)) {
199
- instance.__piImageToolsInlineItems = [];
200
- }
201
- }
220
+ const [entry] = preparedPreviewItemsQueue.splice(index, 1);
221
+ return entry?.items;
222
+ }
202
223
 
203
- const baseLines = originalRender.call(this, width);
204
- const previewLines = renderPreviewLines(instance.__piImageToolsInlineItems ?? [], width);
205
- if (previewLines.length === 0) {
206
- return baseLines;
224
+ function getInFlightPreviewItems(images: readonly ImagePayload[]): Promise<ImagePreviewItem[]> | undefined {
225
+ if (images.length === 0) {
226
+ return undefined;
227
+ }
228
+
229
+ return inFlightPreviewItems.get(getImagePayloadSignature(images));
230
+ }
231
+
232
+ function buildPreviewItemsOnce(
233
+ images: readonly ImagePayload[],
234
+ options: { cwd?: string; logger?: DebugLogger } = {},
235
+ ): Promise<ImagePreviewItem[]> {
236
+ const signature = getImagePayloadSignature(images);
237
+ const existing = inFlightPreviewItems.get(signature);
238
+ if (existing) {
239
+ return existing;
240
+ }
241
+
242
+ const promise = buildPreviewItems(images, options);
243
+ promise.then(
244
+ () => inFlightPreviewItems.delete(signature),
245
+ () => inFlightPreviewItems.delete(signature),
246
+ );
247
+ promise.catch(() => {
248
+ // Consumers log contextual errors; this prevents unhandled rejections when prebuild times out.
249
+ });
250
+ inFlightPreviewItems.set(signature, promise);
251
+ return promise;
252
+ }
253
+
254
+ async function waitForPreviewItemsDeadline(
255
+ previewItemsPromise: Promise<ImagePreviewItem[]>,
256
+ ): Promise<ImagePreviewItem[] | undefined> {
257
+ let timeout: ReturnType<typeof setTimeout> | undefined;
258
+
259
+ try {
260
+ return await Promise.race([
261
+ previewItemsPromise,
262
+ new Promise<undefined>((resolve) => {
263
+ timeout = setTimeout(() => resolve(undefined), PREPARE_SUBMITTED_PREVIEW_TIMEOUT_MS);
264
+ timeout.unref?.();
265
+ }),
266
+ ]);
267
+ } finally {
268
+ if (timeout) {
269
+ clearTimeout(timeout);
207
270
  }
271
+ }
272
+ }
208
273
 
209
- return [...baseLines, ...previewLines];
210
- };
274
+ function imagePlaceholderText(count: number): string {
275
+ if (count <= 1) {
276
+ return "[󰈟 1 image attached]";
277
+ }
211
278
 
212
- prototype.__piImageToolsInlinePatched = true;
279
+ return `[󰈟 ${count} images attached]`;
213
280
  }
214
281
 
215
- function assignPreviewItemsToLatestUserMessage(
282
+ function isUserMessageComponentLike(value: unknown): value is UserMessageInstance {
283
+ if (value instanceof UserMessageComponent) {
284
+ return true;
285
+ }
286
+
287
+ if (!isRecord(value) || typeof value.render !== "function") {
288
+ return false;
289
+ }
290
+
291
+ const constructorName =
292
+ typeof value.constructor === "function" && typeof value.constructor.name === "string"
293
+ ? value.constructor.name
294
+ : undefined;
295
+
296
+ return constructorName === "UserMessageComponent";
297
+ }
298
+
299
+ function requestInteractiveModeRender(mode: InteractiveModeLike): void {
300
+ try {
301
+ mode.ui?.requestRender?.();
302
+ } catch {
303
+ // Rendering will be retried by the next TUI update.
304
+ }
305
+ }
306
+
307
+ function addPreviewItemsAfterLatestUserMessage(
216
308
  mode: InteractiveModeLike,
217
309
  fromChildIndex: number,
218
310
  previewItems: ImagePreviewItem[],
219
- ): void {
220
- const children = mode.chatContainer?.children;
221
- if (!Array.isArray(children) || children.length === 0) {
222
- return;
311
+ ): boolean {
312
+ const chatContainer = mode.chatContainer;
313
+ const children = chatContainer?.children;
314
+ if (!Array.isArray(children) || children.length === 0 || previewItems.length === 0) {
315
+ return false;
223
316
  }
224
317
 
225
318
  const start = Math.max(0, fromChildIndex);
226
319
  for (let index = children.length - 1; index >= start; index -= 1) {
227
320
  const child = children[index];
228
- if (!(child instanceof UserMessageComponent)) {
321
+ if (!isUserMessageComponentLike(child)) {
229
322
  continue;
230
323
  }
231
324
 
232
- const instance = child as unknown as UserMessageInstance;
233
- instance.__piImageToolsInlineItems = previewItems;
234
- instance.__piImageToolsInlineAssigned = true;
235
- return;
325
+ const previewComponent = new ImagePreviewChatComponent(previewItems);
326
+ const insertIndex = index + 1;
327
+ if (insertIndex >= children.length && typeof chatContainer?.addChild === "function") {
328
+ chatContainer.addChild(previewComponent);
329
+ } else {
330
+ children.splice(insertIndex, 0, previewComponent);
331
+ }
332
+ return true;
236
333
  }
334
+
335
+ return false;
237
336
  }
238
337
 
239
338
  function logInlinePreviewError(
@@ -262,7 +361,10 @@ function patchInteractiveMode(logger?: DebugLogger): void {
262
361
  prototype.__piImageToolsOriginalAddMessageToChat = prototype.addMessageToChat;
263
362
  }
264
363
 
265
- if (prototype.__piImageToolsPreviewPatched) {
364
+ if (
365
+ prototype.__piImageToolsPreviewPatched &&
366
+ prototype.__piImageToolsPreviewPatchVersion === INLINE_PREVIEW_PATCH_VERSION
367
+ ) {
266
368
  return;
267
369
  }
268
370
 
@@ -299,13 +401,26 @@ function patchInteractiveMode(logger?: DebugLogger): void {
299
401
  return;
300
402
  }
301
403
 
302
- void buildPreviewItems(imagePayloads, { logger })
404
+ const preparedPreviewItems = consumePreparedPreviewItems(imagePayloads);
405
+ if (preparedPreviewItems) {
406
+ if (addPreviewItemsAfterLatestUserMessage(mode, beforeCount, preparedPreviewItems)) {
407
+ requestInteractiveModeRender(mode);
408
+ }
409
+ return;
410
+ }
411
+
412
+ const previewItemsPromise = getInFlightPreviewItems(imagePayloads)
413
+ ?? buildPreviewItemsOnce(imagePayloads, { logger });
414
+
415
+ void previewItemsPromise
303
416
  .then((previewItems) => {
304
417
  if (previewItems.length === 0) {
305
418
  return;
306
419
  }
307
420
 
308
- assignPreviewItemsToLatestUserMessage(mode, beforeCount, previewItems);
421
+ if (addPreviewItemsAfterLatestUserMessage(mode, beforeCount, previewItems)) {
422
+ requestInteractiveModeRender(mode);
423
+ }
309
424
  })
310
425
  .catch((error: unknown) => {
311
426
  logInlinePreviewError(logger, "inline-user-preview.build_preview_failed", error);
@@ -313,6 +428,7 @@ function patchInteractiveMode(logger?: DebugLogger): void {
313
428
  };
314
429
 
315
430
  prototype.__piImageToolsPreviewPatched = true;
431
+ prototype.__piImageToolsPreviewPatchVersion = INLINE_PREVIEW_PATCH_VERSION;
316
432
  }
317
433
 
318
434
  export interface RegisterInlineUserImagePreviewOptions {
@@ -323,15 +439,16 @@ export function registerInlineUserImagePreview(
323
439
  pi: ExtensionAPI,
324
440
  options: RegisterInlineUserImagePreviewOptions = {},
325
441
  ): void {
442
+ const applyPatch = (): void => {
443
+ try {
444
+ patchInteractiveMode(options.logger);
445
+ } catch (error) {
446
+ logInlinePreviewError(options.logger, "inline-user-preview.patch_failed", error);
447
+ }
448
+ };
449
+
326
450
  const runPatch = (delayMs: number): void => {
327
- setTimeout(() => {
328
- try {
329
- patchInteractiveMode(options.logger);
330
- patchUserMessageRender();
331
- } catch (error) {
332
- logInlinePreviewError(options.logger, "inline-user-preview.patch_failed", error);
333
- }
334
- }, delayMs);
451
+ setTimeout(applyPatch, delayMs);
335
452
  };
336
453
 
337
454
  const schedulePatch = (): void => {
@@ -342,18 +459,57 @@ export function registerInlineUserImagePreview(
342
459
  const handleSessionEvent = (eventName: string, cwd: string | undefined): void => {
343
460
  try {
344
461
  setActiveTerminalImageSettingsCwd(cwd);
462
+ applyPatch();
345
463
  schedulePatch();
346
464
  } catch (error) {
347
465
  logInlinePreviewError(options.logger, `inline-user-preview.${eventName}_failed`, error);
348
466
  }
349
467
  };
350
468
 
469
+ const prepareSubmittedImagePreview = async (
470
+ event: { images?: unknown },
471
+ cwd: string | undefined,
472
+ ): Promise<{ imagePayloads: ImagePayload[]; previewItems: ImagePreviewItem[] } | undefined> => {
473
+ try {
474
+ setActiveTerminalImageSettingsCwd(cwd);
475
+ applyPatch();
476
+ const imagePayloads = extractImagePayloadsFromContent(event.images);
477
+ if (imagePayloads.length === 0) {
478
+ return undefined;
479
+ }
480
+
481
+ const previewItemsPromise = buildPreviewItemsOnce(imagePayloads, { cwd, logger: options.logger });
482
+ const previewItems = await waitForPreviewItemsDeadline(previewItemsPromise);
483
+ if (!previewItems || previewItems.length === 0) {
484
+ return undefined;
485
+ }
486
+
487
+ return { imagePayloads, previewItems };
488
+ } catch (error) {
489
+ logInlinePreviewError(options.logger, "inline-user-preview.prepare_submitted_preview_failed", error);
490
+ return undefined;
491
+ }
492
+ };
493
+
494
+ applyPatch();
495
+
351
496
  pi.on("session_start", async (_event, ctx) => {
352
497
  handleSessionEvent("session_start", ctx.cwd);
353
498
  });
354
499
 
355
- pi.on("before_agent_start", async (_event, ctx) => {
500
+ pi.on("before_agent_start", async (event, ctx) => {
356
501
  handleSessionEvent("before_agent_start", ctx.cwd);
502
+ if (!ctx.hasUI) {
503
+ return undefined;
504
+ }
505
+
506
+ const preparedPreview = await prepareSubmittedImagePreview(event, ctx.cwd);
507
+ if (!preparedPreview) {
508
+ return undefined;
509
+ }
510
+
511
+ queuePreparedPreviewItems(preparedPreview.imagePayloads, preparedPreview.previewItems);
512
+ return undefined;
357
513
  });
358
514
 
359
515
  const onSessionSwitch = pi.on as unknown as (
@@ -230,20 +230,15 @@ function removeBuiltinConflicts(shortcuts: readonly KeyId[]): KeyId[] {
230
230
  return shortcuts.filter((shortcut) => !builtinShortcuts.has(normalizeShortcutKey(shortcut)));
231
231
  }
232
232
 
233
- function getImagePasteShortcuts(
233
+ export function getImagePasteShortcuts(
234
234
  config: ImageToolsConfig,
235
235
  platform: NodeJS.Platform = process.platform,
236
236
  ): KeyId[] {
237
237
  const shouldAvoidBuiltinConflicts =
238
238
  config.shortcuts.avoidBuiltinConflicts || config.shortcuts.suppressBuiltinConflictWarnings;
239
+ const candidates = config.shortcuts.pasteImage ?? getImagePasteShortcutCandidates(platform);
239
240
 
240
- if (config.shortcuts.pasteImage !== undefined) {
241
- return shouldAvoidBuiltinConflicts
242
- ? removeBuiltinConflicts(config.shortcuts.pasteImage)
243
- : config.shortcuts.pasteImage;
244
- }
245
-
246
- return removeBuiltinConflicts(getImagePasteShortcutCandidates(platform));
241
+ return shouldAvoidBuiltinConflicts ? removeBuiltinConflicts(candidates) : [...candidates];
247
242
  }
248
243
 
249
244
  export function registerImagePasteKeybindings(
@@ -251,16 +246,20 @@ export function registerImagePasteKeybindings(
251
246
  handler: PasteImageHandler,
252
247
  options: RegisterImagePasteKeybindingsOptions,
253
248
  ): void {
249
+ const platform = process.platform;
254
250
  const configuredShortcuts = options.config.shortcuts.pasteImage;
255
- const shortcuts = getImagePasteShortcuts(options.config);
256
- const skippedShortcuts = configuredShortcuts?.filter(
251
+ const requestedShortcuts = configuredShortcuts ?? getImagePasteShortcutCandidates(platform);
252
+ const shortcuts = getImagePasteShortcuts(options.config, platform);
253
+ const skippedShortcuts = requestedShortcuts.filter(
257
254
  (shortcut) => !shortcuts.some((registeredShortcut) => normalizeShortcutKey(registeredShortcut) === normalizeShortcutKey(shortcut)),
258
- ) ?? [];
255
+ );
259
256
 
260
257
  options.logger.log("keybindings.register", {
261
258
  avoidBuiltinConflicts: options.config.shortcuts.avoidBuiltinConflicts,
262
259
  suppressBuiltinConflictWarnings: options.config.shortcuts.suppressBuiltinConflictWarnings,
263
260
  configured: configuredShortcuts !== undefined,
261
+ platform,
262
+ requestedShortcuts,
264
263
  registeredShortcuts: shortcuts,
265
264
  skippedShortcuts,
266
265
  });
@@ -0,0 +1,68 @@
1
+ import { spawnSync } from "node:child_process";
2
+
3
+ import { isErrnoException } from "../errors.js";
4
+
5
+ export const LIST_TYPES_TIMEOUT_MS = 1000;
6
+ export const READ_TIMEOUT_MS = 5000;
7
+ export const MAX_BUFFER_BYTES = 50 * 1024 * 1024;
8
+
9
+ export interface CommandResult {
10
+ ok: boolean;
11
+ stdout: Buffer;
12
+ stderr: Buffer;
13
+ missingCommand: boolean;
14
+ status: number | null;
15
+ }
16
+
17
+ export interface CommandRunOptions {
18
+ environment?: NodeJS.ProcessEnv;
19
+ maxBuffer?: number;
20
+ timeout: number;
21
+ windowsHide?: boolean;
22
+ }
23
+
24
+ export type CommandRunner = (
25
+ command: string,
26
+ args: readonly string[],
27
+ options: CommandRunOptions,
28
+ ) => CommandResult;
29
+
30
+ function toBuffer(value: unknown): Buffer {
31
+ if (Buffer.isBuffer(value)) {
32
+ return value;
33
+ }
34
+
35
+ if (value instanceof Uint8Array) {
36
+ return Buffer.from(value);
37
+ }
38
+
39
+ return Buffer.from(value === undefined || value === null ? "" : String(value), "utf8");
40
+ }
41
+
42
+ export const defaultCommandRunner: CommandRunner = (command, args, options) => {
43
+ const result = spawnSync(command, [...args], {
44
+ env: options.environment,
45
+ maxBuffer: options.maxBuffer ?? MAX_BUFFER_BYTES,
46
+ stdio: ["ignore", "pipe", "pipe"],
47
+ timeout: options.timeout,
48
+ windowsHide: options.windowsHide,
49
+ });
50
+
51
+ if (result.error) {
52
+ return {
53
+ ok: false,
54
+ stdout: toBuffer(result.stdout),
55
+ stderr: toBuffer(result.stderr),
56
+ missingCommand: isErrnoException(result.error) && result.error.code === "ENOENT",
57
+ status: result.status ?? null,
58
+ };
59
+ }
60
+
61
+ return {
62
+ ok: result.status === 0,
63
+ stdout: toBuffer(result.stdout),
64
+ stderr: toBuffer(result.stderr),
65
+ missingCommand: false,
66
+ status: result.status ?? null,
67
+ };
68
+ };
@@ -0,0 +1,101 @@
1
+ import { buildNamespaceWrappedCommand, defaultCommandExists, type CommandExists } from "../shell-environment.js";
2
+ import {
3
+ defaultCommandRunner,
4
+ MAX_BUFFER_BYTES,
5
+ READ_TIMEOUT_MS,
6
+ type CommandRunner,
7
+ } from "./command-runner.js";
8
+ import type { ClipboardImageProvider, ClipboardProviderContext, ClipboardReadResult } from "./types.js";
9
+
10
+ const PNGF_SCRIPT = `try
11
+ set imageData to the clipboard as «class PNGf»
12
+ return imageData
13
+ on error
14
+ return ""
15
+ end try`;
16
+
17
+ export interface OsascriptPngfProviderOptions {
18
+ priority?: number;
19
+ commandRunner?: CommandRunner;
20
+ commandExists?: CommandExists;
21
+ }
22
+
23
+ function parseAppleScriptPngfData(stdout: Buffer): Uint8Array | null {
24
+ const text = stdout.toString("utf8").trim();
25
+ if (text.length === 0) {
26
+ return null;
27
+ }
28
+
29
+ const match = text.match(/«data\s+PNGf([0-9a-fA-F\s]+)»/i);
30
+ if (!match) {
31
+ return null;
32
+ }
33
+
34
+ const hex = match[1]?.replace(/\s+/g, "") ?? "";
35
+ if (hex.length === 0 || hex.length % 2 !== 0) {
36
+ return null;
37
+ }
38
+
39
+ const bytes = Buffer.from(hex, "hex");
40
+ return bytes.length > 0 ? new Uint8Array(bytes) : null;
41
+ }
42
+
43
+ export class OsascriptPngfProvider implements ClipboardImageProvider {
44
+ readonly capabilities;
45
+ private readonly commandRunner: CommandRunner;
46
+ private readonly commandExists: CommandExists;
47
+
48
+ constructor(options: OsascriptPngfProviderOptions = {}) {
49
+ this.capabilities = {
50
+ id: "mac-osascript-pngf",
51
+ name: "osascript PNGf",
52
+ platforms: ["darwin"],
53
+ priority: options.priority ?? 30,
54
+ };
55
+ this.commandRunner = options.commandRunner ?? defaultCommandRunner;
56
+ this.commandExists = options.commandExists ?? defaultCommandExists;
57
+ }
58
+
59
+ isAvailable(context: ClipboardProviderContext): boolean {
60
+ try {
61
+ return this.commandExists("osascript", context);
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
66
+
67
+ read(context: ClipboardProviderContext): ClipboardReadResult {
68
+ const wrapped = buildNamespaceWrappedCommand(
69
+ "osascript",
70
+ ["-e", PNGF_SCRIPT],
71
+ context,
72
+ this.commandExists,
73
+ );
74
+ const result = this.commandRunner(wrapped.command, wrapped.args, {
75
+ environment: context.environment,
76
+ maxBuffer: MAX_BUFFER_BYTES,
77
+ timeout: READ_TIMEOUT_MS,
78
+ });
79
+
80
+ if (result.missingCommand) {
81
+ return { available: false, image: null };
82
+ }
83
+
84
+ if (!result.ok || result.stdout.length === 0) {
85
+ return { available: true, image: null };
86
+ }
87
+
88
+ const bytes = parseAppleScriptPngfData(result.stdout);
89
+ if (!bytes) {
90
+ return { available: true, image: null };
91
+ }
92
+
93
+ return {
94
+ available: true,
95
+ image: {
96
+ bytes,
97
+ mimeType: "image/png",
98
+ },
99
+ };
100
+ }
101
+ }