pi-paster 0.1.4 → 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 { Image, getCellDimensions, getImageDimensions, truncateToWidth } from "@earendil-works/pi-tui";
6
+ import { platform } from "node:process";
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 {
499
+ ok: false,
500
+ reason: "unsupported-platform"
501
+ };
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 {
191
518
  ok: false,
192
519
  reason: "unsupported-platform"
193
520
  };
194
- return readMacOSClipboardImage(maxBytes);
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() {
@@ -408,6 +796,11 @@ var PasterEditor = class extends CustomEditor {
408
796
  };
409
797
  //#endregion
410
798
  //#region src/preview.ts
799
+ function formatAttachmentLine(attachment, width, style) {
800
+ const maxWidth = Math.max(1, width);
801
+ const line = style(`Attached ${attachment.placeholder} ${attachment.originalPath}`);
802
+ return visibleWidth(line) > maxWidth ? truncateToWidth(line, maxWidth, "") : line;
803
+ }
411
804
  var ImagePreviewMessage = class {
412
805
  images;
413
806
  constructor(attachments, theme) {
@@ -421,10 +814,11 @@ var ImagePreviewMessage = class {
421
814
  }
422
815
  render(width) {
423
816
  const lines = [];
817
+ const safeWidth = Math.max(1, width);
424
818
  for (let index = 0; index < this.attachments.length; index++) {
425
819
  const attachment = this.attachments[index];
426
- lines.push(this.theme.fallbackColor(`Attached ${attachment.placeholder} ${attachment.originalPath}`));
427
- lines.push(...this.images[index].render(width));
820
+ lines.push(formatAttachmentLine(attachment, safeWidth, this.theme.fallbackColor));
821
+ lines.push(...this.images[index].render(safeWidth));
428
822
  }
429
823
  return lines;
430
824
  }
@@ -448,8 +842,7 @@ var CursorImagePreviewWidget = class {
448
842
  this.image.invalidate();
449
843
  }
450
844
  headerLine(width) {
451
- const title = `Attached ${this.attachment.placeholder} ${this.attachment.originalPath}`;
452
- return this.theme.title(truncateToWidth(title, Math.max(1, width), ""));
845
+ return formatAttachmentLine(this.attachment, width, this.theme.title);
453
846
  }
454
847
  createImage(attachment, maxWidthCells = 60) {
455
848
  return new Image(attachment.data, attachment.mimeType, { fallbackColor: this.theme.accent }, {
@@ -508,9 +901,7 @@ function createImagePasteTerminalInputHandler(options) {
508
901
  cwd: options.cwd,
509
902
  store: options.store,
510
903
  loadImage: options.loadImage,
511
- onReject: (result) => {
512
- if (result.reason === "too-large") options.notify?.(`paster: image is over 10 MB and was not attached: ${result.path}`);
513
- }
904
+ onReject: (result) => describeReject(result, options.notify)
514
905
  });
515
906
  return (data) => {
516
907
  let prefix = "";
@@ -607,29 +998,35 @@ function paster(pi, config = {}) {
607
998
  }
608
999
  store.clear();
609
1000
  });
610
- pi.on("input", (event, ctx) => {
1001
+ function previewMessage(attachments) {
1002
+ const placeholders = attachments.map((attachment) => attachment.placeholder);
1003
+ return {
1004
+ customType: "paster-preview",
1005
+ content: `(attachment preview: ${placeholders.join(", ")})`,
1006
+ display: true,
1007
+ details: { placeholders }
1008
+ };
1009
+ }
1010
+ pi.on("input", async (event, ctx) => {
611
1011
  if (event.source === "extension") return { action: "continue" };
612
1012
  if (ctx.hasUI) activeEditor?.clearCursorPreview();
613
1013
  const attachments = store.matchingPlaceholders(event.text);
614
1014
  if (attachments.length === 0) return { action: "continue" };
615
- pendingPreview = attachments;
1015
+ if (ctx.isIdle()) pendingPreview = attachments;
1016
+ else pi.sendMessage(previewMessage(attachments), { deliverAs: "followUp" });
1017
+ const images = await imagesForTextOptimized(store, event.text, event.images);
616
1018
  return {
617
1019
  action: "transform",
618
1020
  text: event.text,
619
- images: imagesForText(store, event.text, event.images)
1021
+ images
620
1022
  };
621
1023
  });
622
1024
  pi.on("before_agent_start", () => {
623
1025
  if (pendingPreview.length === 0) return;
624
- const placeholders = pendingPreview.map((attachment) => attachment.placeholder);
1026
+ const message = previewMessage(pendingPreview);
625
1027
  pendingPreview = [];
626
- return { message: {
627
- customType: "paster-preview",
628
- content: "",
629
- display: true,
630
- details: { placeholders }
631
- } };
1028
+ return { message };
632
1029
  });
633
1030
  }
634
1031
  //#endregion
635
- 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 };