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/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 { appendImagePathContext, 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";
@@ -28,15 +29,22 @@ export default function paster(pi: ExtensionAPI, config: PasterConfig = {}): voi
28
29
  let activeEditor: PasterEditor | undefined;
29
30
  let unsubscribeTerminalInput: (() => void) | undefined;
30
31
 
31
- pi.registerMessageRenderer<PasterPreviewDetails>("paster-preview", (message, _options, theme) => {
32
+ pi.registerMessageRenderer<PasterPreviewDetails>("paster-preview", (message, options, theme) => {
32
33
  const placeholders = message.details?.placeholders ?? [];
33
34
  const attachments = store
34
35
  .list()
35
36
  .filter((attachment) => placeholders.includes(attachment.placeholder));
36
37
  if (attachments.length === 0) return undefined;
37
- return new ImagePreviewMessage(attachments, {
38
- fallbackColor: (text) => theme.fg("muted", text),
39
- });
38
+ return new ImagePreviewMessage(
39
+ attachments,
40
+ {
41
+ fallbackColor: (text) => theme.fg("muted", text),
42
+ background: (text) => theme.bg("toolSuccessBg", text),
43
+ title: (text) => theme.fg("toolTitle", theme.bold(text)),
44
+ muted: (text) => theme.fg("muted", text),
45
+ },
46
+ { expanded: options.expanded, style: resolvedConfig.submittedPreviewStyle },
47
+ );
40
48
  });
41
49
 
42
50
  pi.on("session_start", (_event, ctx) => {
@@ -111,15 +119,16 @@ export default function paster(pi: ExtensionAPI, config: PasterConfig = {}): voi
111
119
  });
112
120
 
113
121
  function previewMessage(attachments: ImageAttachment[]) {
122
+ const placeholders = attachments.map((attachment) => attachment.placeholder);
114
123
  return {
115
124
  customType: "paster-preview",
116
- content: "",
125
+ content: `(attachment preview: ${placeholders.join(", ")})`,
117
126
  display: true,
118
- details: { placeholders: attachments.map((attachment) => attachment.placeholder) },
127
+ details: { placeholders },
119
128
  };
120
129
  }
121
130
 
122
- pi.on("input", (event, ctx) => {
131
+ pi.on("input", async (event, ctx) => {
123
132
  if (event.source === "extension") return { action: "continue" as const };
124
133
  if (ctx.hasUI) {
125
134
  activeEditor?.clearCursorPreview();
@@ -136,10 +145,18 @@ export default function paster(pi: ExtensionAPI, config: PasterConfig = {}): voi
136
145
  pi.sendMessage(previewMessage(attachments), { deliverAs: "followUp" });
137
146
  }
138
147
 
148
+ // Optimize images on-submit so we never exceed Anthropic's 5 MB/image or
149
+ // 32 MB/request caps. Per-attachment caching means each image is only
150
+ // resized/recompressed once across the whole session.
151
+ const images = await imagesForTextOptimized(store, event.text, event.images);
152
+ const text = resolvedConfig.includeImagePathsInPrompt
153
+ ? appendImagePathContext(event.text, attachments)
154
+ : event.text;
155
+
139
156
  return {
140
157
  action: "transform" as const,
141
- text: event.text,
142
- images: imagesForText(store, event.text, event.images),
158
+ text,
159
+ images,
143
160
  };
144
161
  });
145
162
 
@@ -0,0 +1,209 @@
1
+ /**
2
+ * Image optimizer for pi-paster.
3
+ *
4
+ * Anthropic Messages API hard limits:
5
+ * - 5 MB per image (base64-decoded)
6
+ * - 8000 px on any dimension
7
+ * - 32 MB per request total
8
+ *
9
+ * Claude internally downsamples every input image so the long edge is
10
+ * - 1568 px for most models
11
+ * - 2576 px for Opus 4.7
12
+ * which means: any client-side downscale at or above ~2600 px is lossless
13
+ * from the model's point of view. We resize (aspect-preserving) instead of
14
+ * cropping so no visual information is thrown away.
15
+ *
16
+ * Strategy applied in order:
17
+ * 1. If width or height > 8000 px → resize so the long edge is 8000 px.
18
+ * 2. If decoded bytes > 5 MB → re-encode as JPEG q=95, then 90/85/80/70/60.
19
+ * 3. If still > 5 MB → progressively shrink the long edge to 6000 → 4000
20
+ * → 3000 → 2000 px, re-running JPEG quality ladder each step.
21
+ *
22
+ * `sharp` is loaded lazily so the extension can still ship if the native
23
+ * binding is unavailable (we just skip optimization with a log line).
24
+ */
25
+ import type { SupportedImageMimeType } from "./types.ts";
26
+ import { ANTHROPIC_MAX_DIMENSION, ANTHROPIC_MAX_IMAGE_BYTES } from "./types.ts";
27
+
28
+ type Sharp = (input?: Buffer) => SharpInstance;
29
+ interface SharpInstance {
30
+ metadata(): Promise<{ width?: number; height?: number; format?: string }>;
31
+ resize(opts: {
32
+ width?: number;
33
+ height?: number;
34
+ fit?: string;
35
+ withoutEnlargement?: boolean;
36
+ }): SharpInstance;
37
+ jpeg(opts: { quality: number; mozjpeg?: boolean }): SharpInstance;
38
+ png(opts?: { quality?: number; compressionLevel?: number }): SharpInstance;
39
+ toBuffer(): Promise<Buffer>;
40
+ }
41
+
42
+ let _sharp: Sharp | null | undefined;
43
+ async function getSharp(): Promise<Sharp | null> {
44
+ if (_sharp !== undefined) return _sharp;
45
+ try {
46
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
47
+ const mod: any = await import("sharp");
48
+ const fn = (typeof mod === "function" ? mod : mod?.default) as Sharp | undefined;
49
+ _sharp = typeof fn === "function" ? fn : null;
50
+ } catch {
51
+ _sharp = null;
52
+ }
53
+ return _sharp;
54
+ }
55
+
56
+ export interface OptimizeResult {
57
+ data: string; // base64
58
+ mimeType: SupportedImageMimeType;
59
+ originalBytes: number;
60
+ finalBytes: number;
61
+ originalDim?: { width: number; height: number };
62
+ finalDim?: { width: number; height: number };
63
+ actions: string[];
64
+ changed: boolean;
65
+ }
66
+
67
+ const SHRINK_LADDER = [6000, 4000, 3000, 2000];
68
+ const JPEG_QUALITY_LADDER = [95, 90, 85, 80, 70, 60];
69
+
70
+ export async function optimizeImageBytes(
71
+ input: Buffer,
72
+ mime: SupportedImageMimeType,
73
+ ): Promise<OptimizeResult> {
74
+ const originalBytes = input.length;
75
+ const noop = (): OptimizeResult => ({
76
+ data: input.toString("base64"),
77
+ mimeType: mime,
78
+ originalBytes,
79
+ finalBytes: originalBytes,
80
+ actions: [],
81
+ changed: false,
82
+ });
83
+
84
+ // Fast path: already under both limits.
85
+ if (originalBytes <= ANTHROPIC_MAX_IMAGE_BYTES) {
86
+ // Still need dimension check, but cheap to skip sharp if image is small.
87
+ if (originalBytes <= 256 * 1024) return noop();
88
+ }
89
+
90
+ const sharp = await getSharp();
91
+ if (!sharp) return noop(); // sharp missing — fall back silently.
92
+
93
+ const meta = await sharp(input).metadata();
94
+ const origW = meta.width ?? 0;
95
+ const origH = meta.height ?? 0;
96
+ if (!origW || !origH) return noop();
97
+
98
+ const actions: string[] = [];
99
+ const originalDim = { width: origW, height: origH };
100
+
101
+ // Step 1: respect 8000 px dimension cap (resize, do not crop).
102
+ let workBuf = input;
103
+ let workW = origW;
104
+ let workH = origH;
105
+ if (workW > ANTHROPIC_MAX_DIMENSION || workH > ANTHROPIC_MAX_DIMENSION) {
106
+ workBuf = await sharp(workBuf)
107
+ .resize({
108
+ width: workW >= workH ? ANTHROPIC_MAX_DIMENSION : undefined,
109
+ height: workH > workW ? ANTHROPIC_MAX_DIMENSION : undefined,
110
+ fit: "inside",
111
+ withoutEnlargement: true,
112
+ })
113
+ .toBuffer();
114
+ const m = await sharp(workBuf).metadata();
115
+ workW = m.width ?? workW;
116
+ workH = m.height ?? workH;
117
+ actions.push(`resize to ${workW}x${workH} (8000px cap)`);
118
+ }
119
+
120
+ // Step 2: bytes within limit? done.
121
+ if (workBuf.length <= ANTHROPIC_MAX_IMAGE_BYTES && actions.length === 0) return noop();
122
+ if (workBuf.length <= ANTHROPIC_MAX_IMAGE_BYTES) {
123
+ return {
124
+ data: workBuf.toString("base64"),
125
+ mimeType: mime,
126
+ originalBytes,
127
+ finalBytes: workBuf.length,
128
+ originalDim,
129
+ finalDim: { width: workW, height: workH },
130
+ actions,
131
+ changed: true,
132
+ };
133
+ }
134
+
135
+ // Step 3: JPEG quality ladder.
136
+ let outMime: SupportedImageMimeType = "image/jpeg";
137
+ let attempt = workBuf;
138
+ for (const q of JPEG_QUALITY_LADDER) {
139
+ attempt = await sharp(workBuf).jpeg({ quality: q, mozjpeg: true }).toBuffer();
140
+ if (attempt.length <= ANTHROPIC_MAX_IMAGE_BYTES) {
141
+ actions.push(`jpeg q=${q} → ${formatBytes(attempt.length)}`);
142
+ return {
143
+ data: attempt.toString("base64"),
144
+ mimeType: outMime,
145
+ originalBytes,
146
+ finalBytes: attempt.length,
147
+ originalDim,
148
+ finalDim: { width: workW, height: workH },
149
+ actions,
150
+ changed: true,
151
+ };
152
+ }
153
+ }
154
+ actions.push(`jpeg q=60 still ${formatBytes(attempt.length)} — shrinking`);
155
+
156
+ // Step 4: shrink the long edge and retry quality ladder each step.
157
+ for (const longEdge of SHRINK_LADDER) {
158
+ if (Math.max(workW, workH) <= longEdge) continue;
159
+ const resized = await sharp(workBuf)
160
+ .resize({
161
+ width: workW >= workH ? longEdge : undefined,
162
+ height: workH > workW ? longEdge : undefined,
163
+ fit: "inside",
164
+ withoutEnlargement: true,
165
+ })
166
+ .toBuffer();
167
+ const m = await sharp(resized).metadata();
168
+ const newW = m.width ?? workW;
169
+ const newH = m.height ?? workH;
170
+ for (const q of JPEG_QUALITY_LADDER) {
171
+ attempt = await sharp(resized).jpeg({ quality: q, mozjpeg: true }).toBuffer();
172
+ if (attempt.length <= ANTHROPIC_MAX_IMAGE_BYTES) {
173
+ actions.push(`resize ${newW}x${newH} + jpeg q=${q} → ${formatBytes(attempt.length)}`);
174
+ return {
175
+ data: attempt.toString("base64"),
176
+ mimeType: outMime,
177
+ originalBytes,
178
+ finalBytes: attempt.length,
179
+ originalDim,
180
+ finalDim: { width: newW, height: newH },
181
+ actions,
182
+ changed: true,
183
+ };
184
+ }
185
+ }
186
+ workBuf = resized;
187
+ workW = newW;
188
+ workH = newH;
189
+ }
190
+
191
+ // Give up — return last attempt anyway; pi will at least try.
192
+ actions.push(`final ${formatBytes(attempt.length)} — over limit`);
193
+ return {
194
+ data: attempt.toString("base64"),
195
+ mimeType: outMime,
196
+ originalBytes,
197
+ finalBytes: attempt.length,
198
+ originalDim,
199
+ finalDim: { width: workW, height: workH },
200
+ actions,
201
+ changed: true,
202
+ };
203
+ }
204
+
205
+ function formatBytes(bytes: number): string {
206
+ if (bytes >= 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(2)}MB`;
207
+ if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)}KB`;
208
+ return `${bytes}B`;
209
+ }
package/src/preview.ts CHANGED
@@ -1,8 +1,12 @@
1
1
  import {
2
+ Box,
3
+ Container,
2
4
  getCellDimensions,
3
5
  Image,
4
6
  type Component,
5
7
  type ImageTheme,
8
+ Spacer,
9
+ Text,
6
10
  truncateToWidth,
7
11
  visibleWidth,
8
12
  } from "@earendil-works/pi-tui";
@@ -18,12 +22,21 @@ function formatAttachmentLine(
18
22
  return visibleWidth(line) > maxWidth ? truncateToWidth(line, maxWidth, "") : line;
19
23
  }
20
24
 
25
+ export type ImagePreviewMessageStyle = "raw" | "collapsible";
26
+
27
+ interface ImagePreviewMessageTheme extends ImageTheme {
28
+ background?: (text: string) => string;
29
+ title?: (text: string) => string;
30
+ muted?: (text: string) => string;
31
+ }
32
+
21
33
  export class ImagePreviewMessage implements Component {
22
34
  private readonly images: Image[];
23
35
 
24
36
  constructor(
25
37
  private readonly attachments: ImageAttachment[],
26
- private readonly theme: ImageTheme,
38
+ private readonly theme: ImagePreviewMessageTheme,
39
+ private readonly options: { expanded?: boolean; style?: ImagePreviewMessageStyle } = {},
27
40
  ) {
28
41
  this.images = attachments.map(
29
42
  (attachment) =>
@@ -35,18 +48,54 @@ export class ImagePreviewMessage implements Component {
35
48
  );
36
49
  }
37
50
 
51
+ invalidate(): void {
52
+ for (const image of this.images) image.invalidate();
53
+ }
54
+
38
55
  render(width: number): string[] {
56
+ return this.options.style === "collapsible"
57
+ ? this.renderCollapsible(width)
58
+ : this.renderRaw(width);
59
+ }
60
+
61
+ private renderRaw(width: number): string[] {
39
62
  const lines: string[] = [];
63
+ const safeWidth = Math.max(1, width);
40
64
  for (let index = 0; index < this.attachments.length; index++) {
41
65
  const attachment = this.attachments[index]!;
42
- lines.push(formatAttachmentLine(attachment, width, this.theme.fallbackColor));
43
- lines.push(...this.images[index]!.render(width));
66
+ lines.push(formatAttachmentLine(attachment, safeWidth, this.theme.fallbackColor));
67
+ lines.push(...this.images[index]!.render(safeWidth));
44
68
  }
45
69
  return lines;
46
70
  }
47
71
 
48
- invalidate(): void {
49
- for (const image of this.images) image.invalidate();
72
+ private renderCollapsible(width: number): string[] {
73
+ const container = new Container();
74
+ container.addChild(new Spacer(1));
75
+ const box = new Box(1, 1, this.theme.background);
76
+ container.addChild(box);
77
+
78
+ const title = this.theme.title ?? this.theme.fallbackColor;
79
+ const muted = this.theme.muted ?? this.theme.fallbackColor;
80
+ const summary =
81
+ this.attachments.length === 1
82
+ ? `Attached ${this.attachments[0]!.placeholder}`
83
+ : `Attached ${this.attachments.length} images`;
84
+ const suffix = this.options.expanded ? " (ctrl+o to collapse)" : " (ctrl+o to expand)";
85
+ box.addChild(new Text(`${title(summary)}${muted(suffix)}`, 0, 0));
86
+
87
+ for (const attachment of this.attachments) {
88
+ box.addChild(new Text(formatAttachmentLine(attachment, width, muted), 0, 0));
89
+ }
90
+
91
+ const lines = container.render(width);
92
+ if (!this.options.expanded) return lines;
93
+
94
+ const safeWidth = Math.max(1, width);
95
+ for (let index = 0; index < this.attachments.length; index++) {
96
+ lines.push(...this.images[index]!.render(safeWidth));
97
+ }
98
+ return lines;
50
99
  }
51
100
  }
52
101
 
@@ -1,5 +1,5 @@
1
1
  import { PASTE_END, PASTE_START } from "./editor.ts";
2
- import { replaceImagePathsInText } from "./image-utils.ts";
2
+ import { describeReject, replaceImagePathsInText } from "./image-utils.ts";
3
3
  import type { AttachmentStore } from "./store.ts";
4
4
  import type { ImageAttachment, LoadImageResult } from "./types.ts";
5
5
 
@@ -19,11 +19,7 @@ export function createImagePasteTerminalInputHandler(options: {
19
19
  cwd: options.cwd,
20
20
  store: options.store,
21
21
  loadImage: options.loadImage,
22
- onReject: (result) => {
23
- if (result.reason === "too-large") {
24
- options.notify?.(`paster: image is over 10 MB and was not attached: ${result.path}`);
25
- }
26
- },
22
+ onReject: (result) => describeReject(result, options.notify),
27
23
  });
28
24
 
29
25
  return (data: string): TerminalInputResult => {
package/src/types.ts CHANGED
@@ -1,7 +1,17 @@
1
1
  import type { ImageDimensions } from "@earendil-works/pi-tui";
2
2
 
3
3
  export const EXTENSION_NAME = "paster";
4
- export const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
4
+ // Cap on the source file we read from disk. Larger inputs are rejected up
5
+ // front with `too-large` so we never try to base64-encode a multi-gigabyte
6
+ // file. Raised from the legacy 10 MB so high-resolution screenshots and raw
7
+ // camera shots can be ingested and then shrunk by optimize-image.ts before
8
+ // being attached.
9
+ export const MAX_IMAGE_BYTES = 64 * 1024 * 1024;
10
+
11
+ // Hard limits enforced by the Anthropic Messages API. Used by
12
+ // optimize-image.ts to decide when an attachment needs to be shrunk.
13
+ export const ANTHROPIC_MAX_DIMENSION = 8000;
14
+ export const ANTHROPIC_MAX_IMAGE_BYTES = 5 * 1024 * 1024;
5
15
 
6
16
  export type SupportedImageMimeType = "image/png" | "image/jpeg" | "image/webp" | "image/gif";
7
17
 
@@ -13,6 +23,14 @@ export interface ImageAttachment {
13
23
  data: string;
14
24
  dimensions?: ImageDimensions;
15
25
  createdAt: number;
26
+ /** True once optimizeImageBytes has run on this attachment. */
27
+ optimized?: boolean;
28
+ /** Original (pre-optimization) base64 size in bytes — informational. */
29
+ originalBytes?: number;
30
+ /** Final (post-optimization) base64 size in bytes — informational. */
31
+ finalBytes?: number;
32
+ /** Human-readable trail of optimization actions applied, if any. */
33
+ optimizeActions?: string[];
16
34
  }
17
35
 
18
36
  export interface LoadedImage {