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/README.md +10 -6
- package/dist/index.d.mts +75 -3
- package/dist/index.mjs +546 -48
- package/package.json +5 -2
- package/src/clipboard.ts +73 -3
- package/src/config.ts +14 -0
- package/src/editor.ts +107 -19
- package/src/image-utils.ts +200 -11
- package/src/index.ts +27 -10
- package/src/optimize-image.ts +209 -0
- package/src/preview.ts +54 -5
- package/src/terminal-input.ts +2 -6
- package/src/types.ts +19 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-paster",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
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
|
|
61
|
+
"image": "https://unpkg.com/pi-paster@0.2.1/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
|
|
18
|
-
return
|
|
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/config.ts
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
|
+
export type SubmittedPreviewStyle = "raw" | "collapsible";
|
|
2
|
+
|
|
1
3
|
export interface PasterConfig {
|
|
4
|
+
/** How submitted attachment previews render in chat history. */
|
|
5
|
+
submittedPreviewStyle?: SubmittedPreviewStyle;
|
|
6
|
+
/** Append local image paths to the submitted prompt so the agent can manipulate the source files. */
|
|
7
|
+
includeImagePathsInPrompt?: boolean;
|
|
2
8
|
customEditor?: {
|
|
3
9
|
/** Replace pi's input editor to enable inline image UX features. */
|
|
4
10
|
enabled?: boolean;
|
|
@@ -10,6 +16,8 @@ export interface PasterConfig {
|
|
|
10
16
|
}
|
|
11
17
|
|
|
12
18
|
export interface ResolvedPasterConfig {
|
|
19
|
+
submittedPreviewStyle: SubmittedPreviewStyle;
|
|
20
|
+
includeImagePathsInPrompt: boolean;
|
|
13
21
|
customEditor: {
|
|
14
22
|
enabled: boolean;
|
|
15
23
|
showImagePreview: boolean;
|
|
@@ -18,6 +26,8 @@ export interface ResolvedPasterConfig {
|
|
|
18
26
|
}
|
|
19
27
|
|
|
20
28
|
export const DEFAULT_PASTER_CONFIG: ResolvedPasterConfig = {
|
|
29
|
+
submittedPreviewStyle: "raw",
|
|
30
|
+
includeImagePathsInPrompt: true,
|
|
21
31
|
customEditor: {
|
|
22
32
|
enabled: true,
|
|
23
33
|
showImagePreview: true,
|
|
@@ -27,6 +37,10 @@ export const DEFAULT_PASTER_CONFIG: ResolvedPasterConfig = {
|
|
|
27
37
|
|
|
28
38
|
export function resolvePasterConfig(config: PasterConfig = {}): ResolvedPasterConfig {
|
|
29
39
|
return {
|
|
40
|
+
submittedPreviewStyle:
|
|
41
|
+
config.submittedPreviewStyle ?? DEFAULT_PASTER_CONFIG.submittedPreviewStyle,
|
|
42
|
+
includeImagePathsInPrompt:
|
|
43
|
+
config.includeImagePathsInPrompt ?? DEFAULT_PASTER_CONFIG.includeImagePathsInPrompt,
|
|
30
44
|
customEditor: {
|
|
31
45
|
enabled: config.customEditor?.enabled ?? DEFAULT_PASTER_CONFIG.customEditor.enabled,
|
|
32
46
|
showImagePreview:
|
package/src/editor.ts
CHANGED
|
@@ -1,12 +1,65 @@
|
|
|
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
|
|
|
7
7
|
export const PASTE_START = "\x1b[200~";
|
|
8
8
|
export const PASTE_END = "\x1b[201~";
|
|
9
9
|
const PLACEHOLDER_REGEX = /\[#image \d+\]/g;
|
|
10
|
+
const PASTE_MARKER_REGEX = /\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]/g;
|
|
11
|
+
const baseSegmenter = new Intl.Segmenter();
|
|
12
|
+
|
|
13
|
+
interface AtomicSpan {
|
|
14
|
+
start: number;
|
|
15
|
+
end: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface EditorSegmentationAccess {
|
|
19
|
+
segment?: (text: string) => Iterable<Intl.SegmentData>;
|
|
20
|
+
pastes?: Map<number, string>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function atomicSpansForText(text: string, validPasteIds: Set<number>): AtomicSpan[] {
|
|
24
|
+
const spans: AtomicSpan[] = [];
|
|
25
|
+
|
|
26
|
+
for (const match of text.matchAll(PASTE_MARKER_REGEX)) {
|
|
27
|
+
const id = Number.parseInt(match[1]!, 10);
|
|
28
|
+
if (!validPasteIds.has(id)) continue;
|
|
29
|
+
spans.push({ start: match.index, end: match.index + match[0].length });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
for (const match of text.matchAll(PLACEHOLDER_REGEX)) {
|
|
33
|
+
const placeholder = match[0];
|
|
34
|
+
spans.push({ start: match.index, end: match.index + placeholder.length });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return spans.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function segmentTextWithAtomicImages(
|
|
41
|
+
text: string,
|
|
42
|
+
store: AttachmentStore,
|
|
43
|
+
validPasteIds: Set<number> = new Set(),
|
|
44
|
+
): Intl.SegmentData[] {
|
|
45
|
+
const spans = atomicSpansForText(text, validPasteIds);
|
|
46
|
+
if (spans.length === 0) return [...baseSegmenter.segment(text)];
|
|
47
|
+
|
|
48
|
+
const result: Intl.SegmentData[] = [];
|
|
49
|
+
let spanIndex = 0;
|
|
50
|
+
for (const segment of baseSegmenter.segment(text)) {
|
|
51
|
+
while (spanIndex < spans.length && spans[spanIndex]!.end <= segment.index) spanIndex++;
|
|
52
|
+
const span = spans[spanIndex];
|
|
53
|
+
if (span && segment.index >= span.start && segment.index < span.end) {
|
|
54
|
+
if (segment.index === span.start) {
|
|
55
|
+
result.push({ segment: text.slice(span.start, span.end), index: span.start, input: text });
|
|
56
|
+
}
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
result.push(segment);
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
10
63
|
|
|
11
64
|
interface EditorCursor {
|
|
12
65
|
line: number;
|
|
@@ -14,7 +67,8 @@ interface EditorCursor {
|
|
|
14
67
|
}
|
|
15
68
|
|
|
16
69
|
interface PlaceholderAtCursor {
|
|
17
|
-
attachment
|
|
70
|
+
attachment?: ImageAttachment;
|
|
71
|
+
placeholder: string;
|
|
18
72
|
line: number;
|
|
19
73
|
start: number;
|
|
20
74
|
end: number;
|
|
@@ -32,16 +86,16 @@ function findPlaceholderAtCursor(
|
|
|
32
86
|
const start = match.index;
|
|
33
87
|
const end = start + placeholder.length;
|
|
34
88
|
const attachment = store.get(placeholder);
|
|
35
|
-
if (!attachment) continue;
|
|
89
|
+
if (!attachment && mode !== "hover") continue;
|
|
36
90
|
|
|
37
91
|
if (mode === "hover" && cursor.col >= start && cursor.col < end) {
|
|
38
|
-
return { attachment, line: cursor.line, start, end };
|
|
92
|
+
return { attachment, placeholder, line: cursor.line, start, end };
|
|
39
93
|
}
|
|
40
94
|
if (mode === "backspace" && cursor.col > start && cursor.col <= end) {
|
|
41
|
-
return { attachment, line: cursor.line, start, end };
|
|
95
|
+
return { attachment, placeholder, line: cursor.line, start, end };
|
|
42
96
|
}
|
|
43
97
|
if (mode === "delete" && cursor.col >= start && cursor.col < end) {
|
|
44
|
-
return { attachment, line: cursor.line, start, end };
|
|
98
|
+
return { attachment, placeholder, line: cursor.line, start, end };
|
|
45
99
|
}
|
|
46
100
|
}
|
|
47
101
|
return undefined;
|
|
@@ -76,6 +130,7 @@ export class PasterEditor extends CustomEditor {
|
|
|
76
130
|
},
|
|
77
131
|
) {
|
|
78
132
|
super(tui, theme, pasterKeybindings);
|
|
133
|
+
this.installAtomicImageSegmentation();
|
|
79
134
|
this.onPasteImage = () => {
|
|
80
135
|
void this.handlePasteClipboardImage();
|
|
81
136
|
};
|
|
@@ -89,6 +144,7 @@ export class PasterEditor extends CustomEditor {
|
|
|
89
144
|
|
|
90
145
|
override handleInput(data: string): void {
|
|
91
146
|
if (this.handleBracketedPaste(data)) return;
|
|
147
|
+
if (this.handleAtomicPlaceholderNavigation(data)) return;
|
|
92
148
|
if (this.pasterOptions.deletePlaceholderAsBlock && this.handleAtomicPlaceholderDelete(data))
|
|
93
149
|
return;
|
|
94
150
|
|
|
@@ -101,6 +157,16 @@ export class PasterEditor extends CustomEditor {
|
|
|
101
157
|
this.pasterOptions.setCursorPreview(undefined);
|
|
102
158
|
}
|
|
103
159
|
|
|
160
|
+
private installAtomicImageSegmentation(): void {
|
|
161
|
+
const editor = this as unknown as EditorSegmentationAccess;
|
|
162
|
+
editor.segment = (text: string) =>
|
|
163
|
+
segmentTextWithAtomicImages(
|
|
164
|
+
text,
|
|
165
|
+
this.pasterOptions.store,
|
|
166
|
+
new Set(editor.pastes?.keys() ?? []),
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
104
170
|
private async handlePasteClipboardImage(): Promise<void> {
|
|
105
171
|
const attachment = await this.pasterOptions.pasteClipboardImage?.();
|
|
106
172
|
if (!attachment) return;
|
|
@@ -149,6 +215,29 @@ export class PasterEditor extends CustomEditor {
|
|
|
149
215
|
return true;
|
|
150
216
|
}
|
|
151
217
|
|
|
218
|
+
private handleAtomicPlaceholderNavigation(data: string): boolean {
|
|
219
|
+
const isLeft = this.pasterKeybindings.matches(data, "tui.editor.cursorLeft");
|
|
220
|
+
const isRight = this.pasterKeybindings.matches(data, "tui.editor.cursorRight");
|
|
221
|
+
if (!isLeft && !isRight) return false;
|
|
222
|
+
|
|
223
|
+
const line = this.getLines()[this.getCursor().line] ?? "";
|
|
224
|
+
const cursor = this.getCursor();
|
|
225
|
+
const matches = [...line.matchAll(PLACEHOLDER_REGEX)];
|
|
226
|
+
const target = isRight
|
|
227
|
+
? matches.find(
|
|
228
|
+
(match) => cursor.col >= match.index && cursor.col < match.index + match[0].length,
|
|
229
|
+
)
|
|
230
|
+
: matches.find(
|
|
231
|
+
(match) => cursor.col > match.index && cursor.col <= match.index + match[0].length,
|
|
232
|
+
);
|
|
233
|
+
if (!target) return false;
|
|
234
|
+
|
|
235
|
+
this.setCursor(target.index + (isRight ? target[0].length : 0));
|
|
236
|
+
this.updateCursorPreview();
|
|
237
|
+
this.tui.requestRender();
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
|
|
152
241
|
private handleAtomicPlaceholderDelete(data: string): boolean {
|
|
153
242
|
const isBackspace = this.pasterKeybindings.matches(data, "tui.editor.deleteCharBackward");
|
|
154
243
|
const isDelete = this.pasterKeybindings.matches(data, "tui.editor.deleteCharForward");
|
|
@@ -168,17 +257,22 @@ export class PasterEditor extends CustomEditor {
|
|
|
168
257
|
return true;
|
|
169
258
|
}
|
|
170
259
|
|
|
260
|
+
private setCursor(col: number): void {
|
|
261
|
+
const editor = this as unknown as EditorStateAccess;
|
|
262
|
+
if (editor.setCursorCol) {
|
|
263
|
+
editor.setCursorCol(col);
|
|
264
|
+
} else {
|
|
265
|
+
editor.state.cursorCol = col;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
171
269
|
private deleteLineRange(lineIndex: number, start: number, end: number): void {
|
|
172
270
|
const editor = this as unknown as EditorStateAccess;
|
|
173
271
|
editor.pushUndoSnapshot?.();
|
|
174
272
|
const line = editor.state.lines[lineIndex] ?? "";
|
|
175
273
|
editor.state.lines[lineIndex] = line.slice(0, start) + line.slice(end);
|
|
176
274
|
editor.state.cursorLine = lineIndex;
|
|
177
|
-
|
|
178
|
-
editor.setCursorCol(start);
|
|
179
|
-
} else {
|
|
180
|
-
editor.state.cursorCol = start;
|
|
181
|
-
}
|
|
275
|
+
this.setCursor(start);
|
|
182
276
|
editor.lastAction = null;
|
|
183
277
|
editor.historyIndex = -1;
|
|
184
278
|
this.onChange?.(this.getText());
|
|
@@ -189,13 +283,7 @@ export class PasterEditor extends CustomEditor {
|
|
|
189
283
|
return replaceImagePathsInText(text, {
|
|
190
284
|
cwd: this.pasterOptions.cwd,
|
|
191
285
|
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
|
-
},
|
|
286
|
+
onReject: (result) => describeReject(result, this.pasterOptions.notify),
|
|
199
287
|
});
|
|
200
288
|
}
|
|
201
289
|
|
|
@@ -206,7 +294,7 @@ export class PasterEditor extends CustomEditor {
|
|
|
206
294
|
this.getCursor(),
|
|
207
295
|
"hover",
|
|
208
296
|
);
|
|
209
|
-
const nextPlaceholder = target?.attachment
|
|
297
|
+
const nextPlaceholder = target?.attachment?.placeholder;
|
|
210
298
|
if (nextPlaceholder === this.activePreviewPlaceholder) return;
|
|
211
299
|
this.activePreviewPlaceholder = nextPlaceholder;
|
|
212
300
|
this.pasterOptions.setCursorPreview(target?.attachment);
|
package/src/image-utils.ts
CHANGED
|
@@ -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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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 =
|
|
336
|
+
cursor = extended.end;
|
|
215
337
|
replaced++;
|
|
216
338
|
}
|
|
217
339
|
|
|
@@ -234,3 +356,70 @@ export function imagesForText(
|
|
|
234
356
|
})),
|
|
235
357
|
];
|
|
236
358
|
}
|
|
359
|
+
|
|
360
|
+
export function appendImagePathContext(text: string, attachments: ImageAttachment[]): string {
|
|
361
|
+
if (attachments.length === 0) return text;
|
|
362
|
+
const lines = attachments.map(
|
|
363
|
+
(attachment) => `- ${attachment.placeholder}: ${attachment.originalPath}`,
|
|
364
|
+
);
|
|
365
|
+
return `${text}\n\nAttached image paths:\n${lines.join("\n")}`;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Async variant of imagesForText that runs each attachment through the
|
|
370
|
+
* Anthropic-aware image optimizer (resize to 8000px cap, JPEG ladder to stay
|
|
371
|
+
* under the 5 MB / 32 MB request caps). Optimization is cached on the
|
|
372
|
+
* attachment so the cost is paid once per image, not per submit.
|
|
373
|
+
*
|
|
374
|
+
* Used by paster's `input` handler; safe to await on the hot path because
|
|
375
|
+
* sharp is only invoked when the image is actually over the limits.
|
|
376
|
+
*/
|
|
377
|
+
export async function imagesForTextOptimized(
|
|
378
|
+
store: AttachmentStore,
|
|
379
|
+
text: string,
|
|
380
|
+
existing: PasterImageContent[] = [],
|
|
381
|
+
): Promise<PasterImageContent[]> {
|
|
382
|
+
const attachments = store.matchingPlaceholders(text);
|
|
383
|
+
const optimized: PasterImageContent[] = [];
|
|
384
|
+
for (const attachment of attachments) {
|
|
385
|
+
if (!attachment.optimized) {
|
|
386
|
+
try {
|
|
387
|
+
const input = Buffer.from(attachment.data, "base64");
|
|
388
|
+
const result = await optimizeImageBytes(input, attachment.mimeType);
|
|
389
|
+
if (result.changed) {
|
|
390
|
+
attachment.data = result.data;
|
|
391
|
+
attachment.mimeType = result.mimeType;
|
|
392
|
+
if (result.finalDim) {
|
|
393
|
+
attachment.dimensions = {
|
|
394
|
+
widthPx: result.finalDim.width,
|
|
395
|
+
heightPx: result.finalDim.height,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
attachment.optimized = true;
|
|
400
|
+
attachment.originalBytes = result.originalBytes;
|
|
401
|
+
attachment.finalBytes = result.finalBytes;
|
|
402
|
+
attachment.optimizeActions = result.actions;
|
|
403
|
+
} catch {
|
|
404
|
+
// optimization is best-effort; fall through with the original bytes
|
|
405
|
+
attachment.optimized = true;
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
optimized.push({
|
|
409
|
+
type: "image",
|
|
410
|
+
mimeType: attachment.mimeType,
|
|
411
|
+
data: attachment.data,
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
return [...existing, ...optimized];
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
export function describeReject(
|
|
418
|
+
result: Exclude<LoadImageResult, { ok: true }>,
|
|
419
|
+
notify?: (message: string) => void,
|
|
420
|
+
): void {
|
|
421
|
+
if (!notify) return;
|
|
422
|
+
if (result.reason === "too-large") {
|
|
423
|
+
notify(`paster: image is too large and was not attached: ${result.path}`);
|
|
424
|
+
}
|
|
425
|
+
}
|