pi-paster 0.1.5 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +49 -1
- package/dist/index.mjs +416 -27
- package/package.json +5 -2
- package/src/clipboard.ts +73 -3
- package/src/editor.ts +2 -8
- package/src/image-utils.ts +192 -11
- package/src/index.ts +12 -5
- package/src/optimize-image.ts +209 -0
- package/src/preview.ts +3 -2
- package/src/terminal-input.ts +2 -6
- package/src/types.ts +19 -1
package/dist/index.d.mts
CHANGED
|
@@ -23,6 +23,8 @@ declare function resolvePasterConfig(config?: PasterConfig): ResolvedPasterConfi
|
|
|
23
23
|
//#region src/types.d.ts
|
|
24
24
|
declare const EXTENSION_NAME = "paster";
|
|
25
25
|
declare const MAX_IMAGE_BYTES: number;
|
|
26
|
+
declare const ANTHROPIC_MAX_DIMENSION = 8000;
|
|
27
|
+
declare const ANTHROPIC_MAX_IMAGE_BYTES: number;
|
|
26
28
|
type SupportedImageMimeType = "image/png" | "image/jpeg" | "image/webp" | "image/gif";
|
|
27
29
|
interface ImageAttachment {
|
|
28
30
|
id: number;
|
|
@@ -32,6 +34,14 @@ interface ImageAttachment {
|
|
|
32
34
|
data: string;
|
|
33
35
|
dimensions?: ImageDimensions;
|
|
34
36
|
createdAt: number;
|
|
37
|
+
/** True once optimizeImageBytes has run on this attachment. */
|
|
38
|
+
optimized?: boolean;
|
|
39
|
+
/** Original (pre-optimization) base64 size in bytes — informational. */
|
|
40
|
+
originalBytes?: number;
|
|
41
|
+
/** Final (post-optimization) base64 size in bytes — informational. */
|
|
42
|
+
finalBytes?: number;
|
|
43
|
+
/** Human-readable trail of optimization actions applied, if any. */
|
|
44
|
+
optimizeActions?: string[];
|
|
35
45
|
}
|
|
36
46
|
interface LoadedImage {
|
|
37
47
|
originalPath: string;
|
|
@@ -66,6 +76,25 @@ type ClipboardImageResult = {
|
|
|
66
76
|
};
|
|
67
77
|
declare function readClipboardImage(maxBytes?: number): ClipboardImageResult;
|
|
68
78
|
//#endregion
|
|
79
|
+
//#region src/optimize-image.d.ts
|
|
80
|
+
interface OptimizeResult {
|
|
81
|
+
data: string;
|
|
82
|
+
mimeType: SupportedImageMimeType;
|
|
83
|
+
originalBytes: number;
|
|
84
|
+
finalBytes: number;
|
|
85
|
+
originalDim?: {
|
|
86
|
+
width: number;
|
|
87
|
+
height: number;
|
|
88
|
+
};
|
|
89
|
+
finalDim?: {
|
|
90
|
+
width: number;
|
|
91
|
+
height: number;
|
|
92
|
+
};
|
|
93
|
+
actions: string[];
|
|
94
|
+
changed: boolean;
|
|
95
|
+
}
|
|
96
|
+
declare function optimizeImageBytes(input: Buffer, mime: SupportedImageMimeType): Promise<OptimizeResult>;
|
|
97
|
+
//#endregion
|
|
69
98
|
//#region src/store.d.ts
|
|
70
99
|
declare class AttachmentStore {
|
|
71
100
|
private nextId;
|
|
@@ -110,8 +139,14 @@ interface PathToken {
|
|
|
110
139
|
value: string;
|
|
111
140
|
start: number;
|
|
112
141
|
end: number;
|
|
142
|
+
bare: boolean;
|
|
113
143
|
}
|
|
114
144
|
declare function detectImageMimeType(bytes: Uint8Array): SupportedImageMimeType | undefined;
|
|
145
|
+
declare function isWindowsDrivePath(value: string): boolean;
|
|
146
|
+
declare function isWindowsUncPath(value: string): boolean;
|
|
147
|
+
declare function isWindowsLikePath(value: string): boolean;
|
|
148
|
+
declare function isWsl(): boolean;
|
|
149
|
+
declare function windowsToWslPath(windowsPath: string): string;
|
|
115
150
|
declare function resolveImagePath(input: string, cwd: string): string;
|
|
116
151
|
declare function shellUnescape(input: string): string;
|
|
117
152
|
declare function tokenizePathLikeText(text: string): PathToken[];
|
|
@@ -130,6 +165,19 @@ declare function replaceImagePathsInText(text: string, options: {
|
|
|
130
165
|
accepted: ImageAttachment[];
|
|
131
166
|
};
|
|
132
167
|
declare function imagesForText(store: AttachmentStore, text: string, existing?: PasterImageContent[]): PasterImageContent[];
|
|
168
|
+
/**
|
|
169
|
+
* Async variant of imagesForText that runs each attachment through the
|
|
170
|
+
* Anthropic-aware image optimizer (resize to 8000px cap, JPEG ladder to stay
|
|
171
|
+
* under the 5 MB / 32 MB request caps). Optimization is cached on the
|
|
172
|
+
* attachment so the cost is paid once per image, not per submit.
|
|
173
|
+
*
|
|
174
|
+
* Used by paster's `input` handler; safe to await on the hot path because
|
|
175
|
+
* sharp is only invoked when the image is actually over the limits.
|
|
176
|
+
*/
|
|
177
|
+
declare function imagesForTextOptimized(store: AttachmentStore, text: string, existing?: PasterImageContent[]): Promise<PasterImageContent[]>;
|
|
178
|
+
declare function describeReject(result: Exclude<LoadImageResult, {
|
|
179
|
+
ok: true;
|
|
180
|
+
}>, notify?: (message: string) => void): void;
|
|
133
181
|
//#endregion
|
|
134
182
|
//#region src/preview.d.ts
|
|
135
183
|
declare class ImagePreviewMessage implements Component {
|
|
@@ -174,4 +222,4 @@ declare function createImagePasteTerminalInputHandler(options: {
|
|
|
174
222
|
declare function createPaster(config?: PasterConfig): (pi: ExtensionAPI) => void;
|
|
175
223
|
declare function paster(pi: ExtensionAPI, config?: PasterConfig): void;
|
|
176
224
|
//#endregion
|
|
177
|
-
export { AttachmentStore, ClipboardImageResult, CursorImagePreviewWidget, DEFAULT_PASTER_CONFIG, EXTENSION_NAME, ImageAttachment, ImagePreviewMessage, LoadImageResult, LoadedImage, MAX_IMAGE_BYTES, PASTE_END, PASTE_START, PasterConfig, PasterEditor, PasterImageContent, PasterPreviewDetails, ResolvedPasterConfig, SupportedImageMimeType, TerminalInputResult, createImagePasteTerminalInputHandler, createPaster, paster as default, detectImageMimeType, dimensionsForImage, imagesForText, loadImageFromPath, readClipboardImage, replaceImagePathsInText, resolveImagePath, resolvePasterConfig, shellUnescape, tokenizePathLikeText };
|
|
225
|
+
export { ANTHROPIC_MAX_DIMENSION, ANTHROPIC_MAX_IMAGE_BYTES, AttachmentStore, ClipboardImageResult, CursorImagePreviewWidget, DEFAULT_PASTER_CONFIG, EXTENSION_NAME, ImageAttachment, ImagePreviewMessage, LoadImageResult, LoadedImage, MAX_IMAGE_BYTES, OptimizeResult, PASTE_END, PASTE_START, PasterConfig, PasterEditor, PasterImageContent, PasterPreviewDetails, ResolvedPasterConfig, SupportedImageMimeType, TerminalInputResult, createImagePasteTerminalInputHandler, createPaster, paster as default, describeReject, detectImageMimeType, dimensionsForImage, imagesForText, imagesForTextOptimized, isWindowsDrivePath, isWindowsLikePath, isWindowsUncPath, isWsl, loadImageFromPath, optimizeImageBytes, readClipboardImage, replaceImagePathsInText, resolveImagePath, resolvePasterConfig, shellUnescape, tokenizePathLikeText, windowsToWslPath };
|
package/dist/index.mjs
CHANGED
|
@@ -3,22 +3,233 @@ import { randomUUID } from "node:crypto";
|
|
|
3
3
|
import { existsSync, readFileSync, statSync, unlinkSync } from "node:fs";
|
|
4
4
|
import { homedir, tmpdir } from "node:os";
|
|
5
5
|
import { isAbsolute, join, resolve } from "node:path";
|
|
6
|
+
import { platform } from "node:process";
|
|
6
7
|
import { Image, getCellDimensions, getImageDimensions, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
7
8
|
import { CustomEditor } from "@earendil-works/pi-coding-agent";
|
|
8
9
|
//#region src/types.ts
|
|
9
10
|
const EXTENSION_NAME = "paster";
|
|
10
|
-
const MAX_IMAGE_BYTES =
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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 =
|
|
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
|
|
495
|
+
if (process.platform === "darwin") return readMacOSClipboardImage(maxBytes);
|
|
496
|
+
if (process.platform === "win32") return readWindowsClipboardImage(maxBytes);
|
|
497
|
+
if (process.platform === "linux" && isWSL()) return readWindowsClipboardImage(maxBytes);
|
|
498
|
+
return {
|
|
191
499
|
ok: false,
|
|
192
500
|
reason: "unsupported-platform"
|
|
193
501
|
};
|
|
194
|
-
|
|
502
|
+
}
|
|
503
|
+
function isWSL() {
|
|
504
|
+
try {
|
|
505
|
+
return /microsoft|wsl/i.test(readFileSync("/proc/version", "utf8"));
|
|
506
|
+
} catch {
|
|
507
|
+
return false;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
function resolvePowerShell() {
|
|
511
|
+
if (process.platform === "win32") return "powershell.exe";
|
|
512
|
+
for (const c of ["/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe", "/mnt/c/WINDOWS/System32/WindowsPowerShell/v1.0/powershell.exe"]) if (existsSync(c)) return c;
|
|
513
|
+
return "powershell.exe";
|
|
514
|
+
}
|
|
515
|
+
function readWindowsClipboardImage(maxBytes) {
|
|
516
|
+
const exe = resolvePowerShell();
|
|
517
|
+
if (!exe) return {
|
|
518
|
+
ok: false,
|
|
519
|
+
reason: "unsupported-platform"
|
|
520
|
+
};
|
|
521
|
+
const script = [
|
|
522
|
+
"$ErrorActionPreference = 'Stop'",
|
|
523
|
+
"Add-Type -AssemblyName System.Windows.Forms | Out-Null",
|
|
524
|
+
"Add-Type -AssemblyName System.Drawing | Out-Null",
|
|
525
|
+
"$img = [System.Windows.Forms.Clipboard]::GetImage()",
|
|
526
|
+
"if ($img -eq $null) { exit 2 }",
|
|
527
|
+
"$ms = New-Object System.IO.MemoryStream",
|
|
528
|
+
"$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)",
|
|
529
|
+
"[Console]::Out.Write([Convert]::ToBase64String($ms.ToArray()))"
|
|
530
|
+
].join("; ");
|
|
531
|
+
try {
|
|
532
|
+
const result = spawnSync(exe, [
|
|
533
|
+
"-NoProfile",
|
|
534
|
+
"-NonInteractive",
|
|
535
|
+
"-STA",
|
|
536
|
+
"-Command",
|
|
537
|
+
script
|
|
538
|
+
], {
|
|
539
|
+
timeout: 5e3,
|
|
540
|
+
encoding: "utf8",
|
|
541
|
+
maxBuffer: 64 * 1024 * 1024
|
|
542
|
+
});
|
|
543
|
+
if (result.status === 2) return {
|
|
544
|
+
ok: false,
|
|
545
|
+
reason: "empty"
|
|
546
|
+
};
|
|
547
|
+
if (result.status !== 0) return {
|
|
548
|
+
ok: false,
|
|
549
|
+
reason: "read-error"
|
|
550
|
+
};
|
|
551
|
+
const data = (result.stdout || "").trim();
|
|
552
|
+
if (!data) return {
|
|
553
|
+
ok: false,
|
|
554
|
+
reason: "empty"
|
|
555
|
+
};
|
|
556
|
+
const bytes = Buffer.from(data, "base64");
|
|
557
|
+
if (bytes.length === 0) return {
|
|
558
|
+
ok: false,
|
|
559
|
+
reason: "empty"
|
|
560
|
+
};
|
|
561
|
+
if (bytes.length > maxBytes) return {
|
|
562
|
+
ok: false,
|
|
563
|
+
reason: "too-large"
|
|
564
|
+
};
|
|
565
|
+
const mimeType = detectImageMimeType(bytes);
|
|
566
|
+
if (!mimeType) return {
|
|
567
|
+
ok: false,
|
|
568
|
+
reason: "unsupported"
|
|
569
|
+
};
|
|
570
|
+
return {
|
|
571
|
+
ok: true,
|
|
572
|
+
image: {
|
|
573
|
+
originalPath: "clipboard.png",
|
|
574
|
+
mimeType,
|
|
575
|
+
data,
|
|
576
|
+
dimensions: dimensionsForImage(data, mimeType)
|
|
577
|
+
}
|
|
578
|
+
};
|
|
579
|
+
} catch {
|
|
580
|
+
return {
|
|
581
|
+
ok: false,
|
|
582
|
+
reason: "read-error"
|
|
583
|
+
};
|
|
584
|
+
}
|
|
195
585
|
}
|
|
196
586
|
function readMacOSClipboardImage(maxBytes) {
|
|
197
587
|
for (const attempt of [{
|
|
@@ -393,9 +783,7 @@ var PasterEditor = class extends CustomEditor {
|
|
|
393
783
|
return replaceImagePathsInText(text, {
|
|
394
784
|
cwd: this.pasterOptions.cwd,
|
|
395
785
|
store: this.pasterOptions.store,
|
|
396
|
-
onReject: (result) =>
|
|
397
|
-
if (result.reason === "too-large") this.pasterOptions.notify(`paster: image is over 10 MB and was not attached: ${result.path}`);
|
|
398
|
-
}
|
|
786
|
+
onReject: (result) => describeReject(result, this.pasterOptions.notify)
|
|
399
787
|
});
|
|
400
788
|
}
|
|
401
789
|
updateCursorPreview() {
|
|
@@ -426,10 +814,11 @@ var ImagePreviewMessage = class {
|
|
|
426
814
|
}
|
|
427
815
|
render(width) {
|
|
428
816
|
const lines = [];
|
|
817
|
+
const safeWidth = Math.max(1, width);
|
|
429
818
|
for (let index = 0; index < this.attachments.length; index++) {
|
|
430
819
|
const attachment = this.attachments[index];
|
|
431
|
-
lines.push(formatAttachmentLine(attachment,
|
|
432
|
-
lines.push(...this.images[index].render(
|
|
820
|
+
lines.push(formatAttachmentLine(attachment, safeWidth, this.theme.fallbackColor));
|
|
821
|
+
lines.push(...this.images[index].render(safeWidth));
|
|
433
822
|
}
|
|
434
823
|
return lines;
|
|
435
824
|
}
|
|
@@ -512,9 +901,7 @@ function createImagePasteTerminalInputHandler(options) {
|
|
|
512
901
|
cwd: options.cwd,
|
|
513
902
|
store: options.store,
|
|
514
903
|
loadImage: options.loadImage,
|
|
515
|
-
onReject: (result) =>
|
|
516
|
-
if (result.reason === "too-large") options.notify?.(`paster: image is over 10 MB and was not attached: ${result.path}`);
|
|
517
|
-
}
|
|
904
|
+
onReject: (result) => describeReject(result, options.notify)
|
|
518
905
|
});
|
|
519
906
|
return (data) => {
|
|
520
907
|
let prefix = "";
|
|
@@ -612,24 +999,26 @@ function paster(pi, config = {}) {
|
|
|
612
999
|
store.clear();
|
|
613
1000
|
});
|
|
614
1001
|
function previewMessage(attachments) {
|
|
1002
|
+
const placeholders = attachments.map((attachment) => attachment.placeholder);
|
|
615
1003
|
return {
|
|
616
1004
|
customType: "paster-preview",
|
|
617
|
-
content: ""
|
|
1005
|
+
content: `(attachment preview: ${placeholders.join(", ")})`,
|
|
618
1006
|
display: true,
|
|
619
|
-
details: { placeholders
|
|
1007
|
+
details: { placeholders }
|
|
620
1008
|
};
|
|
621
1009
|
}
|
|
622
|
-
pi.on("input", (event, ctx) => {
|
|
1010
|
+
pi.on("input", async (event, ctx) => {
|
|
623
1011
|
if (event.source === "extension") return { action: "continue" };
|
|
624
1012
|
if (ctx.hasUI) activeEditor?.clearCursorPreview();
|
|
625
1013
|
const attachments = store.matchingPlaceholders(event.text);
|
|
626
1014
|
if (attachments.length === 0) return { action: "continue" };
|
|
627
1015
|
if (ctx.isIdle()) pendingPreview = attachments;
|
|
628
1016
|
else pi.sendMessage(previewMessage(attachments), { deliverAs: "followUp" });
|
|
1017
|
+
const images = await imagesForTextOptimized(store, event.text, event.images);
|
|
629
1018
|
return {
|
|
630
1019
|
action: "transform",
|
|
631
1020
|
text: event.text,
|
|
632
|
-
images
|
|
1021
|
+
images
|
|
633
1022
|
};
|
|
634
1023
|
});
|
|
635
1024
|
pi.on("before_agent_start", () => {
|
|
@@ -640,4 +1029,4 @@ function paster(pi, config = {}) {
|
|
|
640
1029
|
});
|
|
641
1030
|
}
|
|
642
1031
|
//#endregion
|
|
643
|
-
export { AttachmentStore, CursorImagePreviewWidget, DEFAULT_PASTER_CONFIG, EXTENSION_NAME, ImagePreviewMessage, MAX_IMAGE_BYTES, PASTE_END, PASTE_START, PasterEditor, createImagePasteTerminalInputHandler, createPaster, paster as default, detectImageMimeType, dimensionsForImage, imagesForText, loadImageFromPath, readClipboardImage, replaceImagePathsInText, resolveImagePath, resolvePasterConfig, shellUnescape, tokenizePathLikeText };
|
|
1032
|
+
export { ANTHROPIC_MAX_DIMENSION, ANTHROPIC_MAX_IMAGE_BYTES, AttachmentStore, CursorImagePreviewWidget, DEFAULT_PASTER_CONFIG, EXTENSION_NAME, ImagePreviewMessage, MAX_IMAGE_BYTES, PASTE_END, PASTE_START, PasterEditor, createImagePasteTerminalInputHandler, createPaster, paster as default, describeReject, detectImageMimeType, dimensionsForImage, imagesForText, imagesForTextOptimized, isWindowsDrivePath, isWindowsLikePath, isWindowsUncPath, isWsl, loadImageFromPath, optimizeImageBytes, readClipboardImage, replaceImagePathsInText, resolveImagePath, resolvePasterConfig, shellUnescape, tokenizePathLikeText, windowsToWslPath };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-paster",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
61
|
+
"image": "https://unpkg.com/pi-paster@0.2.0/docs/preview.png"
|
|
59
62
|
}
|
|
60
63
|
}
|