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/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 };
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
+ }