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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-paster",
3
- "version": "0.1.4",
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.4/docs/preview.png"
61
+ "image": "https://unpkg.com/pi-paster@0.2.0/docs/preview.png"
59
62
  }
60
63
  }
package/src/clipboard.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { randomUUID } from "node:crypto";
3
- import { readFileSync, unlinkSync } from "node:fs";
3
+ import { existsSync, readFileSync, unlinkSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { detectImageMimeType, dimensionsForImage } from "./image-utils.ts";
@@ -14,8 +14,78 @@ export type ClipboardImageResult =
14
14
  };
15
15
 
16
16
  export function readClipboardImage(maxBytes = MAX_IMAGE_BYTES): ClipboardImageResult {
17
- if (process.platform !== "darwin") return { ok: false, reason: "unsupported-platform" };
18
- return readMacOSClipboardImage(maxBytes);
17
+ if (process.platform === "darwin") return readMacOSClipboardImage(maxBytes);
18
+ if (process.platform === "win32") return readWindowsClipboardImage(maxBytes);
19
+ if (process.platform === "linux" && isWSL()) return readWindowsClipboardImage(maxBytes);
20
+ return { ok: false, reason: "unsupported-platform" };
21
+ }
22
+
23
+ function isWSL(): boolean {
24
+ try {
25
+ return /microsoft|wsl/i.test(readFileSync("/proc/version", "utf8"));
26
+ } catch {
27
+ return false;
28
+ }
29
+ }
30
+
31
+ function resolvePowerShell(): string | null {
32
+ if (process.platform === "win32") return "powershell.exe";
33
+ const candidates = [
34
+ "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe",
35
+ "/mnt/c/WINDOWS/System32/WindowsPowerShell/v1.0/powershell.exe",
36
+ ];
37
+ for (const c of candidates) {
38
+ if (existsSync(c)) return c;
39
+ }
40
+ return "powershell.exe";
41
+ }
42
+
43
+ function readWindowsClipboardImage(maxBytes: number): ClipboardImageResult {
44
+ const exe = resolvePowerShell();
45
+ if (!exe) return { ok: false, reason: "unsupported-platform" };
46
+
47
+ const script = [
48
+ "$ErrorActionPreference = 'Stop'",
49
+ "Add-Type -AssemblyName System.Windows.Forms | Out-Null",
50
+ "Add-Type -AssemblyName System.Drawing | Out-Null",
51
+ "$img = [System.Windows.Forms.Clipboard]::GetImage()",
52
+ "if ($img -eq $null) { exit 2 }",
53
+ "$ms = New-Object System.IO.MemoryStream",
54
+ "$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)",
55
+ "[Console]::Out.Write([Convert]::ToBase64String($ms.ToArray()))",
56
+ ].join("; ");
57
+
58
+ try {
59
+ const result = spawnSync(exe, ["-NoProfile", "-NonInteractive", "-STA", "-Command", script], {
60
+ timeout: 5000,
61
+ encoding: "utf8",
62
+ maxBuffer: 64 * 1024 * 1024,
63
+ });
64
+ if (result.status === 2) return { ok: false, reason: "empty" };
65
+ if (result.status !== 0) return { ok: false, reason: "read-error" };
66
+
67
+ const data = (result.stdout || "").trim();
68
+ if (!data) return { ok: false, reason: "empty" };
69
+
70
+ const bytes = Buffer.from(data, "base64");
71
+ if (bytes.length === 0) return { ok: false, reason: "empty" };
72
+ if (bytes.length > maxBytes) return { ok: false, reason: "too-large" };
73
+
74
+ const mimeType = detectImageMimeType(bytes);
75
+ if (!mimeType) return { ok: false, reason: "unsupported" };
76
+
77
+ return {
78
+ ok: true,
79
+ image: {
80
+ originalPath: "clipboard.png",
81
+ mimeType,
82
+ data,
83
+ dimensions: dimensionsForImage(data, mimeType),
84
+ },
85
+ };
86
+ } catch {
87
+ return { ok: false, reason: "read-error" };
88
+ }
19
89
  }
20
90
 
21
91
  function readMacOSClipboardImage(maxBytes: number): ClipboardImageResult {
package/src/editor.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { CustomEditor, type KeybindingsManager } from "@earendil-works/pi-coding-agent";
2
2
  import type { EditorTheme, TUI } from "@earendil-works/pi-tui";
3
- import { replaceImagePathsInText } from "./image-utils.ts";
3
+ import { describeReject, replaceImagePathsInText } from "./image-utils.ts";
4
4
  import type { AttachmentStore } from "./store.ts";
5
5
  import type { ImageAttachment } from "./types.ts";
6
6
 
@@ -189,13 +189,7 @@ export class PasterEditor extends CustomEditor {
189
189
  return replaceImagePathsInText(text, {
190
190
  cwd: this.pasterOptions.cwd,
191
191
  store: this.pasterOptions.store,
192
- onReject: (result) => {
193
- if (result.reason === "too-large") {
194
- this.pasterOptions.notify(
195
- `paster: image is over 10 MB and was not attached: ${result.path}`,
196
- );
197
- }
198
- },
192
+ onReject: (result) => describeReject(result, this.pasterOptions.notify),
199
193
  });
200
194
  }
201
195
 
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync, statSync } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { isAbsolute, resolve } from "node:path";
4
+ import { platform } from "node:process";
4
5
  import { getImageDimensions } from "@earendil-works/pi-tui";
5
6
  import type { AttachmentStore } from "./store.ts";
6
7
  import {
@@ -10,14 +11,18 @@ import {
10
11
  type PasterImageContent,
11
12
  type SupportedImageMimeType,
12
13
  } from "./types.ts";
14
+ import { optimizeImageBytes } from "./optimize-image.ts";
13
15
 
14
16
  interface PathToken {
15
17
  raw: string;
16
18
  value: string;
17
19
  start: number;
18
20
  end: number;
21
+ bare: boolean;
19
22
  }
20
23
 
24
+ const MAX_BARE_PATH_EXTENSIONS = 8;
25
+
21
26
  export function detectImageMimeType(bytes: Uint8Array): SupportedImageMimeType | undefined {
22
27
  if (
23
28
  bytes.length >= 8 &&
@@ -62,9 +67,62 @@ export function detectImageMimeType(bytes: Uint8Array): SupportedImageMimeType |
62
67
  return undefined;
63
68
  }
64
69
 
70
+ const WINDOWS_DRIVE_PATH = /^([a-zA-Z]):[\\/](.*)$/;
71
+
72
+ export function isWindowsDrivePath(value: string): boolean {
73
+ return WINDOWS_DRIVE_PATH.test(value);
74
+ }
75
+
76
+ export function isWindowsUncPath(value: string): boolean {
77
+ return value.startsWith("\\\\") && value.length > 2;
78
+ }
79
+
80
+ export function isWindowsLikePath(value: string): boolean {
81
+ return isWindowsDrivePath(value) || isWindowsUncPath(value);
82
+ }
83
+
84
+ let cachedIsWsl: boolean | undefined;
85
+ export function isWsl(): boolean {
86
+ if (cachedIsWsl !== undefined) return cachedIsWsl;
87
+ if (platform !== "linux") {
88
+ cachedIsWsl = false;
89
+ return cachedIsWsl;
90
+ }
91
+ if (process.env.WSL_DISTRO_NAME || process.env.WSL_INTEROP) {
92
+ cachedIsWsl = true;
93
+ return cachedIsWsl;
94
+ }
95
+ try {
96
+ const release = readFileSync("/proc/version", "utf8");
97
+ cachedIsWsl = /microsoft|wsl/i.test(release);
98
+ } catch {
99
+ cachedIsWsl = false;
100
+ }
101
+ return cachedIsWsl;
102
+ }
103
+
104
+ export function windowsToWslPath(windowsPath: string): string {
105
+ const driveMatch = WINDOWS_DRIVE_PATH.exec(windowsPath);
106
+ if (driveMatch) {
107
+ const drive = driveMatch[1]!.toLowerCase();
108
+ const rest = driveMatch[2]!.replace(/\\/g, "/");
109
+ return rest.length > 0 ? `/mnt/${drive}/${rest}` : `/mnt/${drive}`;
110
+ }
111
+ if (isWindowsUncPath(windowsPath)) {
112
+ // \\server\share\path -> //wsl.localhost-style not generally translatable; pass through.
113
+ return windowsPath.replace(/\\/g, "/");
114
+ }
115
+ return windowsPath;
116
+ }
117
+
65
118
  export function resolveImagePath(input: string, cwd: string): string {
66
119
  if (input === "~") return homedir();
67
120
  if (input.startsWith("~/")) return resolve(homedir(), input.slice(2));
121
+ if (isWindowsLikePath(input)) {
122
+ if (platform === "win32") return input;
123
+ if (isWsl()) return windowsToWslPath(input);
124
+ return input;
125
+ }
68
126
  if (isAbsolute(input)) return input;
69
127
  return resolve(cwd, input);
70
128
  }
@@ -88,10 +146,26 @@ function isPathLike(value: string): boolean {
88
146
  value.startsWith("~/") ||
89
147
  value === "~" ||
90
148
  value.startsWith("./") ||
91
- value.startsWith("../")
149
+ value.startsWith("../") ||
150
+ isWindowsLikePath(value)
92
151
  );
93
152
  }
94
153
 
154
+ function startsWithWindowsPath(text: string, index: number): boolean {
155
+ if (
156
+ index + 2 < text.length &&
157
+ /[a-zA-Z]/.test(text[index]!) &&
158
+ text[index + 1] === ":" &&
159
+ (text[index + 2] === "\\" || text[index + 2] === "/")
160
+ ) {
161
+ return true;
162
+ }
163
+ if (index + 1 < text.length && text[index] === "\\" && text[index + 1] === "\\") {
164
+ return true;
165
+ }
166
+ return false;
167
+ }
168
+
95
169
  export function tokenizePathLikeText(text: string): PathToken[] {
96
170
  const tokens: PathToken[] = [];
97
171
  let index = 0;
@@ -107,11 +181,12 @@ export function tokenizePathLikeText(text: string): PathToken[] {
107
181
  if (char === "'" || char === '"') {
108
182
  const quote = char;
109
183
  index++;
184
+ const windowsMode = startsWithWindowsPath(text, index);
110
185
  let value = "";
111
186
  let closed = false;
112
187
  while (index < text.length) {
113
188
  const current = text[index]!;
114
- if (current === "\\" && quote === '"' && index + 1 < text.length) {
189
+ if (!windowsMode && current === "\\" && quote === '"' && index + 1 < text.length) {
115
190
  value += text[index + 1]!;
116
191
  index += 2;
117
192
  continue;
@@ -125,15 +200,16 @@ export function tokenizePathLikeText(text: string): PathToken[] {
125
200
  index++;
126
201
  }
127
202
  if (closed && isPathLike(value))
128
- tokens.push({ raw: text.slice(start, index), value, start, end: index });
203
+ tokens.push({ raw: text.slice(start, index), value, start, end: index, bare: false });
129
204
  continue;
130
205
  }
131
206
 
207
+ const windowsMode = startsWithWindowsPath(text, index);
132
208
  let rawValue = "";
133
209
  while (index < text.length) {
134
210
  const current = text[index]!;
135
211
  if (/\s/.test(current)) break;
136
- if (current === "\\" && index + 1 < text.length) {
212
+ if (!windowsMode && current === "\\" && index + 1 < text.length) {
137
213
  rawValue += current + text[index + 1]!;
138
214
  index += 2;
139
215
  continue;
@@ -141,13 +217,57 @@ export function tokenizePathLikeText(text: string): PathToken[] {
141
217
  rawValue += current;
142
218
  index++;
143
219
  }
144
- const value = shellUnescape(rawValue);
145
- if (isPathLike(value)) tokens.push({ raw: rawValue, value, start, end: index });
220
+ const value = windowsMode ? rawValue : shellUnescape(rawValue);
221
+ if (isPathLike(value)) tokens.push({ raw: rawValue, value, start, end: index, bare: true });
146
222
  }
147
223
 
148
224
  return tokens;
149
225
  }
150
226
 
227
+ function tryExtendBareToken(
228
+ text: string,
229
+ token: PathToken,
230
+ attempt: (path: string) => LoadImageResult,
231
+ ): { value: string; end: number; result: LoadImageResult } {
232
+ let value = token.value;
233
+ let end = token.end;
234
+ let lastResult = attempt(value);
235
+ if (lastResult.ok || lastResult.reason === "too-large" || !token.bare) {
236
+ return { value, end, result: lastResult };
237
+ }
238
+
239
+ let scan = end;
240
+ for (let i = 0; i < MAX_BARE_PATH_EXTENSIONS; i++) {
241
+ let wsEnd = scan;
242
+ while (wsEnd < text.length) {
243
+ const ch = text[wsEnd]!;
244
+ if (ch === "\n" || ch === "\r") break;
245
+ if (!/\s/.test(ch)) break;
246
+ wsEnd++;
247
+ }
248
+ if (wsEnd === scan) break;
249
+
250
+ let wordEnd = wsEnd;
251
+ while (wordEnd < text.length && !/\s/.test(text[wordEnd]!)) wordEnd++;
252
+ if (wordEnd === wsEnd) break;
253
+
254
+ const nextWord = shellUnescape(text.slice(wsEnd, wordEnd));
255
+ if (isPathLike(nextWord)) break;
256
+
257
+ const extendedValue = value + text.slice(scan, wsEnd) + nextWord;
258
+ const candidate = attempt(extendedValue);
259
+ scan = wordEnd;
260
+ if (candidate.ok || candidate.reason === "too-large") {
261
+ return { value: extendedValue, end: wordEnd, result: candidate };
262
+ }
263
+ value = extendedValue;
264
+ end = wordEnd;
265
+ lastResult = candidate;
266
+ }
267
+
268
+ return { value, end, result: lastResult };
269
+ }
270
+
151
271
  export function dimensionsForImage(data: string, mimeType: SupportedImageMimeType) {
152
272
  return getImageDimensions(data, mimeType) ?? undefined;
153
273
  }
@@ -202,16 +322,18 @@ export function replaceImagePathsInText(
202
322
  const loadImage = options.loadImage ?? loadImageFromPath;
203
323
 
204
324
  for (const token of tokens) {
205
- const result = loadImage(token.value, options.cwd);
206
- if (!result.ok) {
207
- options.onReject?.(result);
325
+ if (token.start < cursor) continue;
326
+
327
+ const extended = tryExtendBareToken(text, token, (path) => loadImage(path, options.cwd));
328
+ if (!extended.result.ok) {
329
+ if (extended.result.reason === "too-large") options.onReject?.(extended.result);
208
330
  continue;
209
331
  }
210
332
 
211
- const attachment = options.store.add(result.image);
333
+ const attachment = options.store.add(extended.result.image);
212
334
  accepted.push(attachment);
213
335
  output += text.slice(cursor, token.start) + attachment.placeholder;
214
- cursor = token.end;
336
+ cursor = extended.end;
215
337
  replaced++;
216
338
  }
217
339
 
@@ -234,3 +356,62 @@ export function imagesForText(
234
356
  })),
235
357
  ];
236
358
  }
359
+
360
+ /**
361
+ * Async variant of imagesForText that runs each attachment through the
362
+ * Anthropic-aware image optimizer (resize to 8000px cap, JPEG ladder to stay
363
+ * under the 5 MB / 32 MB request caps). Optimization is cached on the
364
+ * attachment so the cost is paid once per image, not per submit.
365
+ *
366
+ * Used by paster's `input` handler; safe to await on the hot path because
367
+ * sharp is only invoked when the image is actually over the limits.
368
+ */
369
+ export async function imagesForTextOptimized(
370
+ store: AttachmentStore,
371
+ text: string,
372
+ existing: PasterImageContent[] = [],
373
+ ): Promise<PasterImageContent[]> {
374
+ const attachments = store.matchingPlaceholders(text);
375
+ const optimized: PasterImageContent[] = [];
376
+ for (const attachment of attachments) {
377
+ if (!attachment.optimized) {
378
+ try {
379
+ const input = Buffer.from(attachment.data, "base64");
380
+ const result = await optimizeImageBytes(input, attachment.mimeType);
381
+ if (result.changed) {
382
+ attachment.data = result.data;
383
+ attachment.mimeType = result.mimeType;
384
+ if (result.finalDim) {
385
+ attachment.dimensions = {
386
+ widthPx: result.finalDim.width,
387
+ heightPx: result.finalDim.height,
388
+ };
389
+ }
390
+ }
391
+ attachment.optimized = true;
392
+ attachment.originalBytes = result.originalBytes;
393
+ attachment.finalBytes = result.finalBytes;
394
+ attachment.optimizeActions = result.actions;
395
+ } catch {
396
+ // optimization is best-effort; fall through with the original bytes
397
+ attachment.optimized = true;
398
+ }
399
+ }
400
+ optimized.push({
401
+ type: "image",
402
+ mimeType: attachment.mimeType,
403
+ data: attachment.data,
404
+ });
405
+ }
406
+ return [...existing, ...optimized];
407
+ }
408
+
409
+ export function describeReject(
410
+ result: Exclude<LoadImageResult, { ok: true }>,
411
+ notify?: (message: string) => void,
412
+ ): void {
413
+ if (!notify) return;
414
+ if (result.reason === "too-large") {
415
+ notify(`paster: image is too large and was not attached: ${result.path}`);
416
+ }
417
+ }
package/src/index.ts CHANGED
@@ -2,13 +2,14 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { readClipboardImage } from "./clipboard.ts";
3
3
  import { type PasterConfig, resolvePasterConfig } from "./config.ts";
4
4
  import { PasterEditor } from "./editor.ts";
5
- import { imagesForText } from "./image-utils.ts";
5
+ import { imagesForTextOptimized } from "./image-utils.ts";
6
6
  import { CursorImagePreviewWidget, ImagePreviewMessage } from "./preview.ts";
7
7
  import { AttachmentStore } from "./store.ts";
8
8
  import { createImagePasteTerminalInputHandler } from "./terminal-input.ts";
9
9
  import type { ImageAttachment, PasterPreviewDetails } from "./types.ts";
10
10
 
11
11
  export * from "./clipboard.ts";
12
+ export * from "./optimize-image.ts";
12
13
  export * from "./config.ts";
13
14
  export * from "./editor.ts";
14
15
  export * from "./image-utils.ts";
@@ -110,7 +111,17 @@ export default function paster(pi: ExtensionAPI, config: PasterConfig = {}): voi
110
111
  store.clear();
111
112
  });
112
113
 
113
- pi.on("input", (event, ctx) => {
114
+ function previewMessage(attachments: ImageAttachment[]) {
115
+ const placeholders = attachments.map((attachment) => attachment.placeholder);
116
+ return {
117
+ customType: "paster-preview",
118
+ content: `(attachment preview: ${placeholders.join(", ")})`,
119
+ display: true,
120
+ details: { placeholders },
121
+ };
122
+ }
123
+
124
+ pi.on("input", async (event, ctx) => {
114
125
  if (event.source === "extension") return { action: "continue" as const };
115
126
  if (ctx.hasUI) {
116
127
  activeEditor?.clearCursorPreview();
@@ -118,26 +129,31 @@ export default function paster(pi: ExtensionAPI, config: PasterConfig = {}): voi
118
129
 
119
130
  const attachments = store.matchingPlaceholders(event.text);
120
131
  if (attachments.length === 0) return { action: "continue" as const };
121
- pendingPreview = attachments;
132
+
133
+ if (ctx.isIdle()) {
134
+ pendingPreview = attachments;
135
+ } else {
136
+ // Queued steer/follow-up messages do not fire before_agent_start when they are
137
+ // later delivered by the running agent, so enqueue the preview alongside them now.
138
+ pi.sendMessage(previewMessage(attachments), { deliverAs: "followUp" });
139
+ }
140
+
141
+ // Optimize images on-submit so we never exceed Anthropic's 5 MB/image or
142
+ // 32 MB/request caps. Per-attachment caching means each image is only
143
+ // resized/recompressed once across the whole session.
144
+ const images = await imagesForTextOptimized(store, event.text, event.images);
122
145
 
123
146
  return {
124
147
  action: "transform" as const,
125
148
  text: event.text,
126
- images: imagesForText(store, event.text, event.images),
149
+ images,
127
150
  };
128
151
  });
129
152
 
130
153
  pi.on("before_agent_start", () => {
131
154
  if (pendingPreview.length === 0) return;
132
- const placeholders = pendingPreview.map((attachment) => attachment.placeholder);
155
+ const message = previewMessage(pendingPreview);
133
156
  pendingPreview = [];
134
- return {
135
- message: {
136
- customType: "paster-preview",
137
- content: "",
138
- display: true,
139
- details: { placeholders },
140
- },
141
- };
157
+ return { message };
142
158
  });
143
159
  }