pi-paster 0.1.5 → 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/dist/index.d.mts CHANGED
@@ -23,6 +23,8 @@ declare function resolvePasterConfig(config?: PasterConfig): ResolvedPasterConfi
23
23
  //#region src/types.d.ts
24
24
  declare const EXTENSION_NAME = "paster";
25
25
  declare const MAX_IMAGE_BYTES: number;
26
+ declare const ANTHROPIC_MAX_DIMENSION = 8000;
27
+ declare const ANTHROPIC_MAX_IMAGE_BYTES: number;
26
28
  type SupportedImageMimeType = "image/png" | "image/jpeg" | "image/webp" | "image/gif";
27
29
  interface ImageAttachment {
28
30
  id: number;
@@ -32,6 +34,14 @@ interface ImageAttachment {
32
34
  data: string;
33
35
  dimensions?: ImageDimensions;
34
36
  createdAt: number;
37
+ /** True once optimizeImageBytes has run on this attachment. */
38
+ optimized?: boolean;
39
+ /** Original (pre-optimization) base64 size in bytes — informational. */
40
+ originalBytes?: number;
41
+ /** Final (post-optimization) base64 size in bytes — informational. */
42
+ finalBytes?: number;
43
+ /** Human-readable trail of optimization actions applied, if any. */
44
+ optimizeActions?: string[];
35
45
  }
36
46
  interface LoadedImage {
37
47
  originalPath: string;
@@ -66,6 +76,25 @@ type ClipboardImageResult = {
66
76
  };
67
77
  declare function readClipboardImage(maxBytes?: number): ClipboardImageResult;
68
78
  //#endregion
79
+ //#region src/optimize-image.d.ts
80
+ interface OptimizeResult {
81
+ data: string;
82
+ mimeType: SupportedImageMimeType;
83
+ originalBytes: number;
84
+ finalBytes: number;
85
+ originalDim?: {
86
+ width: number;
87
+ height: number;
88
+ };
89
+ finalDim?: {
90
+ width: number;
91
+ height: number;
92
+ };
93
+ actions: string[];
94
+ changed: boolean;
95
+ }
96
+ declare function optimizeImageBytes(input: Buffer, mime: SupportedImageMimeType): Promise<OptimizeResult>;
97
+ //#endregion
69
98
  //#region src/store.d.ts
70
99
  declare class AttachmentStore {
71
100
  private nextId;
@@ -110,8 +139,14 @@ interface PathToken {
110
139
  value: string;
111
140
  start: number;
112
141
  end: number;
142
+ bare: boolean;
113
143
  }
114
144
  declare function detectImageMimeType(bytes: Uint8Array): SupportedImageMimeType | undefined;
145
+ declare function isWindowsDrivePath(value: string): boolean;
146
+ declare function isWindowsUncPath(value: string): boolean;
147
+ declare function isWindowsLikePath(value: string): boolean;
148
+ declare function isWsl(): boolean;
149
+ declare function windowsToWslPath(windowsPath: string): string;
115
150
  declare function resolveImagePath(input: string, cwd: string): string;
116
151
  declare function shellUnescape(input: string): string;
117
152
  declare function tokenizePathLikeText(text: string): PathToken[];
@@ -130,6 +165,19 @@ declare function replaceImagePathsInText(text: string, options: {
130
165
  accepted: ImageAttachment[];
131
166
  };
132
167
  declare function imagesForText(store: AttachmentStore, text: string, existing?: PasterImageContent[]): PasterImageContent[];
168
+ /**
169
+ * Async variant of imagesForText that runs each attachment through the
170
+ * Anthropic-aware image optimizer (resize to 8000px cap, JPEG ladder to stay
171
+ * under the 5 MB / 32 MB request caps). Optimization is cached on the
172
+ * attachment so the cost is paid once per image, not per submit.
173
+ *
174
+ * Used by paster's `input` handler; safe to await on the hot path because
175
+ * sharp is only invoked when the image is actually over the limits.
176
+ */
177
+ declare function imagesForTextOptimized(store: AttachmentStore, text: string, existing?: PasterImageContent[]): Promise<PasterImageContent[]>;
178
+ declare function describeReject(result: Exclude<LoadImageResult, {
179
+ ok: true;
180
+ }>, notify?: (message: string) => void): void;
133
181
  //#endregion
134
182
  //#region src/preview.d.ts
135
183
  declare class ImagePreviewMessage implements Component {
@@ -174,4 +222,4 @@ declare function createImagePasteTerminalInputHandler(options: {
174
222
  declare function createPaster(config?: PasterConfig): (pi: ExtensionAPI) => void;
175
223
  declare function paster(pi: ExtensionAPI, config?: PasterConfig): void;
176
224
  //#endregion
177
- export { AttachmentStore, ClipboardImageResult, CursorImagePreviewWidget, DEFAULT_PASTER_CONFIG, EXTENSION_NAME, ImageAttachment, ImagePreviewMessage, LoadImageResult, LoadedImage, MAX_IMAGE_BYTES, PASTE_END, PASTE_START, PasterConfig, PasterEditor, PasterImageContent, PasterPreviewDetails, ResolvedPasterConfig, SupportedImageMimeType, TerminalInputResult, createImagePasteTerminalInputHandler, createPaster, paster as default, detectImageMimeType, dimensionsForImage, imagesForText, loadImageFromPath, readClipboardImage, replaceImagePathsInText, resolveImagePath, resolvePasterConfig, shellUnescape, tokenizePathLikeText };
225
+ export { ANTHROPIC_MAX_DIMENSION, ANTHROPIC_MAX_IMAGE_BYTES, AttachmentStore, ClipboardImageResult, CursorImagePreviewWidget, DEFAULT_PASTER_CONFIG, EXTENSION_NAME, ImageAttachment, ImagePreviewMessage, LoadImageResult, LoadedImage, MAX_IMAGE_BYTES, OptimizeResult, PASTE_END, PASTE_START, PasterConfig, PasterEditor, PasterImageContent, PasterPreviewDetails, ResolvedPasterConfig, SupportedImageMimeType, TerminalInputResult, createImagePasteTerminalInputHandler, createPaster, paster as default, describeReject, detectImageMimeType, dimensionsForImage, imagesForText, imagesForTextOptimized, isWindowsDrivePath, isWindowsLikePath, isWindowsUncPath, isWsl, loadImageFromPath, optimizeImageBytes, readClipboardImage, replaceImagePathsInText, resolveImagePath, resolvePasterConfig, shellUnescape, tokenizePathLikeText, windowsToWslPath };
package/dist/index.mjs CHANGED
@@ -3,22 +3,233 @@ import { randomUUID } from "node:crypto";
3
3
  import { existsSync, readFileSync, statSync, unlinkSync } from "node:fs";
4
4
  import { homedir, tmpdir } from "node:os";
5
5
  import { isAbsolute, join, resolve } from "node:path";
6
+ import { platform } from "node:process";
6
7
  import { Image, getCellDimensions, getImageDimensions, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
7
8
  import { CustomEditor } from "@earendil-works/pi-coding-agent";
8
9
  //#region src/types.ts
9
10
  const EXTENSION_NAME = "paster";
10
- const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
11
+ const MAX_IMAGE_BYTES = 64 * 1024 * 1024;
12
+ const ANTHROPIC_MAX_DIMENSION = 8e3;
13
+ const ANTHROPIC_MAX_IMAGE_BYTES = 5 * 1024 * 1024;
14
+ //#endregion
15
+ //#region src/optimize-image.ts
16
+ let _sharp;
17
+ async function getSharp() {
18
+ if (_sharp !== void 0) return _sharp;
19
+ try {
20
+ const mod = await import("sharp");
21
+ const fn = typeof mod === "function" ? mod : mod?.default;
22
+ _sharp = typeof fn === "function" ? fn : null;
23
+ } catch {
24
+ _sharp = null;
25
+ }
26
+ return _sharp;
27
+ }
28
+ const SHRINK_LADDER = [
29
+ 6e3,
30
+ 4e3,
31
+ 3e3,
32
+ 2e3
33
+ ];
34
+ const JPEG_QUALITY_LADDER = [
35
+ 95,
36
+ 90,
37
+ 85,
38
+ 80,
39
+ 70,
40
+ 60
41
+ ];
42
+ async function optimizeImageBytes(input, mime) {
43
+ const originalBytes = input.length;
44
+ const noop = () => ({
45
+ data: input.toString("base64"),
46
+ mimeType: mime,
47
+ originalBytes,
48
+ finalBytes: originalBytes,
49
+ actions: [],
50
+ changed: false
51
+ });
52
+ if (originalBytes <= 5242880) {
53
+ if (originalBytes <= 256 * 1024) return noop();
54
+ }
55
+ const sharp = await getSharp();
56
+ if (!sharp) return noop();
57
+ const meta = await sharp(input).metadata();
58
+ const origW = meta.width ?? 0;
59
+ const origH = meta.height ?? 0;
60
+ if (!origW || !origH) return noop();
61
+ const actions = [];
62
+ const originalDim = {
63
+ width: origW,
64
+ height: origH
65
+ };
66
+ let workBuf = input;
67
+ let workW = origW;
68
+ let workH = origH;
69
+ if (workW > 8e3 || workH > 8e3) {
70
+ workBuf = await sharp(workBuf).resize({
71
+ width: workW >= workH ? ANTHROPIC_MAX_DIMENSION : void 0,
72
+ height: workH > workW ? ANTHROPIC_MAX_DIMENSION : void 0,
73
+ fit: "inside",
74
+ withoutEnlargement: true
75
+ }).toBuffer();
76
+ const m = await sharp(workBuf).metadata();
77
+ workW = m.width ?? workW;
78
+ workH = m.height ?? workH;
79
+ actions.push(`resize to ${workW}x${workH} (8000px cap)`);
80
+ }
81
+ if (workBuf.length <= 5242880 && actions.length === 0) return noop();
82
+ if (workBuf.length <= 5242880) return {
83
+ data: workBuf.toString("base64"),
84
+ mimeType: mime,
85
+ originalBytes,
86
+ finalBytes: workBuf.length,
87
+ originalDim,
88
+ finalDim: {
89
+ width: workW,
90
+ height: workH
91
+ },
92
+ actions,
93
+ changed: true
94
+ };
95
+ let outMime = "image/jpeg";
96
+ let attempt = workBuf;
97
+ for (const q of JPEG_QUALITY_LADDER) {
98
+ attempt = await sharp(workBuf).jpeg({
99
+ quality: q,
100
+ mozjpeg: true
101
+ }).toBuffer();
102
+ if (attempt.length <= 5242880) {
103
+ actions.push(`jpeg q=${q} → ${formatBytes(attempt.length)}`);
104
+ return {
105
+ data: attempt.toString("base64"),
106
+ mimeType: outMime,
107
+ originalBytes,
108
+ finalBytes: attempt.length,
109
+ originalDim,
110
+ finalDim: {
111
+ width: workW,
112
+ height: workH
113
+ },
114
+ actions,
115
+ changed: true
116
+ };
117
+ }
118
+ }
119
+ actions.push(`jpeg q=60 still ${formatBytes(attempt.length)} — shrinking`);
120
+ for (const longEdge of SHRINK_LADDER) {
121
+ if (Math.max(workW, workH) <= longEdge) continue;
122
+ const resized = await sharp(workBuf).resize({
123
+ width: workW >= workH ? longEdge : void 0,
124
+ height: workH > workW ? longEdge : void 0,
125
+ fit: "inside",
126
+ withoutEnlargement: true
127
+ }).toBuffer();
128
+ const m = await sharp(resized).metadata();
129
+ const newW = m.width ?? workW;
130
+ const newH = m.height ?? workH;
131
+ for (const q of JPEG_QUALITY_LADDER) {
132
+ attempt = await sharp(resized).jpeg({
133
+ quality: q,
134
+ mozjpeg: true
135
+ }).toBuffer();
136
+ if (attempt.length <= 5242880) {
137
+ actions.push(`resize ${newW}x${newH} + jpeg q=${q} → ${formatBytes(attempt.length)}`);
138
+ return {
139
+ data: attempt.toString("base64"),
140
+ mimeType: outMime,
141
+ originalBytes,
142
+ finalBytes: attempt.length,
143
+ originalDim,
144
+ finalDim: {
145
+ width: newW,
146
+ height: newH
147
+ },
148
+ actions,
149
+ changed: true
150
+ };
151
+ }
152
+ }
153
+ workBuf = resized;
154
+ workW = newW;
155
+ workH = newH;
156
+ }
157
+ actions.push(`final ${formatBytes(attempt.length)} — over limit`);
158
+ return {
159
+ data: attempt.toString("base64"),
160
+ mimeType: outMime,
161
+ originalBytes,
162
+ finalBytes: attempt.length,
163
+ originalDim,
164
+ finalDim: {
165
+ width: workW,
166
+ height: workH
167
+ },
168
+ actions,
169
+ changed: true
170
+ };
171
+ }
172
+ function formatBytes(bytes) {
173
+ if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(2)}MB`;
174
+ if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)}KB`;
175
+ return `${bytes}B`;
176
+ }
11
177
  //#endregion
12
178
  //#region src/image-utils.ts
179
+ const MAX_BARE_PATH_EXTENSIONS = 8;
13
180
  function detectImageMimeType(bytes) {
14
181
  if (bytes.length >= 8 && bytes[0] === 137 && bytes[1] === 80 && bytes[2] === 78 && bytes[3] === 71 && bytes[4] === 13 && bytes[5] === 10 && bytes[6] === 26 && bytes[7] === 10) return "image/png";
15
182
  if (bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) return "image/jpeg";
16
183
  if (bytes.length >= 6 && bytes[0] === 71 && bytes[1] === 73 && bytes[2] === 70 && bytes[3] === 56 && (bytes[4] === 55 || bytes[4] === 57) && bytes[5] === 97) return "image/gif";
17
184
  if (bytes.length >= 12 && bytes[0] === 82 && bytes[1] === 73 && bytes[2] === 70 && bytes[3] === 70 && bytes[8] === 87 && bytes[9] === 69 && bytes[10] === 66 && bytes[11] === 80) return "image/webp";
18
185
  }
186
+ const WINDOWS_DRIVE_PATH = /^([a-zA-Z]):[\\/](.*)$/;
187
+ function isWindowsDrivePath(value) {
188
+ return WINDOWS_DRIVE_PATH.test(value);
189
+ }
190
+ function isWindowsUncPath(value) {
191
+ return value.startsWith("\\\\") && value.length > 2;
192
+ }
193
+ function isWindowsLikePath(value) {
194
+ return isWindowsDrivePath(value) || isWindowsUncPath(value);
195
+ }
196
+ let cachedIsWsl;
197
+ function isWsl() {
198
+ if (cachedIsWsl !== void 0) return cachedIsWsl;
199
+ if (platform !== "linux") {
200
+ cachedIsWsl = false;
201
+ return cachedIsWsl;
202
+ }
203
+ if (process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP) {
204
+ cachedIsWsl = true;
205
+ return cachedIsWsl;
206
+ }
207
+ try {
208
+ const release = readFileSync("/proc/version", "utf8");
209
+ cachedIsWsl = /microsoft|wsl/i.test(release);
210
+ } catch {
211
+ cachedIsWsl = false;
212
+ }
213
+ return cachedIsWsl;
214
+ }
215
+ function windowsToWslPath(windowsPath) {
216
+ const driveMatch = WINDOWS_DRIVE_PATH.exec(windowsPath);
217
+ if (driveMatch) {
218
+ const drive = driveMatch[1].toLowerCase();
219
+ const rest = driveMatch[2].replace(/\\/g, "/");
220
+ return rest.length > 0 ? `/mnt/${drive}/${rest}` : `/mnt/${drive}`;
221
+ }
222
+ if (isWindowsUncPath(windowsPath)) return windowsPath.replace(/\\/g, "/");
223
+ return windowsPath;
224
+ }
19
225
  function resolveImagePath(input, cwd) {
20
226
  if (input === "~") return homedir();
21
227
  if (input.startsWith("~/")) return resolve(homedir(), input.slice(2));
228
+ if (isWindowsLikePath(input)) {
229
+ if (platform === "win32") return input;
230
+ if (isWsl()) return windowsToWslPath(input);
231
+ return input;
232
+ }
22
233
  if (isAbsolute(input)) return input;
23
234
  return resolve(cwd, input);
24
235
  }
@@ -32,7 +243,12 @@ function shellUnescape(input) {
32
243
  return result;
33
244
  }
34
245
  function isPathLike(value) {
35
- return value.startsWith("/") || value.startsWith("~/") || value === "~" || value.startsWith("./") || value.startsWith("../");
246
+ return value.startsWith("/") || value.startsWith("~/") || value === "~" || value.startsWith("./") || value.startsWith("../") || isWindowsLikePath(value);
247
+ }
248
+ function startsWithWindowsPath(text, index) {
249
+ if (index + 2 < text.length && /[a-zA-Z]/.test(text[index]) && text[index + 1] === ":" && (text[index + 2] === "\\" || text[index + 2] === "/")) return true;
250
+ if (index + 1 < text.length && text[index] === "\\" && text[index + 1] === "\\") return true;
251
+ return false;
36
252
  }
37
253
  function tokenizePathLikeText(text) {
38
254
  const tokens = [];
@@ -47,11 +263,12 @@ function tokenizePathLikeText(text) {
47
263
  if (char === "'" || char === "\"") {
48
264
  const quote = char;
49
265
  index++;
266
+ const windowsMode = startsWithWindowsPath(text, index);
50
267
  let value = "";
51
268
  let closed = false;
52
269
  while (index < text.length) {
53
270
  const current = text[index];
54
- if (current === "\\" && quote === "\"" && index + 1 < text.length) {
271
+ if (!windowsMode && current === "\\" && quote === "\"" && index + 1 < text.length) {
55
272
  value += text[index + 1];
56
273
  index += 2;
57
274
  continue;
@@ -68,15 +285,17 @@ function tokenizePathLikeText(text) {
68
285
  raw: text.slice(start, index),
69
286
  value,
70
287
  start,
71
- end: index
288
+ end: index,
289
+ bare: false
72
290
  });
73
291
  continue;
74
292
  }
293
+ const windowsMode = startsWithWindowsPath(text, index);
75
294
  let rawValue = "";
76
295
  while (index < text.length) {
77
296
  const current = text[index];
78
297
  if (/\s/.test(current)) break;
79
- if (current === "\\" && index + 1 < text.length) {
298
+ if (!windowsMode && current === "\\" && index + 1 < text.length) {
80
299
  rawValue += current + text[index + 1];
81
300
  index += 2;
82
301
  continue;
@@ -84,16 +303,59 @@ function tokenizePathLikeText(text) {
84
303
  rawValue += current;
85
304
  index++;
86
305
  }
87
- const value = shellUnescape(rawValue);
306
+ const value = windowsMode ? rawValue : shellUnescape(rawValue);
88
307
  if (isPathLike(value)) tokens.push({
89
308
  raw: rawValue,
90
309
  value,
91
310
  start,
92
- end: index
311
+ end: index,
312
+ bare: true
93
313
  });
94
314
  }
95
315
  return tokens;
96
316
  }
317
+ function tryExtendBareToken(text, token, attempt) {
318
+ let value = token.value;
319
+ let end = token.end;
320
+ let lastResult = attempt(value);
321
+ if (lastResult.ok || lastResult.reason === "too-large" || !token.bare) return {
322
+ value,
323
+ end,
324
+ result: lastResult
325
+ };
326
+ let scan = end;
327
+ for (let i = 0; i < MAX_BARE_PATH_EXTENSIONS; i++) {
328
+ let wsEnd = scan;
329
+ while (wsEnd < text.length) {
330
+ const ch = text[wsEnd];
331
+ if (ch === "\n" || ch === "\r") break;
332
+ if (!/\s/.test(ch)) break;
333
+ wsEnd++;
334
+ }
335
+ if (wsEnd === scan) break;
336
+ let wordEnd = wsEnd;
337
+ while (wordEnd < text.length && !/\s/.test(text[wordEnd])) wordEnd++;
338
+ if (wordEnd === wsEnd) break;
339
+ const nextWord = shellUnescape(text.slice(wsEnd, wordEnd));
340
+ if (isPathLike(nextWord)) break;
341
+ const extendedValue = value + text.slice(scan, wsEnd) + nextWord;
342
+ const candidate = attempt(extendedValue);
343
+ scan = wordEnd;
344
+ if (candidate.ok || candidate.reason === "too-large") return {
345
+ value: extendedValue,
346
+ end: wordEnd,
347
+ result: candidate
348
+ };
349
+ value = extendedValue;
350
+ end = wordEnd;
351
+ lastResult = candidate;
352
+ }
353
+ return {
354
+ value,
355
+ end,
356
+ result: lastResult
357
+ };
358
+ }
97
359
  function dimensionsForImage(data, mimeType) {
98
360
  return getImageDimensions(data, mimeType) ?? void 0;
99
361
  }
@@ -154,15 +416,16 @@ function replaceImagePathsInText(text, options) {
154
416
  const accepted = [];
155
417
  const loadImage = options.loadImage ?? loadImageFromPath;
156
418
  for (const token of tokens) {
157
- const result = loadImage(token.value, options.cwd);
158
- if (!result.ok) {
159
- options.onReject?.(result);
419
+ if (token.start < cursor) continue;
420
+ const extended = tryExtendBareToken(text, token, (path) => loadImage(path, options.cwd));
421
+ if (!extended.result.ok) {
422
+ if (extended.result.reason === "too-large") options.onReject?.(extended.result);
160
423
  continue;
161
424
  }
162
- const attachment = options.store.add(result.image);
425
+ const attachment = options.store.add(extended.result.image);
163
426
  accepted.push(attachment);
164
427
  output += text.slice(cursor, token.start) + attachment.placeholder;
165
- cursor = token.end;
428
+ cursor = extended.end;
166
429
  replaced++;
167
430
  }
168
431
  if (replaced === 0) return {
@@ -184,14 +447,141 @@ function imagesForText(store, text, existing = []) {
184
447
  data: attachment.data
185
448
  }))];
186
449
  }
450
+ /**
451
+ * Async variant of imagesForText that runs each attachment through the
452
+ * Anthropic-aware image optimizer (resize to 8000px cap, JPEG ladder to stay
453
+ * under the 5 MB / 32 MB request caps). Optimization is cached on the
454
+ * attachment so the cost is paid once per image, not per submit.
455
+ *
456
+ * Used by paster's `input` handler; safe to await on the hot path because
457
+ * sharp is only invoked when the image is actually over the limits.
458
+ */
459
+ async function imagesForTextOptimized(store, text, existing = []) {
460
+ const attachments = store.matchingPlaceholders(text);
461
+ const optimized = [];
462
+ for (const attachment of attachments) {
463
+ if (!attachment.optimized) try {
464
+ const result = await optimizeImageBytes(Buffer.from(attachment.data, "base64"), attachment.mimeType);
465
+ if (result.changed) {
466
+ attachment.data = result.data;
467
+ attachment.mimeType = result.mimeType;
468
+ if (result.finalDim) attachment.dimensions = {
469
+ widthPx: result.finalDim.width,
470
+ heightPx: result.finalDim.height
471
+ };
472
+ }
473
+ attachment.optimized = true;
474
+ attachment.originalBytes = result.originalBytes;
475
+ attachment.finalBytes = result.finalBytes;
476
+ attachment.optimizeActions = result.actions;
477
+ } catch {
478
+ attachment.optimized = true;
479
+ }
480
+ optimized.push({
481
+ type: "image",
482
+ mimeType: attachment.mimeType,
483
+ data: attachment.data
484
+ });
485
+ }
486
+ return [...existing, ...optimized];
487
+ }
488
+ function describeReject(result, notify) {
489
+ if (!notify) return;
490
+ if (result.reason === "too-large") notify(`paster: image is too large and was not attached: ${result.path}`);
491
+ }
187
492
  //#endregion
188
493
  //#region src/clipboard.ts
189
494
  function readClipboardImage(maxBytes = MAX_IMAGE_BYTES) {
190
- if (process.platform !== "darwin") return {
495
+ if (process.platform === "darwin") return readMacOSClipboardImage(maxBytes);
496
+ if (process.platform === "win32") return readWindowsClipboardImage(maxBytes);
497
+ if (process.platform === "linux" && isWSL()) return readWindowsClipboardImage(maxBytes);
498
+ return {
191
499
  ok: false,
192
500
  reason: "unsupported-platform"
193
501
  };
194
- return readMacOSClipboardImage(maxBytes);
502
+ }
503
+ function isWSL() {
504
+ try {
505
+ return /microsoft|wsl/i.test(readFileSync("/proc/version", "utf8"));
506
+ } catch {
507
+ return false;
508
+ }
509
+ }
510
+ function resolvePowerShell() {
511
+ if (process.platform === "win32") return "powershell.exe";
512
+ for (const c of ["/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe", "/mnt/c/WINDOWS/System32/WindowsPowerShell/v1.0/powershell.exe"]) if (existsSync(c)) return c;
513
+ return "powershell.exe";
514
+ }
515
+ function readWindowsClipboardImage(maxBytes) {
516
+ const exe = resolvePowerShell();
517
+ if (!exe) return {
518
+ ok: false,
519
+ reason: "unsupported-platform"
520
+ };
521
+ const script = [
522
+ "$ErrorActionPreference = 'Stop'",
523
+ "Add-Type -AssemblyName System.Windows.Forms | Out-Null",
524
+ "Add-Type -AssemblyName System.Drawing | Out-Null",
525
+ "$img = [System.Windows.Forms.Clipboard]::GetImage()",
526
+ "if ($img -eq $null) { exit 2 }",
527
+ "$ms = New-Object System.IO.MemoryStream",
528
+ "$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)",
529
+ "[Console]::Out.Write([Convert]::ToBase64String($ms.ToArray()))"
530
+ ].join("; ");
531
+ try {
532
+ const result = spawnSync(exe, [
533
+ "-NoProfile",
534
+ "-NonInteractive",
535
+ "-STA",
536
+ "-Command",
537
+ script
538
+ ], {
539
+ timeout: 5e3,
540
+ encoding: "utf8",
541
+ maxBuffer: 64 * 1024 * 1024
542
+ });
543
+ if (result.status === 2) return {
544
+ ok: false,
545
+ reason: "empty"
546
+ };
547
+ if (result.status !== 0) return {
548
+ ok: false,
549
+ reason: "read-error"
550
+ };
551
+ const data = (result.stdout || "").trim();
552
+ if (!data) return {
553
+ ok: false,
554
+ reason: "empty"
555
+ };
556
+ const bytes = Buffer.from(data, "base64");
557
+ if (bytes.length === 0) return {
558
+ ok: false,
559
+ reason: "empty"
560
+ };
561
+ if (bytes.length > maxBytes) return {
562
+ ok: false,
563
+ reason: "too-large"
564
+ };
565
+ const mimeType = detectImageMimeType(bytes);
566
+ if (!mimeType) return {
567
+ ok: false,
568
+ reason: "unsupported"
569
+ };
570
+ return {
571
+ ok: true,
572
+ image: {
573
+ originalPath: "clipboard.png",
574
+ mimeType,
575
+ data,
576
+ dimensions: dimensionsForImage(data, mimeType)
577
+ }
578
+ };
579
+ } catch {
580
+ return {
581
+ ok: false,
582
+ reason: "read-error"
583
+ };
584
+ }
195
585
  }
196
586
  function readMacOSClipboardImage(maxBytes) {
197
587
  for (const attempt of [{
@@ -393,9 +783,7 @@ var PasterEditor = class extends CustomEditor {
393
783
  return replaceImagePathsInText(text, {
394
784
  cwd: this.pasterOptions.cwd,
395
785
  store: this.pasterOptions.store,
396
- onReject: (result) => {
397
- if (result.reason === "too-large") this.pasterOptions.notify(`paster: image is over 10 MB and was not attached: ${result.path}`);
398
- }
786
+ onReject: (result) => describeReject(result, this.pasterOptions.notify)
399
787
  });
400
788
  }
401
789
  updateCursorPreview() {
@@ -426,10 +814,11 @@ var ImagePreviewMessage = class {
426
814
  }
427
815
  render(width) {
428
816
  const lines = [];
817
+ const safeWidth = Math.max(1, width);
429
818
  for (let index = 0; index < this.attachments.length; index++) {
430
819
  const attachment = this.attachments[index];
431
- lines.push(formatAttachmentLine(attachment, width, this.theme.fallbackColor));
432
- lines.push(...this.images[index].render(width));
820
+ lines.push(formatAttachmentLine(attachment, safeWidth, this.theme.fallbackColor));
821
+ lines.push(...this.images[index].render(safeWidth));
433
822
  }
434
823
  return lines;
435
824
  }
@@ -512,9 +901,7 @@ function createImagePasteTerminalInputHandler(options) {
512
901
  cwd: options.cwd,
513
902
  store: options.store,
514
903
  loadImage: options.loadImage,
515
- onReject: (result) => {
516
- if (result.reason === "too-large") options.notify?.(`paster: image is over 10 MB and was not attached: ${result.path}`);
517
- }
904
+ onReject: (result) => describeReject(result, options.notify)
518
905
  });
519
906
  return (data) => {
520
907
  let prefix = "";
@@ -612,24 +999,26 @@ function paster(pi, config = {}) {
612
999
  store.clear();
613
1000
  });
614
1001
  function previewMessage(attachments) {
1002
+ const placeholders = attachments.map((attachment) => attachment.placeholder);
615
1003
  return {
616
1004
  customType: "paster-preview",
617
- content: "",
1005
+ content: `(attachment preview: ${placeholders.join(", ")})`,
618
1006
  display: true,
619
- details: { placeholders: attachments.map((attachment) => attachment.placeholder) }
1007
+ details: { placeholders }
620
1008
  };
621
1009
  }
622
- pi.on("input", (event, ctx) => {
1010
+ pi.on("input", async (event, ctx) => {
623
1011
  if (event.source === "extension") return { action: "continue" };
624
1012
  if (ctx.hasUI) activeEditor?.clearCursorPreview();
625
1013
  const attachments = store.matchingPlaceholders(event.text);
626
1014
  if (attachments.length === 0) return { action: "continue" };
627
1015
  if (ctx.isIdle()) pendingPreview = attachments;
628
1016
  else pi.sendMessage(previewMessage(attachments), { deliverAs: "followUp" });
1017
+ const images = await imagesForTextOptimized(store, event.text, event.images);
629
1018
  return {
630
1019
  action: "transform",
631
1020
  text: event.text,
632
- images: imagesForText(store, event.text, event.images)
1021
+ images
633
1022
  };
634
1023
  });
635
1024
  pi.on("before_agent_start", () => {
@@ -640,4 +1029,4 @@ function paster(pi, config = {}) {
640
1029
  });
641
1030
  }
642
1031
  //#endregion
643
- export { AttachmentStore, CursorImagePreviewWidget, DEFAULT_PASTER_CONFIG, EXTENSION_NAME, ImagePreviewMessage, MAX_IMAGE_BYTES, PASTE_END, PASTE_START, PasterEditor, createImagePasteTerminalInputHandler, createPaster, paster as default, detectImageMimeType, dimensionsForImage, imagesForText, loadImageFromPath, readClipboardImage, replaceImagePathsInText, resolveImagePath, resolvePasterConfig, shellUnescape, tokenizePathLikeText };
1032
+ export { ANTHROPIC_MAX_DIMENSION, ANTHROPIC_MAX_IMAGE_BYTES, AttachmentStore, CursorImagePreviewWidget, DEFAULT_PASTER_CONFIG, EXTENSION_NAME, ImagePreviewMessage, MAX_IMAGE_BYTES, PASTE_END, PASTE_START, PasterEditor, createImagePasteTerminalInputHandler, createPaster, paster as default, describeReject, detectImageMimeType, dimensionsForImage, imagesForText, imagesForTextOptimized, isWindowsDrivePath, isWindowsLikePath, isWindowsUncPath, isWsl, loadImageFromPath, optimizeImageBytes, readClipboardImage, replaceImagePathsInText, resolveImagePath, resolvePasterConfig, shellUnescape, tokenizePathLikeText, windowsToWslPath };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-paster",
3
- "version": "0.1.5",
3
+ "version": "0.2.0",
4
4
  "description": "Pi extension that turns pasted image paths into first-class image attachments.",
5
5
  "keywords": [
6
6
  "image-attachments",
@@ -50,11 +50,14 @@
50
50
  "@earendil-works/pi-coding-agent": "*",
51
51
  "@earendil-works/pi-tui": "*"
52
52
  },
53
+ "optionalDependencies": {
54
+ "sharp": "^0.34.0"
55
+ },
53
56
  "packageManager": "pnpm@11.1.2",
54
57
  "pi": {
55
58
  "extensions": [
56
59
  "./src/index.ts"
57
60
  ],
58
- "image": "https://unpkg.com/pi-paster@0.1.5/docs/preview.png"
61
+ "image": "https://unpkg.com/pi-paster@0.2.0/docs/preview.png"
59
62
  }
60
63
  }