pi-paster 0.1.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/README.md +135 -0
- package/dist/index.d.mts +177 -0
- package/dist/index.mjs +634 -0
- package/docs/preview.png +0 -0
- package/package.json +53 -0
- package/spec.md +349 -0
- package/src/clipboard.ts +77 -0
- package/src/config.ts +40 -0
- package/src/editor.ts +214 -0
- package/src/image-utils.ts +236 -0
- package/src/index.ts +143 -0
- package/src/preview.ts +102 -0
- package/src/store.ts +40 -0
- package/src/terminal-input.ts +58 -0
- package/src/types.ts +41 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,634 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { existsSync, readFileSync, statSync, unlinkSync } from "node:fs";
|
|
4
|
+
import { homedir, tmpdir } from "node:os";
|
|
5
|
+
import { basename, isAbsolute, join, resolve } from "node:path";
|
|
6
|
+
import { Image, getCellDimensions, getImageDimensions, truncateToWidth } from "@earendil-works/pi-tui";
|
|
7
|
+
import { CustomEditor } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
//#region src/types.ts
|
|
9
|
+
const EXTENSION_NAME = "paster";
|
|
10
|
+
const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
|
|
11
|
+
//#endregion
|
|
12
|
+
//#region src/image-utils.ts
|
|
13
|
+
function detectImageMimeType(bytes) {
|
|
14
|
+
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
|
+
if (bytes.length >= 3 && bytes[0] === 255 && bytes[1] === 216 && bytes[2] === 255) return "image/jpeg";
|
|
16
|
+
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
|
+
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
|
+
}
|
|
19
|
+
function resolveImagePath(input, cwd) {
|
|
20
|
+
if (input === "~") return homedir();
|
|
21
|
+
if (input.startsWith("~/")) return resolve(homedir(), input.slice(2));
|
|
22
|
+
if (isAbsolute(input)) return input;
|
|
23
|
+
return resolve(cwd, input);
|
|
24
|
+
}
|
|
25
|
+
function shellUnescape(input) {
|
|
26
|
+
let result = "";
|
|
27
|
+
for (let i = 0; i < input.length; i++) {
|
|
28
|
+
const char = input[i];
|
|
29
|
+
if (char === "\\" && i + 1 < input.length) result += input[++i];
|
|
30
|
+
else result += char;
|
|
31
|
+
}
|
|
32
|
+
return result;
|
|
33
|
+
}
|
|
34
|
+
function isPathLike(value) {
|
|
35
|
+
return value.startsWith("/") || value.startsWith("~/") || value === "~" || value.startsWith("./") || value.startsWith("../");
|
|
36
|
+
}
|
|
37
|
+
function tokenizePathLikeText(text) {
|
|
38
|
+
const tokens = [];
|
|
39
|
+
let index = 0;
|
|
40
|
+
while (index < text.length) {
|
|
41
|
+
const char = text[index];
|
|
42
|
+
if (/\s/.test(char)) {
|
|
43
|
+
index++;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const start = index;
|
|
47
|
+
if (char === "'" || char === "\"") {
|
|
48
|
+
const quote = char;
|
|
49
|
+
index++;
|
|
50
|
+
let value = "";
|
|
51
|
+
let closed = false;
|
|
52
|
+
while (index < text.length) {
|
|
53
|
+
const current = text[index];
|
|
54
|
+
if (current === "\\" && quote === "\"" && index + 1 < text.length) {
|
|
55
|
+
value += text[index + 1];
|
|
56
|
+
index += 2;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (current === quote) {
|
|
60
|
+
index++;
|
|
61
|
+
closed = true;
|
|
62
|
+
break;
|
|
63
|
+
}
|
|
64
|
+
value += current;
|
|
65
|
+
index++;
|
|
66
|
+
}
|
|
67
|
+
if (closed && isPathLike(value)) tokens.push({
|
|
68
|
+
raw: text.slice(start, index),
|
|
69
|
+
value,
|
|
70
|
+
start,
|
|
71
|
+
end: index
|
|
72
|
+
});
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
let rawValue = "";
|
|
76
|
+
while (index < text.length) {
|
|
77
|
+
const current = text[index];
|
|
78
|
+
if (/\s/.test(current)) break;
|
|
79
|
+
if (current === "\\" && index + 1 < text.length) {
|
|
80
|
+
rawValue += current + text[index + 1];
|
|
81
|
+
index += 2;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
rawValue += current;
|
|
85
|
+
index++;
|
|
86
|
+
}
|
|
87
|
+
const value = shellUnescape(rawValue);
|
|
88
|
+
if (isPathLike(value)) tokens.push({
|
|
89
|
+
raw: rawValue,
|
|
90
|
+
value,
|
|
91
|
+
start,
|
|
92
|
+
end: index
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return tokens;
|
|
96
|
+
}
|
|
97
|
+
function dimensionsForImage(data, mimeType) {
|
|
98
|
+
return getImageDimensions(data, mimeType) ?? void 0;
|
|
99
|
+
}
|
|
100
|
+
function loadImageFromPath(inputPath, cwd, maxBytes = MAX_IMAGE_BYTES) {
|
|
101
|
+
const path = resolveImagePath(inputPath, cwd);
|
|
102
|
+
try {
|
|
103
|
+
if (!existsSync(path)) return {
|
|
104
|
+
ok: false,
|
|
105
|
+
reason: "missing",
|
|
106
|
+
path
|
|
107
|
+
};
|
|
108
|
+
const stat = statSync(path);
|
|
109
|
+
if (!stat.isFile()) return {
|
|
110
|
+
ok: false,
|
|
111
|
+
reason: "not-file",
|
|
112
|
+
path
|
|
113
|
+
};
|
|
114
|
+
if (stat.size > maxBytes) return {
|
|
115
|
+
ok: false,
|
|
116
|
+
reason: "too-large",
|
|
117
|
+
path
|
|
118
|
+
};
|
|
119
|
+
const data = readFileSync(path);
|
|
120
|
+
const mimeType = detectImageMimeType(data);
|
|
121
|
+
if (!mimeType) return {
|
|
122
|
+
ok: false,
|
|
123
|
+
reason: "unsupported",
|
|
124
|
+
path
|
|
125
|
+
};
|
|
126
|
+
const base64Data = data.toString("base64");
|
|
127
|
+
return {
|
|
128
|
+
ok: true,
|
|
129
|
+
image: {
|
|
130
|
+
originalPath: path,
|
|
131
|
+
mimeType,
|
|
132
|
+
data: base64Data,
|
|
133
|
+
dimensions: dimensionsForImage(base64Data, mimeType)
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
} catch {
|
|
137
|
+
return {
|
|
138
|
+
ok: false,
|
|
139
|
+
reason: "read-error",
|
|
140
|
+
path
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
function replaceImagePathsInText(text, options) {
|
|
145
|
+
const tokens = tokenizePathLikeText(text);
|
|
146
|
+
if (tokens.length === 0) return {
|
|
147
|
+
text,
|
|
148
|
+
replaced: 0,
|
|
149
|
+
accepted: []
|
|
150
|
+
};
|
|
151
|
+
let output = "";
|
|
152
|
+
let cursor = 0;
|
|
153
|
+
let replaced = 0;
|
|
154
|
+
const accepted = [];
|
|
155
|
+
const loadImage = options.loadImage ?? loadImageFromPath;
|
|
156
|
+
for (const token of tokens) {
|
|
157
|
+
const result = loadImage(token.value, options.cwd);
|
|
158
|
+
if (!result.ok) {
|
|
159
|
+
options.onReject?.(result);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const attachment = options.store.add(result.image);
|
|
163
|
+
accepted.push(attachment);
|
|
164
|
+
output += text.slice(cursor, token.start) + attachment.placeholder;
|
|
165
|
+
cursor = token.end;
|
|
166
|
+
replaced++;
|
|
167
|
+
}
|
|
168
|
+
if (replaced === 0) return {
|
|
169
|
+
text,
|
|
170
|
+
replaced: 0,
|
|
171
|
+
accepted: []
|
|
172
|
+
};
|
|
173
|
+
output += text.slice(cursor);
|
|
174
|
+
return {
|
|
175
|
+
text: output,
|
|
176
|
+
replaced,
|
|
177
|
+
accepted
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
function imagesForText(store, text, existing = []) {
|
|
181
|
+
return [...existing, ...store.matchingPlaceholders(text).map((attachment) => ({
|
|
182
|
+
type: "image",
|
|
183
|
+
mimeType: attachment.mimeType,
|
|
184
|
+
data: attachment.data
|
|
185
|
+
}))];
|
|
186
|
+
}
|
|
187
|
+
//#endregion
|
|
188
|
+
//#region src/clipboard.ts
|
|
189
|
+
function readClipboardImage(maxBytes = MAX_IMAGE_BYTES) {
|
|
190
|
+
if (process.platform !== "darwin") return {
|
|
191
|
+
ok: false,
|
|
192
|
+
reason: "unsupported-platform"
|
|
193
|
+
};
|
|
194
|
+
return readMacOSClipboardImage(maxBytes);
|
|
195
|
+
}
|
|
196
|
+
function readMacOSClipboardImage(maxBytes) {
|
|
197
|
+
for (const attempt of [{
|
|
198
|
+
appleScriptClass: "PNGf",
|
|
199
|
+
extension: "png"
|
|
200
|
+
}, {
|
|
201
|
+
appleScriptClass: "JPEG",
|
|
202
|
+
extension: "jpg"
|
|
203
|
+
}]) {
|
|
204
|
+
const tmpFile = join(tmpdir(), `paster-clipboard-${randomUUID()}.${attempt.extension}`);
|
|
205
|
+
try {
|
|
206
|
+
if (spawnSync("osascript", [
|
|
207
|
+
"-e",
|
|
208
|
+
`set imageData to the clipboard as «class ${attempt.appleScriptClass}»`,
|
|
209
|
+
"-e",
|
|
210
|
+
`set outputFile to open for access POSIX file ${JSON.stringify(tmpFile)} with write permission`,
|
|
211
|
+
"-e",
|
|
212
|
+
"set eof of outputFile to 0",
|
|
213
|
+
"-e",
|
|
214
|
+
"write imageData to outputFile",
|
|
215
|
+
"-e",
|
|
216
|
+
"close access outputFile"
|
|
217
|
+
], {
|
|
218
|
+
timeout: 3e3,
|
|
219
|
+
stdio: "ignore"
|
|
220
|
+
}).status !== 0) continue;
|
|
221
|
+
const bytes = readFileSync(tmpFile);
|
|
222
|
+
if (bytes.length === 0) continue;
|
|
223
|
+
if (bytes.length > maxBytes) return {
|
|
224
|
+
ok: false,
|
|
225
|
+
reason: "too-large"
|
|
226
|
+
};
|
|
227
|
+
const mimeType = detectImageMimeType(bytes);
|
|
228
|
+
if (!mimeType) continue;
|
|
229
|
+
const data = bytes.toString("base64");
|
|
230
|
+
return {
|
|
231
|
+
ok: true,
|
|
232
|
+
image: {
|
|
233
|
+
originalPath: `clipboard.${attempt.extension}`,
|
|
234
|
+
mimeType,
|
|
235
|
+
data,
|
|
236
|
+
dimensions: dimensionsForImage(data, mimeType)
|
|
237
|
+
}
|
|
238
|
+
};
|
|
239
|
+
} catch {
|
|
240
|
+
return {
|
|
241
|
+
ok: false,
|
|
242
|
+
reason: "read-error"
|
|
243
|
+
};
|
|
244
|
+
} finally {
|
|
245
|
+
try {
|
|
246
|
+
unlinkSync(tmpFile);
|
|
247
|
+
} catch {}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return {
|
|
251
|
+
ok: false,
|
|
252
|
+
reason: "empty"
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
//#endregion
|
|
256
|
+
//#region src/config.ts
|
|
257
|
+
const DEFAULT_PASTER_CONFIG = { customEditor: {
|
|
258
|
+
enabled: true,
|
|
259
|
+
showImagePreview: true,
|
|
260
|
+
deletePlaceholderAsBlock: true
|
|
261
|
+
} };
|
|
262
|
+
function resolvePasterConfig(config = {}) {
|
|
263
|
+
return { customEditor: {
|
|
264
|
+
enabled: config.customEditor?.enabled ?? DEFAULT_PASTER_CONFIG.customEditor.enabled,
|
|
265
|
+
showImagePreview: config.customEditor?.showImagePreview ?? DEFAULT_PASTER_CONFIG.customEditor.showImagePreview,
|
|
266
|
+
deletePlaceholderAsBlock: config.customEditor?.deletePlaceholderAsBlock ?? DEFAULT_PASTER_CONFIG.customEditor.deletePlaceholderAsBlock
|
|
267
|
+
} };
|
|
268
|
+
}
|
|
269
|
+
//#endregion
|
|
270
|
+
//#region src/editor.ts
|
|
271
|
+
const PASTE_START = "\x1B[200~";
|
|
272
|
+
const PASTE_END = "\x1B[201~";
|
|
273
|
+
const PLACEHOLDER_REGEX = /\[#image \d+\]/g;
|
|
274
|
+
function findPlaceholderAtCursor(store, lines, cursor, mode) {
|
|
275
|
+
const line = lines[cursor.line] ?? "";
|
|
276
|
+
for (const match of line.matchAll(PLACEHOLDER_REGEX)) {
|
|
277
|
+
const placeholder = match[0];
|
|
278
|
+
const start = match.index;
|
|
279
|
+
const end = start + placeholder.length;
|
|
280
|
+
const attachment = store.get(placeholder);
|
|
281
|
+
if (!attachment) continue;
|
|
282
|
+
if (mode === "hover" && cursor.col >= start && cursor.col < end) return {
|
|
283
|
+
attachment,
|
|
284
|
+
line: cursor.line,
|
|
285
|
+
start,
|
|
286
|
+
end
|
|
287
|
+
};
|
|
288
|
+
if (mode === "backspace" && cursor.col > start && cursor.col <= end) return {
|
|
289
|
+
attachment,
|
|
290
|
+
line: cursor.line,
|
|
291
|
+
start,
|
|
292
|
+
end
|
|
293
|
+
};
|
|
294
|
+
if (mode === "delete" && cursor.col >= start && cursor.col < end) return {
|
|
295
|
+
attachment,
|
|
296
|
+
line: cursor.line,
|
|
297
|
+
start,
|
|
298
|
+
end
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
var PasterEditor = class extends CustomEditor {
|
|
303
|
+
pasterPasteBuffer;
|
|
304
|
+
activePreviewPlaceholder;
|
|
305
|
+
constructor(tui, theme, pasterKeybindings, pasterOptions) {
|
|
306
|
+
super(tui, theme, pasterKeybindings);
|
|
307
|
+
this.pasterKeybindings = pasterKeybindings;
|
|
308
|
+
this.pasterOptions = pasterOptions;
|
|
309
|
+
this.onPasteImage = () => {
|
|
310
|
+
this.handlePasteClipboardImage();
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
insertTextAtCursor(text) {
|
|
314
|
+
const transformed = this.transform(text);
|
|
315
|
+
super.insertTextAtCursor(transformed.replaced > 0 ? transformed.text : text);
|
|
316
|
+
this.updateCursorPreview();
|
|
317
|
+
}
|
|
318
|
+
handleInput(data) {
|
|
319
|
+
if (this.handleBracketedPaste(data)) return;
|
|
320
|
+
if (this.pasterOptions.deletePlaceholderAsBlock && this.handleAtomicPlaceholderDelete(data)) return;
|
|
321
|
+
super.handleInput(data);
|
|
322
|
+
this.updateCursorPreview();
|
|
323
|
+
}
|
|
324
|
+
clearCursorPreview() {
|
|
325
|
+
this.activePreviewPlaceholder = void 0;
|
|
326
|
+
this.pasterOptions.setCursorPreview(void 0);
|
|
327
|
+
}
|
|
328
|
+
async handlePasteClipboardImage() {
|
|
329
|
+
const attachment = await this.pasterOptions.pasteClipboardImage?.();
|
|
330
|
+
if (!attachment) return;
|
|
331
|
+
super.insertTextAtCursor(attachment.placeholder);
|
|
332
|
+
this.updateCursorPreview();
|
|
333
|
+
this.tui.requestRender();
|
|
334
|
+
}
|
|
335
|
+
handleBracketedPaste(data) {
|
|
336
|
+
let prefix = "";
|
|
337
|
+
const original = data;
|
|
338
|
+
const wasBuffered = this.pasterPasteBuffer !== void 0;
|
|
339
|
+
if (this.pasterPasteBuffer === void 0) {
|
|
340
|
+
const start = data.indexOf(PASTE_START);
|
|
341
|
+
if (start === -1) return false;
|
|
342
|
+
prefix = data.slice(0, start);
|
|
343
|
+
this.pasterPasteBuffer = data.slice(start + 6);
|
|
344
|
+
if (!this.pasterPasteBuffer.includes("\x1B[201~")) {
|
|
345
|
+
if (prefix) super.handleInput(prefix);
|
|
346
|
+
return true;
|
|
347
|
+
}
|
|
348
|
+
} else {
|
|
349
|
+
this.pasterPasteBuffer += data;
|
|
350
|
+
if (!this.pasterPasteBuffer.includes("\x1B[201~")) return true;
|
|
351
|
+
}
|
|
352
|
+
const end = this.pasterPasteBuffer.indexOf(PASTE_END);
|
|
353
|
+
const content = this.pasterPasteBuffer.slice(0, end);
|
|
354
|
+
const remaining = this.pasterPasteBuffer.slice(end + 6);
|
|
355
|
+
this.pasterPasteBuffer = void 0;
|
|
356
|
+
const transformed = this.transform(content);
|
|
357
|
+
if (transformed.replaced === 0) {
|
|
358
|
+
super.handleInput(wasBuffered ? `${PASTE_START}${content}${PASTE_END}${remaining}` : original);
|
|
359
|
+
this.updateCursorPreview();
|
|
360
|
+
return true;
|
|
361
|
+
}
|
|
362
|
+
if (prefix) super.handleInput(prefix);
|
|
363
|
+
super.insertTextAtCursor(transformed.text);
|
|
364
|
+
if (remaining) super.handleInput(remaining);
|
|
365
|
+
this.updateCursorPreview();
|
|
366
|
+
return true;
|
|
367
|
+
}
|
|
368
|
+
handleAtomicPlaceholderDelete(data) {
|
|
369
|
+
const isBackspace = this.pasterKeybindings.matches(data, "tui.editor.deleteCharBackward");
|
|
370
|
+
const isDelete = this.pasterKeybindings.matches(data, "tui.editor.deleteCharForward");
|
|
371
|
+
if (!isBackspace && !isDelete) return false;
|
|
372
|
+
if (isDelete && this.getText().length === 0) return false;
|
|
373
|
+
const target = findPlaceholderAtCursor(this.pasterOptions.store, this.getLines(), this.getCursor(), isBackspace ? "backspace" : "delete");
|
|
374
|
+
if (!target) return false;
|
|
375
|
+
this.deleteLineRange(target.line, target.start, target.end);
|
|
376
|
+
this.updateCursorPreview();
|
|
377
|
+
return true;
|
|
378
|
+
}
|
|
379
|
+
deleteLineRange(lineIndex, start, end) {
|
|
380
|
+
const editor = this;
|
|
381
|
+
editor.pushUndoSnapshot?.();
|
|
382
|
+
const line = editor.state.lines[lineIndex] ?? "";
|
|
383
|
+
editor.state.lines[lineIndex] = line.slice(0, start) + line.slice(end);
|
|
384
|
+
editor.state.cursorLine = lineIndex;
|
|
385
|
+
if (editor.setCursorCol) editor.setCursorCol(start);
|
|
386
|
+
else editor.state.cursorCol = start;
|
|
387
|
+
editor.lastAction = null;
|
|
388
|
+
editor.historyIndex = -1;
|
|
389
|
+
this.onChange?.(this.getText());
|
|
390
|
+
this.tui.requestRender();
|
|
391
|
+
}
|
|
392
|
+
transform(text) {
|
|
393
|
+
return replaceImagePathsInText(text, {
|
|
394
|
+
cwd: this.pasterOptions.cwd,
|
|
395
|
+
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
|
+
}
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
updateCursorPreview() {
|
|
402
|
+
const target = findPlaceholderAtCursor(this.pasterOptions.store, this.getLines(), this.getCursor(), "hover");
|
|
403
|
+
const nextPlaceholder = target?.attachment.placeholder;
|
|
404
|
+
if (nextPlaceholder === this.activePreviewPlaceholder) return;
|
|
405
|
+
this.activePreviewPlaceholder = nextPlaceholder;
|
|
406
|
+
this.pasterOptions.setCursorPreview(target?.attachment);
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
//#endregion
|
|
410
|
+
//#region src/preview.ts
|
|
411
|
+
var ImagePreviewMessage = class {
|
|
412
|
+
images;
|
|
413
|
+
constructor(attachments, theme) {
|
|
414
|
+
this.attachments = attachments;
|
|
415
|
+
this.theme = theme;
|
|
416
|
+
this.images = attachments.map((attachment) => new Image(attachment.data, attachment.mimeType, theme, {
|
|
417
|
+
maxWidthCells: 60,
|
|
418
|
+
maxHeightCells: 16,
|
|
419
|
+
filename: attachment.placeholder
|
|
420
|
+
}));
|
|
421
|
+
}
|
|
422
|
+
render(width) {
|
|
423
|
+
const lines = [];
|
|
424
|
+
for (let index = 0; index < this.attachments.length; index++) {
|
|
425
|
+
lines.push(this.theme.fallbackColor(`Attached ${this.attachments[index].placeholder} (${this.attachments[index].mimeType})`));
|
|
426
|
+
lines.push(...this.images[index].render(width));
|
|
427
|
+
}
|
|
428
|
+
return lines;
|
|
429
|
+
}
|
|
430
|
+
invalidate() {
|
|
431
|
+
for (const image of this.images) image.invalidate();
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
var CursorImagePreviewWidget = class {
|
|
435
|
+
image;
|
|
436
|
+
constructor(attachment, theme) {
|
|
437
|
+
this.attachment = attachment;
|
|
438
|
+
this.theme = theme;
|
|
439
|
+
this.image = this.createImage(attachment);
|
|
440
|
+
}
|
|
441
|
+
render(width) {
|
|
442
|
+
const imageWidth = this.constrainedImageWidth(width);
|
|
443
|
+
this.image = this.createImage(this.attachment, imageWidth);
|
|
444
|
+
return [this.headerLine(width), ...this.image.render(imageWidth + 2)];
|
|
445
|
+
}
|
|
446
|
+
invalidate() {
|
|
447
|
+
this.image.invalidate();
|
|
448
|
+
}
|
|
449
|
+
headerLine(width) {
|
|
450
|
+
const title = `${this.attachment.placeholder} ${basename(this.attachment.originalPath)}`;
|
|
451
|
+
return this.theme.title(truncateToWidth(title, Math.max(1, width), ""));
|
|
452
|
+
}
|
|
453
|
+
createImage(attachment, maxWidthCells = 60) {
|
|
454
|
+
return new Image(attachment.data, attachment.mimeType, { fallbackColor: this.theme.accent }, {
|
|
455
|
+
maxWidthCells,
|
|
456
|
+
filename: attachment.placeholder
|
|
457
|
+
}, attachment.dimensions);
|
|
458
|
+
}
|
|
459
|
+
constrainedImageWidth(width) {
|
|
460
|
+
const maxWidth = Math.max(1, Math.min(60, width - 2));
|
|
461
|
+
const maxRows = 14;
|
|
462
|
+
const dimensions = this.attachment.dimensions;
|
|
463
|
+
if (!dimensions || dimensions.widthPx <= 0 || dimensions.heightPx <= 0) return maxWidth;
|
|
464
|
+
const cell = getCellDimensions();
|
|
465
|
+
const widthForMaxRows = Math.floor(maxRows * cell.heightPx * dimensions.widthPx / (dimensions.heightPx * cell.widthPx));
|
|
466
|
+
return Math.max(1, Math.min(maxWidth, widthForMaxRows));
|
|
467
|
+
}
|
|
468
|
+
};
|
|
469
|
+
//#endregion
|
|
470
|
+
//#region src/store.ts
|
|
471
|
+
var AttachmentStore = class {
|
|
472
|
+
nextId = 1;
|
|
473
|
+
attachments = /* @__PURE__ */ new Map();
|
|
474
|
+
clear() {
|
|
475
|
+
this.nextId = 1;
|
|
476
|
+
this.attachments.clear();
|
|
477
|
+
}
|
|
478
|
+
list() {
|
|
479
|
+
return [...this.attachments.values()].sort((a, b) => a.id - b.id);
|
|
480
|
+
}
|
|
481
|
+
add(input) {
|
|
482
|
+
const id = this.nextId++;
|
|
483
|
+
const attachment = {
|
|
484
|
+
...input,
|
|
485
|
+
id,
|
|
486
|
+
placeholder: `[#image ${id}]`,
|
|
487
|
+
createdAt: Date.now()
|
|
488
|
+
};
|
|
489
|
+
this.attachments.set(attachment.placeholder, attachment);
|
|
490
|
+
return attachment;
|
|
491
|
+
}
|
|
492
|
+
get(placeholder) {
|
|
493
|
+
return this.attachments.get(placeholder);
|
|
494
|
+
}
|
|
495
|
+
matchingPlaceholders(text) {
|
|
496
|
+
return this.list().map((attachment) => ({
|
|
497
|
+
attachment,
|
|
498
|
+
index: text.indexOf(attachment.placeholder)
|
|
499
|
+
})).filter((match) => match.index >= 0).sort((a, b) => a.index - b.index).map((match) => match.attachment);
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
//#endregion
|
|
503
|
+
//#region src/terminal-input.ts
|
|
504
|
+
function createImagePasteTerminalInputHandler(options) {
|
|
505
|
+
let pasteBuffer;
|
|
506
|
+
const transform = (text) => replaceImagePathsInText(text, {
|
|
507
|
+
cwd: options.cwd,
|
|
508
|
+
store: options.store,
|
|
509
|
+
loadImage: options.loadImage,
|
|
510
|
+
onReject: (result) => {
|
|
511
|
+
if (result.reason === "too-large") options.notify?.(`paster: image is over 10 MB and was not attached: ${result.path}`);
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
return (data) => {
|
|
515
|
+
let prefix = "";
|
|
516
|
+
const wasBuffered = pasteBuffer !== void 0;
|
|
517
|
+
if (pasteBuffer === void 0) {
|
|
518
|
+
const start = data.indexOf(PASTE_START);
|
|
519
|
+
if (start === -1) return void 0;
|
|
520
|
+
prefix = data.slice(0, start);
|
|
521
|
+
pasteBuffer = data.slice(start + 6);
|
|
522
|
+
if (!pasteBuffer.includes("\x1B[201~")) return prefix ? { data: prefix } : { consume: true };
|
|
523
|
+
} else {
|
|
524
|
+
pasteBuffer += data;
|
|
525
|
+
if (!pasteBuffer.includes("\x1B[201~")) return { consume: true };
|
|
526
|
+
}
|
|
527
|
+
const end = pasteBuffer.indexOf(PASTE_END);
|
|
528
|
+
const content = pasteBuffer.slice(0, end);
|
|
529
|
+
const remaining = pasteBuffer.slice(end + 6);
|
|
530
|
+
pasteBuffer = void 0;
|
|
531
|
+
const transformed = transform(content);
|
|
532
|
+
if (transformed.replaced === 0) return wasBuffered ? { data: `${PASTE_START}${content}${PASTE_END}${remaining}` } : void 0;
|
|
533
|
+
options.onAccept?.(transformed.accepted);
|
|
534
|
+
return { data: `${prefix}${transformed.text}${remaining}` };
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
//#endregion
|
|
538
|
+
//#region src/index.ts
|
|
539
|
+
function createPaster(config = {}) {
|
|
540
|
+
return (pi) => paster(pi, config);
|
|
541
|
+
}
|
|
542
|
+
function paster(pi, config = {}) {
|
|
543
|
+
const resolvedConfig = resolvePasterConfig(config);
|
|
544
|
+
const store = new AttachmentStore();
|
|
545
|
+
let pendingPreview = [];
|
|
546
|
+
let activeEditor;
|
|
547
|
+
let unsubscribeTerminalInput;
|
|
548
|
+
pi.registerMessageRenderer("paster-preview", (message, _options, theme) => {
|
|
549
|
+
const placeholders = message.details?.placeholders ?? [];
|
|
550
|
+
const attachments = store.list().filter((attachment) => placeholders.includes(attachment.placeholder));
|
|
551
|
+
if (attachments.length === 0) return void 0;
|
|
552
|
+
return new ImagePreviewMessage(attachments, { fallbackColor: (text) => theme.fg("muted", text) });
|
|
553
|
+
});
|
|
554
|
+
pi.on("session_start", (_event, ctx) => {
|
|
555
|
+
store.clear();
|
|
556
|
+
pendingPreview = [];
|
|
557
|
+
if (!ctx.hasUI) return;
|
|
558
|
+
unsubscribeTerminalInput?.();
|
|
559
|
+
unsubscribeTerminalInput = void 0;
|
|
560
|
+
activeEditor?.clearCursorPreview();
|
|
561
|
+
activeEditor = void 0;
|
|
562
|
+
ctx.ui.setWidget("paster-cursor-preview", void 0, { placement: "aboveEditor" });
|
|
563
|
+
if (!resolvedConfig.customEditor.enabled) {
|
|
564
|
+
unsubscribeTerminalInput = ctx.ui.onTerminalInput(createImagePasteTerminalInputHandler({
|
|
565
|
+
cwd: ctx.cwd,
|
|
566
|
+
store,
|
|
567
|
+
notify: (message) => ctx.ui.notify(message, "warning")
|
|
568
|
+
}));
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
ctx.ui.setEditorComponent((tui, theme, keybindings) => {
|
|
572
|
+
activeEditor = new PasterEditor(tui, theme, keybindings, {
|
|
573
|
+
cwd: ctx.cwd,
|
|
574
|
+
store,
|
|
575
|
+
notify: (message) => ctx.ui.notify(message, "warning"),
|
|
576
|
+
deletePlaceholderAsBlock: resolvedConfig.customEditor.deletePlaceholderAsBlock,
|
|
577
|
+
pasteClipboardImage: () => {
|
|
578
|
+
const result = readClipboardImage();
|
|
579
|
+
if (!result.ok) {
|
|
580
|
+
if (result.reason !== "empty" && result.reason !== "unsupported-platform") ctx.ui.notify("paster: clipboard image could not be attached", "warning");
|
|
581
|
+
return;
|
|
582
|
+
}
|
|
583
|
+
return store.add(result.image);
|
|
584
|
+
},
|
|
585
|
+
setCursorPreview: (attachment) => {
|
|
586
|
+
if (!resolvedConfig.customEditor.showImagePreview) return;
|
|
587
|
+
ctx.ui.setWidget("paster-cursor-preview", attachment ? (_tui, widgetTheme) => new CursorImagePreviewWidget(attachment, {
|
|
588
|
+
title: (text) => widgetTheme.fg("accent", text),
|
|
589
|
+
muted: (text) => widgetTheme.fg("muted", text),
|
|
590
|
+
accent: (text) => widgetTheme.fg("accent", text)
|
|
591
|
+
}) : void 0, { placement: "aboveEditor" });
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
return activeEditor;
|
|
595
|
+
});
|
|
596
|
+
});
|
|
597
|
+
pi.on("session_shutdown", (_event, ctx) => {
|
|
598
|
+
pendingPreview = [];
|
|
599
|
+
if (ctx.hasUI) {
|
|
600
|
+
unsubscribeTerminalInput?.();
|
|
601
|
+
unsubscribeTerminalInput = void 0;
|
|
602
|
+
activeEditor?.clearCursorPreview();
|
|
603
|
+
activeEditor = void 0;
|
|
604
|
+
ctx.ui.setWidget("paster-cursor-preview", void 0, { placement: "aboveEditor" });
|
|
605
|
+
ctx.ui.setEditorComponent(void 0);
|
|
606
|
+
}
|
|
607
|
+
store.clear();
|
|
608
|
+
});
|
|
609
|
+
pi.on("input", (event, ctx) => {
|
|
610
|
+
if (event.source === "extension") return { action: "continue" };
|
|
611
|
+
if (ctx.hasUI) activeEditor?.clearCursorPreview();
|
|
612
|
+
const attachments = store.matchingPlaceholders(event.text);
|
|
613
|
+
if (attachments.length === 0) return { action: "continue" };
|
|
614
|
+
pendingPreview = attachments;
|
|
615
|
+
return {
|
|
616
|
+
action: "transform",
|
|
617
|
+
text: event.text,
|
|
618
|
+
images: imagesForText(store, event.text, event.images)
|
|
619
|
+
};
|
|
620
|
+
});
|
|
621
|
+
pi.on("before_agent_start", () => {
|
|
622
|
+
if (pendingPreview.length === 0) return;
|
|
623
|
+
const placeholders = pendingPreview.map((attachment) => attachment.placeholder);
|
|
624
|
+
pendingPreview = [];
|
|
625
|
+
return { message: {
|
|
626
|
+
customType: "paster-preview",
|
|
627
|
+
content: "",
|
|
628
|
+
display: true,
|
|
629
|
+
details: { placeholders }
|
|
630
|
+
} };
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
//#endregion
|
|
634
|
+
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 };
|
package/docs/preview.png
ADDED
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-paster",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi extension that turns pasted image paths into first-class image attachments.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"image-attachments",
|
|
7
|
+
"pi-extension",
|
|
8
|
+
"pi-package"
|
|
9
|
+
],
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"src",
|
|
14
|
+
"docs",
|
|
15
|
+
"spec.md",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
18
|
+
"type": "module",
|
|
19
|
+
"exports": {
|
|
20
|
+
".": "./dist/index.mjs",
|
|
21
|
+
"./package.json": "./package.json"
|
|
22
|
+
},
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "vp pack",
|
|
28
|
+
"dev": "vp pack --watch",
|
|
29
|
+
"test": "vp test",
|
|
30
|
+
"check": "vp check",
|
|
31
|
+
"prepare": "vp config",
|
|
32
|
+
"prepublishOnly": "vp run build"
|
|
33
|
+
},
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@earendil-works/pi-coding-agent": "^0.74.0",
|
|
36
|
+
"@earendil-works/pi-tui": "^0.74.0",
|
|
37
|
+
"@types/node": "^25.8.0",
|
|
38
|
+
"@typescript/native-preview": "7.0.0-dev.20260515.1",
|
|
39
|
+
"typescript": "^6.0.3",
|
|
40
|
+
"vite-plus": "catalog:"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
44
|
+
"@earendil-works/pi-tui": "*"
|
|
45
|
+
},
|
|
46
|
+
"packageManager": "pnpm@11.1.2",
|
|
47
|
+
"pi": {
|
|
48
|
+
"extensions": [
|
|
49
|
+
"./src/index.ts"
|
|
50
|
+
],
|
|
51
|
+
"image": "https://unpkg.com/pi-paster@0.1.0/docs/preview.png"
|
|
52
|
+
}
|
|
53
|
+
}
|