pi-paster 0.1.5 → 0.2.1

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.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, visibleWidth } from "@earendil-works/pi-tui";
6
+ import { platform } from "node:process";
7
+ import { Box, Container, Image, Spacer, Text, 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,145 @@ function imagesForText(store, text, existing = []) {
184
447
  data: attachment.data
185
448
  }))];
186
449
  }
450
+ function appendImagePathContext(text, attachments) {
451
+ if (attachments.length === 0) return text;
452
+ return `${text}\n\nAttached image paths:\n${attachments.map((attachment) => `- ${attachment.placeholder}: ${attachment.originalPath}`).join("\n")}`;
453
+ }
454
+ /**
455
+ * Async variant of imagesForText that runs each attachment through the
456
+ * Anthropic-aware image optimizer (resize to 8000px cap, JPEG ladder to stay
457
+ * under the 5 MB / 32 MB request caps). Optimization is cached on the
458
+ * attachment so the cost is paid once per image, not per submit.
459
+ *
460
+ * Used by paster's `input` handler; safe to await on the hot path because
461
+ * sharp is only invoked when the image is actually over the limits.
462
+ */
463
+ async function imagesForTextOptimized(store, text, existing = []) {
464
+ const attachments = store.matchingPlaceholders(text);
465
+ const optimized = [];
466
+ for (const attachment of attachments) {
467
+ if (!attachment.optimized) try {
468
+ const result = await optimizeImageBytes(Buffer.from(attachment.data, "base64"), attachment.mimeType);
469
+ if (result.changed) {
470
+ attachment.data = result.data;
471
+ attachment.mimeType = result.mimeType;
472
+ if (result.finalDim) attachment.dimensions = {
473
+ widthPx: result.finalDim.width,
474
+ heightPx: result.finalDim.height
475
+ };
476
+ }
477
+ attachment.optimized = true;
478
+ attachment.originalBytes = result.originalBytes;
479
+ attachment.finalBytes = result.finalBytes;
480
+ attachment.optimizeActions = result.actions;
481
+ } catch {
482
+ attachment.optimized = true;
483
+ }
484
+ optimized.push({
485
+ type: "image",
486
+ mimeType: attachment.mimeType,
487
+ data: attachment.data
488
+ });
489
+ }
490
+ return [...existing, ...optimized];
491
+ }
492
+ function describeReject(result, notify) {
493
+ if (!notify) return;
494
+ if (result.reason === "too-large") notify(`paster: image is too large and was not attached: ${result.path}`);
495
+ }
187
496
  //#endregion
188
497
  //#region src/clipboard.ts
189
498
  function readClipboardImage(maxBytes = MAX_IMAGE_BYTES) {
190
- if (process.platform !== "darwin") return {
499
+ if (process.platform === "darwin") return readMacOSClipboardImage(maxBytes);
500
+ if (process.platform === "win32") return readWindowsClipboardImage(maxBytes);
501
+ if (process.platform === "linux" && isWSL()) return readWindowsClipboardImage(maxBytes);
502
+ return {
191
503
  ok: false,
192
504
  reason: "unsupported-platform"
193
505
  };
194
- return readMacOSClipboardImage(maxBytes);
506
+ }
507
+ function isWSL() {
508
+ try {
509
+ return /microsoft|wsl/i.test(readFileSync("/proc/version", "utf8"));
510
+ } catch {
511
+ return false;
512
+ }
513
+ }
514
+ function resolvePowerShell() {
515
+ if (process.platform === "win32") return "powershell.exe";
516
+ 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;
517
+ return "powershell.exe";
518
+ }
519
+ function readWindowsClipboardImage(maxBytes) {
520
+ const exe = resolvePowerShell();
521
+ if (!exe) return {
522
+ ok: false,
523
+ reason: "unsupported-platform"
524
+ };
525
+ const script = [
526
+ "$ErrorActionPreference = 'Stop'",
527
+ "Add-Type -AssemblyName System.Windows.Forms | Out-Null",
528
+ "Add-Type -AssemblyName System.Drawing | Out-Null",
529
+ "$img = [System.Windows.Forms.Clipboard]::GetImage()",
530
+ "if ($img -eq $null) { exit 2 }",
531
+ "$ms = New-Object System.IO.MemoryStream",
532
+ "$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)",
533
+ "[Console]::Out.Write([Convert]::ToBase64String($ms.ToArray()))"
534
+ ].join("; ");
535
+ try {
536
+ const result = spawnSync(exe, [
537
+ "-NoProfile",
538
+ "-NonInteractive",
539
+ "-STA",
540
+ "-Command",
541
+ script
542
+ ], {
543
+ timeout: 5e3,
544
+ encoding: "utf8",
545
+ maxBuffer: 64 * 1024 * 1024
546
+ });
547
+ if (result.status === 2) return {
548
+ ok: false,
549
+ reason: "empty"
550
+ };
551
+ if (result.status !== 0) return {
552
+ ok: false,
553
+ reason: "read-error"
554
+ };
555
+ const data = (result.stdout || "").trim();
556
+ if (!data) return {
557
+ ok: false,
558
+ reason: "empty"
559
+ };
560
+ const bytes = Buffer.from(data, "base64");
561
+ if (bytes.length === 0) return {
562
+ ok: false,
563
+ reason: "empty"
564
+ };
565
+ if (bytes.length > maxBytes) return {
566
+ ok: false,
567
+ reason: "too-large"
568
+ };
569
+ const mimeType = detectImageMimeType(bytes);
570
+ if (!mimeType) return {
571
+ ok: false,
572
+ reason: "unsupported"
573
+ };
574
+ return {
575
+ ok: true,
576
+ image: {
577
+ originalPath: "clipboard.png",
578
+ mimeType,
579
+ data,
580
+ dimensions: dimensionsForImage(data, mimeType)
581
+ }
582
+ };
583
+ } catch {
584
+ return {
585
+ ok: false,
586
+ reason: "read-error"
587
+ };
588
+ }
195
589
  }
196
590
  function readMacOSClipboardImage(maxBytes) {
197
591
  for (const attempt of [{
@@ -254,23 +648,72 @@ function readMacOSClipboardImage(maxBytes) {
254
648
  }
255
649
  //#endregion
256
650
  //#region src/config.ts
257
- const DEFAULT_PASTER_CONFIG = { customEditor: {
258
- enabled: true,
259
- showImagePreview: true,
260
- deletePlaceholderAsBlock: true
261
- } };
651
+ const DEFAULT_PASTER_CONFIG = {
652
+ submittedPreviewStyle: "raw",
653
+ includeImagePathsInPrompt: true,
654
+ customEditor: {
655
+ enabled: true,
656
+ showImagePreview: true,
657
+ deletePlaceholderAsBlock: true
658
+ }
659
+ };
262
660
  function resolvePasterConfig(config = {}) {
263
- return { customEditor: {
264
- enabled: config.customEditor?.enabled ?? DEFAULT_PASTER_CONFIG.customEditor.enabled,
265
- showImagePreview: config.customEditor?.showImagePreview ?? DEFAULT_PASTER_CONFIG.customEditor.showImagePreview,
266
- deletePlaceholderAsBlock: config.customEditor?.deletePlaceholderAsBlock ?? DEFAULT_PASTER_CONFIG.customEditor.deletePlaceholderAsBlock
267
- } };
661
+ return {
662
+ submittedPreviewStyle: config.submittedPreviewStyle ?? DEFAULT_PASTER_CONFIG.submittedPreviewStyle,
663
+ includeImagePathsInPrompt: config.includeImagePathsInPrompt ?? DEFAULT_PASTER_CONFIG.includeImagePathsInPrompt,
664
+ customEditor: {
665
+ enabled: config.customEditor?.enabled ?? DEFAULT_PASTER_CONFIG.customEditor.enabled,
666
+ showImagePreview: config.customEditor?.showImagePreview ?? DEFAULT_PASTER_CONFIG.customEditor.showImagePreview,
667
+ deletePlaceholderAsBlock: config.customEditor?.deletePlaceholderAsBlock ?? DEFAULT_PASTER_CONFIG.customEditor.deletePlaceholderAsBlock
668
+ }
669
+ };
268
670
  }
269
671
  //#endregion
270
672
  //#region src/editor.ts
271
673
  const PASTE_START = "\x1B[200~";
272
674
  const PASTE_END = "\x1B[201~";
273
675
  const PLACEHOLDER_REGEX = /\[#image \d+\]/g;
676
+ const PASTE_MARKER_REGEX = /\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]/g;
677
+ const baseSegmenter = new Intl.Segmenter();
678
+ function atomicSpansForText(text, validPasteIds) {
679
+ const spans = [];
680
+ for (const match of text.matchAll(PASTE_MARKER_REGEX)) {
681
+ const id = Number.parseInt(match[1], 10);
682
+ if (!validPasteIds.has(id)) continue;
683
+ spans.push({
684
+ start: match.index,
685
+ end: match.index + match[0].length
686
+ });
687
+ }
688
+ for (const match of text.matchAll(PLACEHOLDER_REGEX)) {
689
+ const placeholder = match[0];
690
+ spans.push({
691
+ start: match.index,
692
+ end: match.index + placeholder.length
693
+ });
694
+ }
695
+ return spans.sort((a, b) => a.start - b.start || a.end - b.end);
696
+ }
697
+ function segmentTextWithAtomicImages(text, store, validPasteIds = /* @__PURE__ */ new Set()) {
698
+ const spans = atomicSpansForText(text, validPasteIds);
699
+ if (spans.length === 0) return [...baseSegmenter.segment(text)];
700
+ const result = [];
701
+ let spanIndex = 0;
702
+ for (const segment of baseSegmenter.segment(text)) {
703
+ while (spanIndex < spans.length && spans[spanIndex].end <= segment.index) spanIndex++;
704
+ const span = spans[spanIndex];
705
+ if (span && segment.index >= span.start && segment.index < span.end) {
706
+ if (segment.index === span.start) result.push({
707
+ segment: text.slice(span.start, span.end),
708
+ index: span.start,
709
+ input: text
710
+ });
711
+ continue;
712
+ }
713
+ result.push(segment);
714
+ }
715
+ return result;
716
+ }
274
717
  function findPlaceholderAtCursor(store, lines, cursor, mode) {
275
718
  const line = lines[cursor.line] ?? "";
276
719
  for (const match of line.matchAll(PLACEHOLDER_REGEX)) {
@@ -278,21 +721,24 @@ function findPlaceholderAtCursor(store, lines, cursor, mode) {
278
721
  const start = match.index;
279
722
  const end = start + placeholder.length;
280
723
  const attachment = store.get(placeholder);
281
- if (!attachment) continue;
724
+ if (!attachment && mode !== "hover") continue;
282
725
  if (mode === "hover" && cursor.col >= start && cursor.col < end) return {
283
726
  attachment,
727
+ placeholder,
284
728
  line: cursor.line,
285
729
  start,
286
730
  end
287
731
  };
288
732
  if (mode === "backspace" && cursor.col > start && cursor.col <= end) return {
289
733
  attachment,
734
+ placeholder,
290
735
  line: cursor.line,
291
736
  start,
292
737
  end
293
738
  };
294
739
  if (mode === "delete" && cursor.col >= start && cursor.col < end) return {
295
740
  attachment,
741
+ placeholder,
296
742
  line: cursor.line,
297
743
  start,
298
744
  end
@@ -306,6 +752,7 @@ var PasterEditor = class extends CustomEditor {
306
752
  super(tui, theme, pasterKeybindings);
307
753
  this.pasterKeybindings = pasterKeybindings;
308
754
  this.pasterOptions = pasterOptions;
755
+ this.installAtomicImageSegmentation();
309
756
  this.onPasteImage = () => {
310
757
  this.handlePasteClipboardImage();
311
758
  };
@@ -317,6 +764,7 @@ var PasterEditor = class extends CustomEditor {
317
764
  }
318
765
  handleInput(data) {
319
766
  if (this.handleBracketedPaste(data)) return;
767
+ if (this.handleAtomicPlaceholderNavigation(data)) return;
320
768
  if (this.pasterOptions.deletePlaceholderAsBlock && this.handleAtomicPlaceholderDelete(data)) return;
321
769
  super.handleInput(data);
322
770
  this.updateCursorPreview();
@@ -325,6 +773,10 @@ var PasterEditor = class extends CustomEditor {
325
773
  this.activePreviewPlaceholder = void 0;
326
774
  this.pasterOptions.setCursorPreview(void 0);
327
775
  }
776
+ installAtomicImageSegmentation() {
777
+ const editor = this;
778
+ editor.segment = (text) => segmentTextWithAtomicImages(text, this.pasterOptions.store, new Set(editor.pastes?.keys() ?? []));
779
+ }
328
780
  async handlePasteClipboardImage() {
329
781
  const attachment = await this.pasterOptions.pasteClipboardImage?.();
330
782
  if (!attachment) return;
@@ -365,6 +817,20 @@ var PasterEditor = class extends CustomEditor {
365
817
  this.updateCursorPreview();
366
818
  return true;
367
819
  }
820
+ handleAtomicPlaceholderNavigation(data) {
821
+ const isLeft = this.pasterKeybindings.matches(data, "tui.editor.cursorLeft");
822
+ const isRight = this.pasterKeybindings.matches(data, "tui.editor.cursorRight");
823
+ if (!isLeft && !isRight) return false;
824
+ const line = this.getLines()[this.getCursor().line] ?? "";
825
+ const cursor = this.getCursor();
826
+ const matches = [...line.matchAll(PLACEHOLDER_REGEX)];
827
+ const target = isRight ? matches.find((match) => cursor.col >= match.index && cursor.col < match.index + match[0].length) : matches.find((match) => cursor.col > match.index && cursor.col <= match.index + match[0].length);
828
+ if (!target) return false;
829
+ this.setCursor(target.index + (isRight ? target[0].length : 0));
830
+ this.updateCursorPreview();
831
+ this.tui.requestRender();
832
+ return true;
833
+ }
368
834
  handleAtomicPlaceholderDelete(data) {
369
835
  const isBackspace = this.pasterKeybindings.matches(data, "tui.editor.deleteCharBackward");
370
836
  const isDelete = this.pasterKeybindings.matches(data, "tui.editor.deleteCharForward");
@@ -376,14 +842,18 @@ var PasterEditor = class extends CustomEditor {
376
842
  this.updateCursorPreview();
377
843
  return true;
378
844
  }
845
+ setCursor(col) {
846
+ const editor = this;
847
+ if (editor.setCursorCol) editor.setCursorCol(col);
848
+ else editor.state.cursorCol = col;
849
+ }
379
850
  deleteLineRange(lineIndex, start, end) {
380
851
  const editor = this;
381
852
  editor.pushUndoSnapshot?.();
382
853
  const line = editor.state.lines[lineIndex] ?? "";
383
854
  editor.state.lines[lineIndex] = line.slice(0, start) + line.slice(end);
384
855
  editor.state.cursorLine = lineIndex;
385
- if (editor.setCursorCol) editor.setCursorCol(start);
386
- else editor.state.cursorCol = start;
856
+ this.setCursor(start);
387
857
  editor.lastAction = null;
388
858
  editor.historyIndex = -1;
389
859
  this.onChange?.(this.getText());
@@ -393,14 +863,12 @@ var PasterEditor = class extends CustomEditor {
393
863
  return replaceImagePathsInText(text, {
394
864
  cwd: this.pasterOptions.cwd,
395
865
  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
- }
866
+ onReject: (result) => describeReject(result, this.pasterOptions.notify)
399
867
  });
400
868
  }
401
869
  updateCursorPreview() {
402
870
  const target = findPlaceholderAtCursor(this.pasterOptions.store, this.getLines(), this.getCursor(), "hover");
403
- const nextPlaceholder = target?.attachment.placeholder;
871
+ const nextPlaceholder = target?.attachment?.placeholder;
404
872
  if (nextPlaceholder === this.activePreviewPlaceholder) return;
405
873
  this.activePreviewPlaceholder = nextPlaceholder;
406
874
  this.pasterOptions.setCursorPreview(target?.attachment);
@@ -415,26 +883,48 @@ function formatAttachmentLine(attachment, width, style) {
415
883
  }
416
884
  var ImagePreviewMessage = class {
417
885
  images;
418
- constructor(attachments, theme) {
886
+ constructor(attachments, theme, options = {}) {
419
887
  this.attachments = attachments;
420
888
  this.theme = theme;
889
+ this.options = options;
421
890
  this.images = attachments.map((attachment) => new Image(attachment.data, attachment.mimeType, theme, {
422
891
  maxWidthCells: 60,
423
892
  maxHeightCells: 16,
424
893
  filename: attachment.placeholder
425
894
  }));
426
895
  }
896
+ invalidate() {
897
+ for (const image of this.images) image.invalidate();
898
+ }
427
899
  render(width) {
900
+ return this.options.style === "collapsible" ? this.renderCollapsible(width) : this.renderRaw(width);
901
+ }
902
+ renderRaw(width) {
428
903
  const lines = [];
904
+ const safeWidth = Math.max(1, width);
429
905
  for (let index = 0; index < this.attachments.length; index++) {
430
906
  const attachment = this.attachments[index];
431
- lines.push(formatAttachmentLine(attachment, width, this.theme.fallbackColor));
432
- lines.push(...this.images[index].render(width));
907
+ lines.push(formatAttachmentLine(attachment, safeWidth, this.theme.fallbackColor));
908
+ lines.push(...this.images[index].render(safeWidth));
433
909
  }
434
910
  return lines;
435
911
  }
436
- invalidate() {
437
- for (const image of this.images) image.invalidate();
912
+ renderCollapsible(width) {
913
+ const container = new Container();
914
+ container.addChild(new Spacer(1));
915
+ const box = new Box(1, 1, this.theme.background);
916
+ container.addChild(box);
917
+ const title = this.theme.title ?? this.theme.fallbackColor;
918
+ const muted = this.theme.muted ?? this.theme.fallbackColor;
919
+ const summary = this.attachments.length === 1 ? `Attached ${this.attachments[0].placeholder}` : `Attached ${this.attachments.length} images`;
920
+ const suffix = this.options.expanded ? " (ctrl+o to collapse)" : " (ctrl+o to expand)";
921
+ box.addChild(new Text(`${title(summary)}${muted(suffix)}`, 0, 0));
922
+ for (const attachment of this.attachments) box.addChild(new Text(formatAttachmentLine(attachment, width, muted), 0, 0));
923
+ const lines = container.render(width);
924
+ if (!this.options.expanded) return lines;
925
+ const safeWidth = Math.max(1, width);
926
+ for (let index = 0; index < this.attachments.length; index++) lines.push(...this.images[index].render(safeWidth));
927
+ return lines;
438
928
  }
439
929
  };
440
930
  var CursorImagePreviewWidget = class {
@@ -512,9 +1002,7 @@ function createImagePasteTerminalInputHandler(options) {
512
1002
  cwd: options.cwd,
513
1003
  store: options.store,
514
1004
  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
- }
1005
+ onReject: (result) => describeReject(result, options.notify)
518
1006
  });
519
1007
  return (data) => {
520
1008
  let prefix = "";
@@ -550,11 +1038,19 @@ function paster(pi, config = {}) {
550
1038
  let pendingPreview = [];
551
1039
  let activeEditor;
552
1040
  let unsubscribeTerminalInput;
553
- pi.registerMessageRenderer("paster-preview", (message, _options, theme) => {
1041
+ pi.registerMessageRenderer("paster-preview", (message, options, theme) => {
554
1042
  const placeholders = message.details?.placeholders ?? [];
555
1043
  const attachments = store.list().filter((attachment) => placeholders.includes(attachment.placeholder));
556
1044
  if (attachments.length === 0) return void 0;
557
- return new ImagePreviewMessage(attachments, { fallbackColor: (text) => theme.fg("muted", text) });
1045
+ return new ImagePreviewMessage(attachments, {
1046
+ fallbackColor: (text) => theme.fg("muted", text),
1047
+ background: (text) => theme.bg("toolSuccessBg", text),
1048
+ title: (text) => theme.fg("toolTitle", theme.bold(text)),
1049
+ muted: (text) => theme.fg("muted", text)
1050
+ }, {
1051
+ expanded: options.expanded,
1052
+ style: resolvedConfig.submittedPreviewStyle
1053
+ });
558
1054
  });
559
1055
  pi.on("session_start", (_event, ctx) => {
560
1056
  store.clear();
@@ -612,24 +1108,26 @@ function paster(pi, config = {}) {
612
1108
  store.clear();
613
1109
  });
614
1110
  function previewMessage(attachments) {
1111
+ const placeholders = attachments.map((attachment) => attachment.placeholder);
615
1112
  return {
616
1113
  customType: "paster-preview",
617
- content: "",
1114
+ content: `(attachment preview: ${placeholders.join(", ")})`,
618
1115
  display: true,
619
- details: { placeholders: attachments.map((attachment) => attachment.placeholder) }
1116
+ details: { placeholders }
620
1117
  };
621
1118
  }
622
- pi.on("input", (event, ctx) => {
1119
+ pi.on("input", async (event, ctx) => {
623
1120
  if (event.source === "extension") return { action: "continue" };
624
1121
  if (ctx.hasUI) activeEditor?.clearCursorPreview();
625
1122
  const attachments = store.matchingPlaceholders(event.text);
626
1123
  if (attachments.length === 0) return { action: "continue" };
627
1124
  if (ctx.isIdle()) pendingPreview = attachments;
628
1125
  else pi.sendMessage(previewMessage(attachments), { deliverAs: "followUp" });
1126
+ const images = await imagesForTextOptimized(store, event.text, event.images);
629
1127
  return {
630
1128
  action: "transform",
631
- text: event.text,
632
- images: imagesForText(store, event.text, event.images)
1129
+ text: resolvedConfig.includeImagePathsInPrompt ? appendImagePathContext(event.text, attachments) : event.text,
1130
+ images
633
1131
  };
634
1132
  });
635
1133
  pi.on("before_agent_start", () => {
@@ -640,4 +1138,4 @@ function paster(pi, config = {}) {
640
1138
  });
641
1139
  }
642
1140
  //#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 };
1141
+ export { ANTHROPIC_MAX_DIMENSION, ANTHROPIC_MAX_IMAGE_BYTES, AttachmentStore, CursorImagePreviewWidget, DEFAULT_PASTER_CONFIG, EXTENSION_NAME, ImagePreviewMessage, MAX_IMAGE_BYTES, PASTE_END, PASTE_START, PasterEditor, appendImagePathContext, createImagePasteTerminalInputHandler, createPaster, paster as default, describeReject, detectImageMimeType, dimensionsForImage, imagesForText, imagesForTextOptimized, isWindowsDrivePath, isWindowsLikePath, isWindowsUncPath, isWsl, loadImageFromPath, optimizeImageBytes, readClipboardImage, replaceImagePathsInText, resolveImagePath, resolvePasterConfig, segmentTextWithAtomicImages, shellUnescape, tokenizePathLikeText, windowsToWslPath };