@pi-archimedes/image-paste 0.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/package.json ADDED
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "@pi-archimedes/image-paste",
3
+ "version": "0.2.0",
4
+ "type": "module",
5
+ "keywords": [
6
+ "pi-package"
7
+ ],
8
+ "description": "Clipboard image paste for pi-archimedes",
9
+ "files": [
10
+ "src"
11
+ ],
12
+ "main": "./src/index.ts",
13
+ "peerDependencies": {
14
+ "@earendil-works/pi-coding-agent": ">=0.1.0",
15
+ "@earendil-works/pi-tui": ">=0.1.0"
16
+ },
17
+ "devDependencies": {
18
+ "typescript": "^6.0.0"
19
+ },
20
+ "pi": {
21
+ "extensions": [
22
+ "./src/index.ts"
23
+ ]
24
+ }
25
+ }
@@ -0,0 +1,417 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { createRequire } from "node:module";
3
+
4
+ import type { ClipboardImage } from "./types.js";
5
+
6
+ const require = createRequire(import.meta.url);
7
+
8
+ const LIST_TYPES_TIMEOUT_MS = 1000;
9
+ const READ_TIMEOUT_MS = 5000;
10
+ const MAX_BUFFER_BYTES = 50 * 1024 * 1024;
11
+ const SUPPORTED_IMAGE_MIME_TYPES = [
12
+ "image/png",
13
+ "image/jpeg",
14
+ "image/webp",
15
+ "image/gif",
16
+ "image/bmp",
17
+ ] as const;
18
+
19
+ let cachedClipboardModule: ClipboardModule | null | undefined;
20
+
21
+ interface ClipboardModule {
22
+ hasImage: () => boolean;
23
+ getImageBinary: () => Promise<Array<number> | Uint8Array>;
24
+ }
25
+
26
+ interface CommandResult {
27
+ ok: boolean;
28
+ stdout: Buffer;
29
+ missingCommand: boolean;
30
+ }
31
+
32
+ interface ClipboardReadResult {
33
+ available: boolean;
34
+ image: ClipboardImage | null;
35
+ }
36
+
37
+ function isErrnoException(error: Error): error is NodeJS.ErrnoException {
38
+ return "code" in error;
39
+ }
40
+
41
+ function hasGraphicalSession(platform: NodeJS.Platform, environment: NodeJS.ProcessEnv): boolean {
42
+ return platform !== "linux" || Boolean(environment.DISPLAY || environment.WAYLAND_DISPLAY);
43
+ }
44
+
45
+ function isWaylandSession(environment: NodeJS.ProcessEnv): boolean {
46
+ return Boolean(environment.WAYLAND_DISPLAY) || environment.XDG_SESSION_TYPE === "wayland";
47
+ }
48
+
49
+ function normalizeMimeType(mimeType: string): string {
50
+ return mimeType.split(";")[0]?.trim().toLowerCase() ?? mimeType.toLowerCase();
51
+ }
52
+
53
+ function selectPreferredImageMimeType(mimeTypes: readonly string[]): string | null {
54
+ const normalized = mimeTypes
55
+ .map((mimeType) => mimeType.trim())
56
+ .filter((mimeType) => mimeType.length > 0)
57
+ .map((mimeType) => ({ raw: mimeType, normalized: normalizeMimeType(mimeType) }));
58
+
59
+ for (const preferredMimeType of SUPPORTED_IMAGE_MIME_TYPES) {
60
+ const match = normalized.find((mimeType) => mimeType.normalized === preferredMimeType);
61
+ if (match) {
62
+ return match.raw;
63
+ }
64
+ }
65
+
66
+ const firstImage = normalized.find((mimeType) => mimeType.normalized.startsWith("image/"));
67
+ return firstImage?.raw ?? null;
68
+ }
69
+
70
+ function loadClipboardModule(
71
+ platform: NodeJS.Platform = process.platform,
72
+ environment: NodeJS.ProcessEnv = process.env,
73
+ ): ClipboardModule | null {
74
+ if (cachedClipboardModule !== undefined) {
75
+ return cachedClipboardModule;
76
+ }
77
+
78
+ if (environment.TERMUX_VERSION || !hasGraphicalSession(platform, environment)) {
79
+ cachedClipboardModule = null;
80
+ return cachedClipboardModule;
81
+ }
82
+
83
+ try {
84
+ cachedClipboardModule = require("@mariozechner/clipboard") as ClipboardModule;
85
+ } catch {
86
+ cachedClipboardModule = null;
87
+ }
88
+
89
+ return cachedClipboardModule;
90
+ }
91
+
92
+ async function readClipboardImageViaNativeModule(
93
+ platform: NodeJS.Platform,
94
+ environment: NodeJS.ProcessEnv,
95
+ ): Promise<ClipboardReadResult> {
96
+ const clipboard = loadClipboardModule(platform, environment);
97
+ if (!clipboard) {
98
+ return { available: false, image: null };
99
+ }
100
+
101
+ if (!clipboard.hasImage()) {
102
+ return { available: true, image: null };
103
+ }
104
+
105
+ const imageData = await clipboard.getImageBinary();
106
+ if (!imageData || imageData.length === 0) {
107
+ return { available: true, image: null };
108
+ }
109
+
110
+ const bytes = imageData instanceof Uint8Array ? imageData : Uint8Array.from(imageData);
111
+ return {
112
+ available: true,
113
+ image: {
114
+ bytes,
115
+ mimeType: "image/png",
116
+ },
117
+ };
118
+ }
119
+
120
+ function runCommand(
121
+ command: string,
122
+ args: string[],
123
+ timeout: number,
124
+ ): CommandResult {
125
+ const result = spawnSync(command, args, {
126
+ timeout,
127
+ maxBuffer: MAX_BUFFER_BYTES,
128
+ });
129
+
130
+ if (result.error) {
131
+ return {
132
+ ok: false,
133
+ stdout: Buffer.alloc(0),
134
+ missingCommand: isErrnoException(result.error) && result.error.code === "ENOENT",
135
+ };
136
+ }
137
+
138
+ const stdout = Buffer.isBuffer(result.stdout)
139
+ ? result.stdout
140
+ : Buffer.from(result.stdout ?? "", typeof result.stdout === "string" ? "utf8" : undefined);
141
+
142
+ return {
143
+ ok: result.status === 0,
144
+ stdout,
145
+ missingCommand: false,
146
+ };
147
+ }
148
+
149
+ function encodePowerShell(script: string): string {
150
+ return Buffer.from(script, "utf16le").toString("base64");
151
+ }
152
+
153
+ function readClipboardImageViaPowerShell(): ClipboardReadResult {
154
+ const script = `
155
+ $ErrorActionPreference = 'Stop'
156
+ Add-Type -AssemblyName System.Windows.Forms
157
+ Add-Type -AssemblyName System.Drawing
158
+
159
+ if (-not [System.Windows.Forms.Clipboard]::ContainsImage()) {
160
+ return
161
+ }
162
+
163
+ $image = [System.Windows.Forms.Clipboard]::GetImage()
164
+ if ($null -eq $image) {
165
+ return
166
+ }
167
+
168
+ $stream = New-Object System.IO.MemoryStream
169
+ try {
170
+ $image.Save($stream, [System.Drawing.Imaging.ImageFormat]::Png)
171
+ [System.Convert]::ToBase64String($stream.ToArray())
172
+ } finally {
173
+ $stream.Dispose()
174
+ $image.Dispose()
175
+ }
176
+ `;
177
+
178
+ const result = spawnSync(
179
+ "powershell.exe",
180
+ [
181
+ "-NoProfile",
182
+ "-NonInteractive",
183
+ "-ExecutionPolicy",
184
+ "Bypass",
185
+ "-STA",
186
+ "-EncodedCommand",
187
+ encodePowerShell(script),
188
+ ],
189
+ {
190
+ encoding: "utf8",
191
+ timeout: READ_TIMEOUT_MS,
192
+ maxBuffer: MAX_BUFFER_BYTES,
193
+ windowsHide: true,
194
+ },
195
+ );
196
+
197
+ if (result.error) {
198
+ return {
199
+ available: !isErrnoException(result.error) || result.error.code !== "ENOENT",
200
+ image: null,
201
+ };
202
+ }
203
+
204
+ if (result.status !== 0) {
205
+ return { available: true, image: null };
206
+ }
207
+
208
+ const base64 = result.stdout.trim();
209
+ if (!base64) {
210
+ return { available: true, image: null };
211
+ }
212
+
213
+ try {
214
+ const bytes = Buffer.from(base64, "base64");
215
+ if (bytes.length === 0) {
216
+ return { available: true, image: null };
217
+ }
218
+
219
+ return {
220
+ available: true,
221
+ image: {
222
+ bytes: new Uint8Array(bytes),
223
+ mimeType: "image/png",
224
+ },
225
+ };
226
+ } catch {
227
+ return { available: true, image: null };
228
+ }
229
+ }
230
+
231
+ function readClipboardImageViaWlPaste(): ClipboardReadResult {
232
+ const listTypes = runCommand("wl-paste", ["--list-types"], LIST_TYPES_TIMEOUT_MS);
233
+ if (listTypes.missingCommand) {
234
+ return { available: false, image: null };
235
+ }
236
+
237
+ if (!listTypes.ok) {
238
+ return { available: true, image: null };
239
+ }
240
+
241
+ const mimeTypes = listTypes.stdout
242
+ .toString("utf8")
243
+ .split(/\r?\n/)
244
+ .map((mimeType) => mimeType.trim())
245
+ .filter((mimeType) => mimeType.length > 0);
246
+
247
+ const selectedMimeType = selectPreferredImageMimeType(mimeTypes);
248
+ if (!selectedMimeType) {
249
+ return { available: true, image: null };
250
+ }
251
+
252
+ const imageData = runCommand(
253
+ "wl-paste",
254
+ ["--type", selectedMimeType, "--no-newline"],
255
+ READ_TIMEOUT_MS,
256
+ );
257
+
258
+ if (!imageData.ok || imageData.stdout.length === 0) {
259
+ return { available: true, image: null };
260
+ }
261
+
262
+ return {
263
+ available: true,
264
+ image: {
265
+ bytes: new Uint8Array(imageData.stdout),
266
+ mimeType: normalizeMimeType(selectedMimeType),
267
+ },
268
+ };
269
+ }
270
+
271
+ function readClipboardImageViaXclip(): ClipboardReadResult {
272
+ const targets = runCommand(
273
+ "xclip",
274
+ ["-selection", "clipboard", "-t", "TARGETS", "-o"],
275
+ LIST_TYPES_TIMEOUT_MS,
276
+ );
277
+
278
+ if (targets.missingCommand) {
279
+ return { available: false, image: null };
280
+ }
281
+
282
+ const advertisedMimeTypes = targets.ok
283
+ ? targets.stdout
284
+ .toString("utf8")
285
+ .split(/\r?\n/)
286
+ .map((mimeType) => mimeType.trim())
287
+ .filter((mimeType) => mimeType.length > 0)
288
+ : [];
289
+
290
+ const preferredMimeType =
291
+ advertisedMimeTypes.length > 0 ? selectPreferredImageMimeType(advertisedMimeTypes) : null;
292
+ const mimeTypesToTry = preferredMimeType
293
+ ? [preferredMimeType, ...SUPPORTED_IMAGE_MIME_TYPES]
294
+ : [...SUPPORTED_IMAGE_MIME_TYPES];
295
+
296
+ for (const mimeType of mimeTypesToTry) {
297
+ const imageData = runCommand(
298
+ "xclip",
299
+ ["-selection", "clipboard", "-t", mimeType, "-o"],
300
+ READ_TIMEOUT_MS,
301
+ );
302
+
303
+ if (imageData.ok && imageData.stdout.length > 0) {
304
+ return {
305
+ available: true,
306
+ image: {
307
+ bytes: new Uint8Array(imageData.stdout),
308
+ mimeType: normalizeMimeType(mimeType),
309
+ },
310
+ };
311
+ }
312
+ }
313
+
314
+ return { available: true, image: null };
315
+ }
316
+
317
+ function getUnavailableReaderMessage(platform: NodeJS.Platform): string {
318
+ switch (platform) {
319
+ case "linux":
320
+ return "No Linux clipboard image reader is available. Install wl-clipboard or xclip, or ensure @mariozechner/clipboard is installed.";
321
+ case "darwin":
322
+ return "No macOS clipboard image reader is available. Ensure @mariozechner/clipboard is installed.";
323
+ case "win32":
324
+ return "No Windows clipboard image reader is available. Ensure PowerShell is available or @mariozechner/clipboard is installed.";
325
+ default:
326
+ return `Clipboard image paste is not supported on platform: ${platform}`;
327
+ }
328
+ }
329
+
330
+ export async function readClipboardImage(options?: {
331
+ environment?: NodeJS.ProcessEnv;
332
+ platform?: NodeJS.Platform;
333
+ }): Promise<ClipboardImage | null> {
334
+ const environment = options?.environment ?? process.env;
335
+ const platform = options?.platform ?? process.platform;
336
+
337
+ if (environment.TERMUX_VERSION) {
338
+ return null;
339
+ }
340
+
341
+ if (!hasGraphicalSession(platform, environment)) {
342
+ throw new Error("Clipboard image paste requires a graphical desktop session with DISPLAY or WAYLAND_DISPLAY.");
343
+ }
344
+
345
+ const readerResults: ClipboardReadResult[] = [];
346
+
347
+ const recordResult = (result: ClipboardReadResult): ClipboardImage | null => {
348
+ readerResults.push(result);
349
+ return result.image;
350
+ };
351
+
352
+ if (platform === "win32") {
353
+ const nativeImage = recordResult(await readClipboardImageViaNativeModule(platform, environment));
354
+ if (nativeImage) {
355
+ return nativeImage;
356
+ }
357
+
358
+ const powerShellImage = recordResult(readClipboardImageViaPowerShell());
359
+ if (powerShellImage) {
360
+ return powerShellImage;
361
+ }
362
+ } else if (platform === "linux") {
363
+ const sessionReaders = isWaylandSession(environment)
364
+ ? [readClipboardImageViaWlPaste, readClipboardImageViaXclip]
365
+ : [readClipboardImageViaXclip, readClipboardImageViaWlPaste];
366
+
367
+ for (const reader of sessionReaders) {
368
+ const image = recordResult(reader());
369
+ if (image) {
370
+ return image;
371
+ }
372
+ }
373
+
374
+ const nativeImage = recordResult(await readClipboardImageViaNativeModule(platform, environment));
375
+ if (nativeImage) {
376
+ return nativeImage;
377
+ }
378
+ } else {
379
+ const nativeImage = recordResult(await readClipboardImageViaNativeModule(platform, environment));
380
+ if (nativeImage) {
381
+ return nativeImage;
382
+ }
383
+ }
384
+
385
+ if (readerResults.some((result) => result.available)) {
386
+ return null;
387
+ }
388
+
389
+ throw new Error(getUnavailableReaderMessage(platform));
390
+ }
391
+
392
+ import { existsSync } from "node:fs";
393
+ import { readFile } from "node:fs/promises";
394
+
395
+ const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"]);
396
+
397
+ export function isImageFilePath(path: string): boolean {
398
+ return IMAGE_EXTENSIONS.has(path.toLowerCase().split(".").pop() ?? "");
399
+ }
400
+
401
+ export async function readFileAsImage(filePath: string): Promise<ClipboardImage | null> {
402
+ if (!existsSync(filePath)) return null;
403
+ const ext = filePath.toLowerCase().split(".").pop() ?? "";
404
+ const mimeMap: Record<string, string> = {
405
+ png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg",
406
+ webp: "image/webp", gif: "image/gif", bmp: "image/bmp",
407
+ };
408
+ const mimeType = mimeMap[ext];
409
+ if (!mimeType) return null;
410
+ try {
411
+ const bytes = await readFile(filePath);
412
+ if (bytes.length === 0) return null;
413
+ return { bytes: new Uint8Array(bytes), mimeType };
414
+ } catch {
415
+ return null;
416
+ }
417
+ }
package/src/index.ts ADDED
@@ -0,0 +1,190 @@
1
+ import { randomUUID } from "node:crypto";
2
+
3
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+
5
+ import type { KeyId } from "@earendil-works/pi-tui";
6
+
7
+ import { readClipboardImage } from "./clipboard.js";
8
+ import { registerImagePreview, sendPreviewMessage } from "./preview.js";
9
+ import type { ClipboardImage, PendingImage, ImageMarker } from "./types.js";
10
+
11
+ const MAX_FILE_SIZE_BYTES = 20 * 1024 * 1024; // 20MB
12
+
13
+ // ── Queue management ────────────────────────────────────────────
14
+
15
+ interface ImageQueue {
16
+ images: PendingImage[];
17
+ markers: ImageMarker[];
18
+ nextIndex: number;
19
+ }
20
+
21
+ function createImageQueue(): ImageQueue {
22
+ return { images: [], markers: [], nextIndex: 1 };
23
+ }
24
+
25
+ // Marker key: the visible text WITHOUT trailing space (e.g. "[Image #1]")
26
+ // This is used for both detection and replacement — consistent strategy.
27
+ function markerKey(marker: ImageMarker): string {
28
+ return marker.text.trim();
29
+ }
30
+
31
+ function queueImage(
32
+ queue: ImageQueue,
33
+ image: ClipboardImage,
34
+ ctx: ExtensionContext,
35
+ ): ImageMarker {
36
+ const id = randomUUID();
37
+ const placeholder = `[Image #${queue.nextIndex}] `;
38
+
39
+ const pending: PendingImage = {
40
+ id,
41
+ base64: Buffer.from(image.bytes).toString("base64"),
42
+ mimeType: image.mimeType,
43
+ };
44
+ queue.images.push(pending);
45
+
46
+ const marker: ImageMarker = {
47
+ id,
48
+ text: placeholder,
49
+ index: queue.nextIndex,
50
+ };
51
+ queue.markers.push(marker);
52
+ queue.nextIndex += 1;
53
+
54
+ // Insert placeholder into editor
55
+ ctx.ui.pasteToEditor(placeholder);
56
+ return marker;
57
+ }
58
+
59
+ // ── Registration ────────────────────────────────────────────────
60
+
61
+ function getImagePasteShortcuts(): KeyId[] {
62
+ if (process.platform === "win32") {
63
+ return ["alt+v", "ctrl+alt+v"] as KeyId[];
64
+ }
65
+ return ["ctrl+v", "alt+v", "ctrl+alt+v"] as KeyId[];
66
+ }
67
+
68
+ // Module-level state — registered once, queue reset per session
69
+ let _ctx: ExtensionContext | null = null;
70
+ let _queue: ImageQueue | null = null;
71
+ let _pasting = false;
72
+
73
+ export function registerImagePaste(pi: ExtensionAPI): void {
74
+ // Register preview renderer (once)
75
+ registerImagePreview(pi);
76
+
77
+ // Register shortcuts (once)
78
+ const pasteImage = async (): Promise<void> => {
79
+ if (_pasting) return; // Mutex: prevent concurrent paste operations
80
+ if (!_ctx || !_queue || !_ctx.hasUI) return;
81
+ _pasting = true;
82
+ try {
83
+ const image = await readClipboardImage();
84
+ if (!image) {
85
+ _ctx.ui.notify("No image found in clipboard.", "warning");
86
+ return;
87
+ }
88
+ if (image.bytes.length > MAX_FILE_SIZE_BYTES) {
89
+ _ctx.ui.notify(
90
+ `Image too large (${(image.bytes.length / 1024 / 1024).toFixed(1)}MB > 20MB).`,
91
+ "warning",
92
+ );
93
+ return;
94
+ }
95
+ queueImage(_queue, image, _ctx);
96
+ _ctx.ui.notify("Image attached from clipboard.", "info");
97
+ } catch (error) {
98
+ const msg = error instanceof Error ? error.message : "Unknown error";
99
+ _ctx.ui.notify(`Image paste failed: ${msg}`, "warning");
100
+ } finally {
101
+ _pasting = false;
102
+ }
103
+ };
104
+
105
+ for (const shortcut of getImagePasteShortcuts()) {
106
+ pi.registerShortcut(shortcut, {
107
+ description: "Attach clipboard image to draft",
108
+ handler: pasteImage,
109
+ });
110
+ }
111
+
112
+ // Input event handler — attach images on submit (once)
113
+ pi.on("input", async (event) => {
114
+ if (event.source === "extension") {
115
+ return { action: "continue" as const };
116
+ }
117
+ if (!_queue || !_queue.markers.length || !_ctx) {
118
+ return { action: "continue" as const };
119
+ }
120
+
121
+ // ── Marker matching: use consistent key (trimmed text) ──
122
+ let hasMarkers = false;
123
+ for (const marker of _queue.markers) {
124
+ if (event.text.includes(markerKey(marker))) {
125
+ hasMarkers = true;
126
+ break;
127
+ }
128
+ }
129
+
130
+ if (!hasMarkers) {
131
+ // No markers found — clear queue (user removed them)
132
+ _queue.images.length = 0;
133
+ _queue.markers.length = 0;
134
+ _queue.nextIndex = 1;
135
+ return { action: "continue" as const };
136
+ }
137
+
138
+ // Match markers to images using trimmed key
139
+ const imagesToAttach: PendingImage[] = [];
140
+
141
+ for (const marker of _queue.markers) {
142
+ const key = markerKey(marker);
143
+ if (event.text.includes(key)) {
144
+ const pending = _queue.images.find((img) => img.id === marker.id);
145
+ if (pending) {
146
+ imagesToAttach.push(pending);
147
+ }
148
+ }
149
+ }
150
+
151
+ // Clear queue
152
+ _queue.images.length = 0;
153
+ _queue.markers.length = 0;
154
+ _queue.nextIndex = 1;
155
+
156
+ if (imagesToAttach.length === 0) {
157
+ return { action: "continue" as const };
158
+ }
159
+
160
+ // Send preview message for TUI display (images are invisible in user messages)
161
+ try {
162
+ sendPreviewMessage(pi, imagesToAttach);
163
+ } catch {
164
+ // Preview is optional — don't fail the submit
165
+ }
166
+
167
+ return {
168
+ action: "transform" as const,
169
+ text: event.text,
170
+ images: imagesToAttach.map((img) => ({
171
+ type: "image" as const,
172
+ data: img.base64,
173
+ mimeType: img.mimeType,
174
+ })),
175
+ };
176
+ });
177
+ }
178
+
179
+ // Called on session_start to initialize/reset the queue
180
+ export function initImagePasteSession(ctx: ExtensionContext): void {
181
+ _ctx = ctx;
182
+ _queue = createImageQueue();
183
+ }
184
+
185
+ // Called on session_shutdown to clear state
186
+ export function shutdownImagePaste(): void {
187
+ _ctx = null;
188
+ _queue = null;
189
+ _pasting = false;
190
+ }
package/src/preview.ts ADDED
@@ -0,0 +1,66 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { Container, Image, Spacer, Text } from "@earendil-works/pi-tui";
3
+
4
+ import type { PendingImage } from "./types.js";
5
+
6
+ const CUSTOM_TYPE = "hephaestus-image-preview";
7
+
8
+ interface PreviewDetails {
9
+ images: Array<{ data: string; mimeType: string }>;
10
+ }
11
+
12
+ export function registerImagePreview(pi: ExtensionAPI): void {
13
+ pi.registerMessageRenderer<PreviewDetails>(CUSTOM_TYPE, (message, _options, theme) => {
14
+ try {
15
+ // Theme.fg is runtime-available but not exposed in the Theme type definition
16
+ const fg = (theme as any).fg as ((color: string, text: string) => string) | undefined;
17
+ if (!fg) return undefined;
18
+
19
+ // Extract image data from content array
20
+ const content = message.content;
21
+ if (!Array.isArray(content) || content.length === 0) return undefined;
22
+
23
+ const container = new Container();
24
+ container.addChild(new Spacer(1));
25
+
26
+ for (const item of content) {
27
+ if (item.type === "image") {
28
+ container.addChild(new Spacer(1));
29
+ container.addChild(
30
+ new Image(item.data, item.mimeType, {
31
+ fallbackColor: (text: string) => fg("toolOutput", text),
32
+ }, {
33
+ maxWidthCells: 60,
34
+ }),
35
+ );
36
+ }
37
+ }
38
+
39
+ return container;
40
+ } catch {
41
+ return undefined;
42
+ }
43
+ });
44
+ }
45
+
46
+ export function sendPreviewMessage(
47
+ pi: ExtensionAPI,
48
+ images: PendingImage[],
49
+ ): void {
50
+ if (images.length === 0) return;
51
+
52
+ // Send each image as a separate custom message with image in content
53
+ // This ensures pi renders the image inline using its built-in image support
54
+ for (const img of images) {
55
+ pi.sendMessage(
56
+ {
57
+ customType: CUSTOM_TYPE,
58
+ content: [
59
+ { type: "image" as const, data: img.base64, mimeType: img.mimeType },
60
+ ],
61
+ display: true,
62
+ },
63
+ { triggerTurn: false },
64
+ );
65
+ }
66
+ }
package/src/types.ts ADDED
@@ -0,0 +1,16 @@
1
+ export interface ClipboardImage {
2
+ bytes: Uint8Array;
3
+ mimeType: string; // "image/png", "image/jpeg", "image/webp", "image/gif"
4
+ }
5
+
6
+ export interface PendingImage {
7
+ id: string; // UUID — unique per paste operation
8
+ base64: string;
9
+ mimeType: string;
10
+ }
11
+
12
+ export interface ImageMarker {
13
+ id: string; // matches PendingImage.id
14
+ text: string; // "[Image #N]" — the exact text inserted
15
+ index: number; // 1-based insertion order (for N in "[Image #N]")
16
+ }