pi-paster 0.1.4 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.mts +49 -1
- package/dist/index.mjs +433 -36
- 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 +29 -13
- package/src/optimize-image.ts +209 -0
- package/src/preview.ts +15 -6
- 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 {
|
|
6
|
+
import { platform } from "node:process";
|
|
7
|
+
import { Image, getCellDimensions, getImageDimensions, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
7
8
|
import { CustomEditor } from "@earendil-works/pi-coding-agent";
|
|
8
9
|
//#region src/types.ts
|
|
9
10
|
const EXTENSION_NAME = "paster";
|
|
10
|
-
const MAX_IMAGE_BYTES =
|
|
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 {
|
|
499
|
+
ok: false,
|
|
500
|
+
reason: "unsupported-platform"
|
|
501
|
+
};
|
|
502
|
+
}
|
|
503
|
+
function isWSL() {
|
|
504
|
+
try {
|
|
505
|
+
return /microsoft|wsl/i.test(readFileSync("/proc/version", "utf8"));
|
|
506
|
+
} catch {
|
|
507
|
+
return false;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
function resolvePowerShell() {
|
|
511
|
+
if (process.platform === "win32") return "powershell.exe";
|
|
512
|
+
for (const c of ["/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe", "/mnt/c/WINDOWS/System32/WindowsPowerShell/v1.0/powershell.exe"]) if (existsSync(c)) return c;
|
|
513
|
+
return "powershell.exe";
|
|
514
|
+
}
|
|
515
|
+
function readWindowsClipboardImage(maxBytes) {
|
|
516
|
+
const exe = resolvePowerShell();
|
|
517
|
+
if (!exe) return {
|
|
191
518
|
ok: false,
|
|
192
519
|
reason: "unsupported-platform"
|
|
193
520
|
};
|
|
194
|
-
|
|
521
|
+
const script = [
|
|
522
|
+
"$ErrorActionPreference = 'Stop'",
|
|
523
|
+
"Add-Type -AssemblyName System.Windows.Forms | Out-Null",
|
|
524
|
+
"Add-Type -AssemblyName System.Drawing | Out-Null",
|
|
525
|
+
"$img = [System.Windows.Forms.Clipboard]::GetImage()",
|
|
526
|
+
"if ($img -eq $null) { exit 2 }",
|
|
527
|
+
"$ms = New-Object System.IO.MemoryStream",
|
|
528
|
+
"$img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png)",
|
|
529
|
+
"[Console]::Out.Write([Convert]::ToBase64String($ms.ToArray()))"
|
|
530
|
+
].join("; ");
|
|
531
|
+
try {
|
|
532
|
+
const result = spawnSync(exe, [
|
|
533
|
+
"-NoProfile",
|
|
534
|
+
"-NonInteractive",
|
|
535
|
+
"-STA",
|
|
536
|
+
"-Command",
|
|
537
|
+
script
|
|
538
|
+
], {
|
|
539
|
+
timeout: 5e3,
|
|
540
|
+
encoding: "utf8",
|
|
541
|
+
maxBuffer: 64 * 1024 * 1024
|
|
542
|
+
});
|
|
543
|
+
if (result.status === 2) return {
|
|
544
|
+
ok: false,
|
|
545
|
+
reason: "empty"
|
|
546
|
+
};
|
|
547
|
+
if (result.status !== 0) return {
|
|
548
|
+
ok: false,
|
|
549
|
+
reason: "read-error"
|
|
550
|
+
};
|
|
551
|
+
const data = (result.stdout || "").trim();
|
|
552
|
+
if (!data) return {
|
|
553
|
+
ok: false,
|
|
554
|
+
reason: "empty"
|
|
555
|
+
};
|
|
556
|
+
const bytes = Buffer.from(data, "base64");
|
|
557
|
+
if (bytes.length === 0) return {
|
|
558
|
+
ok: false,
|
|
559
|
+
reason: "empty"
|
|
560
|
+
};
|
|
561
|
+
if (bytes.length > maxBytes) return {
|
|
562
|
+
ok: false,
|
|
563
|
+
reason: "too-large"
|
|
564
|
+
};
|
|
565
|
+
const mimeType = detectImageMimeType(bytes);
|
|
566
|
+
if (!mimeType) return {
|
|
567
|
+
ok: false,
|
|
568
|
+
reason: "unsupported"
|
|
569
|
+
};
|
|
570
|
+
return {
|
|
571
|
+
ok: true,
|
|
572
|
+
image: {
|
|
573
|
+
originalPath: "clipboard.png",
|
|
574
|
+
mimeType,
|
|
575
|
+
data,
|
|
576
|
+
dimensions: dimensionsForImage(data, mimeType)
|
|
577
|
+
}
|
|
578
|
+
};
|
|
579
|
+
} catch {
|
|
580
|
+
return {
|
|
581
|
+
ok: false,
|
|
582
|
+
reason: "read-error"
|
|
583
|
+
};
|
|
584
|
+
}
|
|
195
585
|
}
|
|
196
586
|
function readMacOSClipboardImage(maxBytes) {
|
|
197
587
|
for (const attempt of [{
|
|
@@ -393,9 +783,7 @@ var PasterEditor = class extends CustomEditor {
|
|
|
393
783
|
return replaceImagePathsInText(text, {
|
|
394
784
|
cwd: this.pasterOptions.cwd,
|
|
395
785
|
store: this.pasterOptions.store,
|
|
396
|
-
onReject: (result) =>
|
|
397
|
-
if (result.reason === "too-large") this.pasterOptions.notify(`paster: image is over 10 MB and was not attached: ${result.path}`);
|
|
398
|
-
}
|
|
786
|
+
onReject: (result) => describeReject(result, this.pasterOptions.notify)
|
|
399
787
|
});
|
|
400
788
|
}
|
|
401
789
|
updateCursorPreview() {
|
|
@@ -408,6 +796,11 @@ var PasterEditor = class extends CustomEditor {
|
|
|
408
796
|
};
|
|
409
797
|
//#endregion
|
|
410
798
|
//#region src/preview.ts
|
|
799
|
+
function formatAttachmentLine(attachment, width, style) {
|
|
800
|
+
const maxWidth = Math.max(1, width);
|
|
801
|
+
const line = style(`Attached ${attachment.placeholder} ${attachment.originalPath}`);
|
|
802
|
+
return visibleWidth(line) > maxWidth ? truncateToWidth(line, maxWidth, "") : line;
|
|
803
|
+
}
|
|
411
804
|
var ImagePreviewMessage = class {
|
|
412
805
|
images;
|
|
413
806
|
constructor(attachments, theme) {
|
|
@@ -421,10 +814,11 @@ var ImagePreviewMessage = class {
|
|
|
421
814
|
}
|
|
422
815
|
render(width) {
|
|
423
816
|
const lines = [];
|
|
817
|
+
const safeWidth = Math.max(1, width);
|
|
424
818
|
for (let index = 0; index < this.attachments.length; index++) {
|
|
425
819
|
const attachment = this.attachments[index];
|
|
426
|
-
lines.push(this.theme.fallbackColor
|
|
427
|
-
lines.push(...this.images[index].render(
|
|
820
|
+
lines.push(formatAttachmentLine(attachment, safeWidth, this.theme.fallbackColor));
|
|
821
|
+
lines.push(...this.images[index].render(safeWidth));
|
|
428
822
|
}
|
|
429
823
|
return lines;
|
|
430
824
|
}
|
|
@@ -448,8 +842,7 @@ var CursorImagePreviewWidget = class {
|
|
|
448
842
|
this.image.invalidate();
|
|
449
843
|
}
|
|
450
844
|
headerLine(width) {
|
|
451
|
-
|
|
452
|
-
return this.theme.title(truncateToWidth(title, Math.max(1, width), ""));
|
|
845
|
+
return formatAttachmentLine(this.attachment, width, this.theme.title);
|
|
453
846
|
}
|
|
454
847
|
createImage(attachment, maxWidthCells = 60) {
|
|
455
848
|
return new Image(attachment.data, attachment.mimeType, { fallbackColor: this.theme.accent }, {
|
|
@@ -508,9 +901,7 @@ function createImagePasteTerminalInputHandler(options) {
|
|
|
508
901
|
cwd: options.cwd,
|
|
509
902
|
store: options.store,
|
|
510
903
|
loadImage: options.loadImage,
|
|
511
|
-
onReject: (result) =>
|
|
512
|
-
if (result.reason === "too-large") options.notify?.(`paster: image is over 10 MB and was not attached: ${result.path}`);
|
|
513
|
-
}
|
|
904
|
+
onReject: (result) => describeReject(result, options.notify)
|
|
514
905
|
});
|
|
515
906
|
return (data) => {
|
|
516
907
|
let prefix = "";
|
|
@@ -607,29 +998,35 @@ function paster(pi, config = {}) {
|
|
|
607
998
|
}
|
|
608
999
|
store.clear();
|
|
609
1000
|
});
|
|
610
|
-
|
|
1001
|
+
function previewMessage(attachments) {
|
|
1002
|
+
const placeholders = attachments.map((attachment) => attachment.placeholder);
|
|
1003
|
+
return {
|
|
1004
|
+
customType: "paster-preview",
|
|
1005
|
+
content: `(attachment preview: ${placeholders.join(", ")})`,
|
|
1006
|
+
display: true,
|
|
1007
|
+
details: { placeholders }
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
pi.on("input", async (event, ctx) => {
|
|
611
1011
|
if (event.source === "extension") return { action: "continue" };
|
|
612
1012
|
if (ctx.hasUI) activeEditor?.clearCursorPreview();
|
|
613
1013
|
const attachments = store.matchingPlaceholders(event.text);
|
|
614
1014
|
if (attachments.length === 0) return { action: "continue" };
|
|
615
|
-
pendingPreview = attachments;
|
|
1015
|
+
if (ctx.isIdle()) pendingPreview = attachments;
|
|
1016
|
+
else pi.sendMessage(previewMessage(attachments), { deliverAs: "followUp" });
|
|
1017
|
+
const images = await imagesForTextOptimized(store, event.text, event.images);
|
|
616
1018
|
return {
|
|
617
1019
|
action: "transform",
|
|
618
1020
|
text: event.text,
|
|
619
|
-
images
|
|
1021
|
+
images
|
|
620
1022
|
};
|
|
621
1023
|
});
|
|
622
1024
|
pi.on("before_agent_start", () => {
|
|
623
1025
|
if (pendingPreview.length === 0) return;
|
|
624
|
-
const
|
|
1026
|
+
const message = previewMessage(pendingPreview);
|
|
625
1027
|
pendingPreview = [];
|
|
626
|
-
return { message
|
|
627
|
-
customType: "paster-preview",
|
|
628
|
-
content: "",
|
|
629
|
-
display: true,
|
|
630
|
-
details: { placeholders }
|
|
631
|
-
} };
|
|
1028
|
+
return { message };
|
|
632
1029
|
});
|
|
633
1030
|
}
|
|
634
1031
|
//#endregion
|
|
635
|
-
export { AttachmentStore, CursorImagePreviewWidget, DEFAULT_PASTER_CONFIG, EXTENSION_NAME, ImagePreviewMessage, MAX_IMAGE_BYTES, PASTE_END, PASTE_START, PasterEditor, createImagePasteTerminalInputHandler, createPaster, paster as default, detectImageMimeType, dimensionsForImage, imagesForText, loadImageFromPath, readClipboardImage, replaceImagePathsInText, resolveImagePath, resolvePasterConfig, shellUnescape, tokenizePathLikeText };
|
|
1032
|
+
export { ANTHROPIC_MAX_DIMENSION, ANTHROPIC_MAX_IMAGE_BYTES, AttachmentStore, CursorImagePreviewWidget, DEFAULT_PASTER_CONFIG, EXTENSION_NAME, ImagePreviewMessage, MAX_IMAGE_BYTES, PASTE_END, PASTE_START, PasterEditor, createImagePasteTerminalInputHandler, createPaster, paster as default, describeReject, detectImageMimeType, dimensionsForImage, imagesForText, imagesForTextOptimized, isWindowsDrivePath, isWindowsLikePath, isWindowsUncPath, isWsl, loadImageFromPath, optimizeImageBytes, readClipboardImage, replaceImagePathsInText, resolveImagePath, resolvePasterConfig, shellUnescape, tokenizePathLikeText, windowsToWslPath };
|