@pi-archimedes/image-paste 2.10.3 → 2.11.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/src/index.ts CHANGED
@@ -4,7 +4,16 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
4
4
 
5
5
  import type { KeyId } from "@earendil-works/pi-tui";
6
6
 
7
- import { readClipboardImage } from "./clipboard.js";
7
+ import { diag, describeError } from "@pi-archimedes/core/log";
8
+
9
+ import {
10
+ buildPathInsertion,
11
+ clipboardTextForEditor,
12
+ readClipboardFilePaths,
13
+ readClipboardImage,
14
+ readClipboardText,
15
+ } from "./clipboard.js";
16
+ import { runNativePasteRestore, suiteMayClaimPasteKey } from "./keybinding-offer.js";
8
17
  import { registerImagePreview, sendPreviewMessage } from "./preview.js";
9
18
  import type { ClipboardImage, PendingImage, ImageMarker } from "./types.js";
10
19
 
@@ -12,13 +21,13 @@ const MAX_FILE_SIZE_BYTES = 20 * 1024 * 1024; // 20MB
12
21
 
13
22
  // ── Queue management ────────────────────────────────────────────
14
23
 
15
- interface ImageQueue {
24
+ export interface ImageQueue {
16
25
  images: PendingImage[];
17
26
  markers: ImageMarker[];
18
27
  nextIndex: number;
19
28
  }
20
29
 
21
- function createImageQueue(): ImageQueue {
30
+ export function createImageQueue(): ImageQueue {
22
31
  return { images: [], markers: [], nextIndex: 1 };
23
32
  }
24
33
 
@@ -56,8 +65,105 @@ function queueImage(
56
65
  return marker;
57
66
  }
58
67
 
68
+ // ── The paste dispatch ──────────────────────────────────────────────────────
69
+
70
+ /**
71
+ * One `ctrl+v`, one outcome, in this order: **image → file paths → text**.
72
+ *
73
+ * The order is deliberately NOT pi's. pi's native `handleClipboardPaste` asks
74
+ * for paths first, then an image, then text
75
+ * (`dist/modes/interactive/interactive-mode.js:2497-2531`). We took this key off
76
+ * pi precisely in order to attach images, so an image has to win here —
77
+ * otherwise copying a screenshot on macOS (whose pasteboard carries both the
78
+ * file URL and the image bytes) would keep inserting a path and the one thing
79
+ * this package adds would never fire. Do not "fix" this to match pi: ADR 0030
80
+ * keeps the binding on the condition that this handler is a *superset* of pi's
81
+ * action, and the superset is this branch order plus pi's other two branches.
82
+ *
83
+ * Beyond the order, each branch matches pi's behaviour: the path branch reuses
84
+ * pi's own spacing rule (`buildPathInsertion`), the text branch reads the
85
+ * clipboard the way pi does (`readClipboardText`), and an empty clipboard is
86
+ * silent — pi has no `else` at `:2526-2530`, so neither do we. The genuinely
87
+ * additive branch is the first one: attach, queue, preview.
88
+ */
89
+ export async function dispatchClipboardPaste(
90
+ ctx: ExtensionContext,
91
+ queue: ImageQueue,
92
+ ): Promise<void> {
93
+ try {
94
+ // 1. Image bytes. A reader that cannot run at all is not the same as an
95
+ // empty clipboard, so the failure is kept aside: pi's dispatcher would
96
+ // have aborted here (its catch, `:2532-2534`), but on our key the paths
97
+ // and text branches are independent of the image reader, so they get
98
+ // their turn and the failure is only reported if nothing else answered.
99
+ let imageReaderError: unknown = null;
100
+ let image: ClipboardImage | null = null;
101
+ try {
102
+ image = await readClipboardImage();
103
+ } catch (error) {
104
+ imageReaderError = error;
105
+ }
106
+
107
+ if (image) {
108
+ if (image.bytes.length > MAX_FILE_SIZE_BYTES) {
109
+ ctx.ui.notify(
110
+ `Image too large (${(image.bytes.length / 1024 / 1024).toFixed(1)}MB > 20MB).`,
111
+ "warning",
112
+ );
113
+ return;
114
+ }
115
+ queueImage(queue, image, ctx);
116
+ ctx.ui.notify("Image attached from clipboard.", "info");
117
+ return;
118
+ }
119
+
120
+ // 2. Copied files. pi inserts the paths rather than attaching them, and so
121
+ // do we: a file copied in a file manager is a *path* on the clipboard,
122
+ // not image bytes, so a copied PNG is inserted as text and never read
123
+ // off disk. Exactly one branch runs per keypress, which is what keeps a
124
+ // path from ever attaching as well — nothing double-attaches.
125
+ const filePaths = await readClipboardFilePaths();
126
+ if (filePaths) {
127
+ // pi's guard before inserting (`:2501-2503`).
128
+ if (filePaths.some((filePath) => /\p{Cc}/u.test(filePath))) {
129
+ throw new Error("Clipboard file path contains control characters");
130
+ }
131
+ // getEditorText is declared in 1.2.0 (types.d.ts:137); the peer floor is
132
+ // 0.86.0, which this checkout cannot inspect, so the read is guarded and
133
+ // degrades to pi's empty-draft case (no padding) instead of throwing.
134
+ const editorText = ctx.ui.getEditorText?.() ?? "";
135
+ ctx.ui.pasteToEditor(buildPathInsertion(filePaths, editorText));
136
+ return;
137
+ }
138
+
139
+ // 3. Plain text, prepared for the bracketed-paste route `pasteToEditor`
140
+ // takes (see `clipboardTextForEditor`: an escape byte from the clipboard
141
+ // would otherwise end the paste and lose the rest of it).
142
+ const text = await readClipboardText();
143
+ if (text) {
144
+ ctx.ui.pasteToEditor(clipboardTextForEditor(text));
145
+ return;
146
+ }
147
+
148
+ // 4. Nothing to paste.
149
+ if (imageReaderError) {
150
+ throw imageReaderError instanceof Error
151
+ ? imageReaderError
152
+ : new Error(String(imageReaderError));
153
+ }
154
+ } catch (error) {
155
+ const msg = error instanceof Error ? error.message : "Unknown error";
156
+ ctx.ui.notify(`Clipboard paste failed: ${msg}`, "warning");
157
+ }
158
+ }
159
+
59
160
  // ── Registration ────────────────────────────────────────────────
60
161
 
162
+ /**
163
+ * The keys this package takes over from pi. They are only registered while the
164
+ * suite is allowed to claim them — see {@link suiteMayClaimPasteKey} and the
165
+ * gate in `registerImagePaste`.
166
+ */
61
167
  function getImagePasteShortcuts(): KeyId[] {
62
168
  if (process.platform === "win32") {
63
169
  return ["alt+v", "ctrl+alt+v"] as KeyId[];
@@ -74,39 +180,53 @@ export function registerImagePaste(pi: ExtensionAPI): void {
74
180
  // Register preview renderer (once)
75
181
  registerImagePreview(pi);
76
182
 
77
- // Register shortcuts (once)
78
- const pasteImage = async (): Promise<void> => {
79
- if (_pasting) return; // Mutex: prevent concurrent paste operations
80
- if (!_ctx || !_queue || !_ctx.hasUI) return;
81
- _pasting = true;
82
- try {
83
- const image = await readClipboardImage();
84
- if (!image) {
85
- _ctx.ui.notify("No image found in clipboard.", "warning");
86
- return;
87
- }
88
- if (image.bytes.length > MAX_FILE_SIZE_BYTES) {
89
- _ctx.ui.notify(
90
- `Image too large (${(image.bytes.length / 1024 / 1024).toFixed(1)}MB > 20MB).`,
91
- "warning",
92
- );
93
- return;
183
+ // ── The paste shortcuts ─────────────────────────────────────────
184
+ //
185
+ // Gated on the settings, per session (ADR 0030 amendment). With
186
+ // `archimedes.imagePaste.suppressNativePaste: false` the user asked for pi's
187
+ // own paste, so this registers NOTHING: pi's restored `app.clipboard.pasteImage`
188
+ // binding is then the only thing bound to the key, and no route to
189
+ // `dispatchClipboardPaste` is left behind — the loop below is the package's
190
+ // single entry point to the paste path (no command alias, no second
191
+ // registration; `grep dispatchClipboardPaste` finds only this file and tests).
192
+ // Handing the key back costs the image attach/queue/preview on it, which is
193
+ // exactly the trade the setting asks for; the rest of the package (the
194
+ // submit-time marker attachment and the preview renderer above) stays wired.
195
+ //
196
+ // The predicate is read here, not at module scope, because pi caches the
197
+ // extension factory but re-runs it for every session, and meta calls this
198
+ // function from its own `session_start` handler — so each session gets its own
199
+ // answer, and a `/reload` or `/new` after editing settings.json takes effect
200
+ // without a restart.
201
+ if (suiteMayClaimPasteKey()) {
202
+ const pasteFromClipboard = async (ctx: ExtensionContext): Promise<void> => {
203
+ // Mutex: prevent concurrent paste operations. It is only ever held for as
204
+ // long as one keypress is willing to wait — every read it awaits is
205
+ // bounded (a `spawnSync` timeout per CLI candidate,
206
+ // `NATIVE_READ_TIMEOUT_MS` per native read) — because an unbounded read
207
+ // here would hold the mutex forever and take the key out of service.
208
+ if (_pasting) return;
209
+ if (!_queue || !ctx.hasUI) return;
210
+ _pasting = true;
211
+ try {
212
+ await dispatchClipboardPaste(ctx, _queue);
213
+ } finally {
214
+ _pasting = false;
94
215
  }
95
- queueImage(_queue, image, _ctx);
96
- _ctx.ui.notify("Image attached from clipboard.", "info");
97
- } catch (error) {
98
- const msg = error instanceof Error ? error.message : "Unknown error";
99
- _ctx.ui.notify(`Image paste failed: ${msg}`, "warning");
100
- } finally {
101
- _pasting = false;
102
- }
103
- };
216
+ };
104
217
 
105
- for (const shortcut of getImagePasteShortcuts()) {
106
- pi.registerShortcut(shortcut, {
107
- description: "Attach clipboard image to draft",
108
- handler: pasteImage,
109
- });
218
+ for (const shortcut of getImagePasteShortcuts()) {
219
+ pi.registerShortcut(shortcut, {
220
+ description: "Paste clipboard image, file paths, or text",
221
+ handler: pasteFromClipboard,
222
+ });
223
+ }
224
+ } else {
225
+ diag(
226
+ "image-paste",
227
+ "[archimedes:image-paste] suppressNativePaste is false — registering no paste shortcut; " +
228
+ `${getImagePasteShortcuts().join(", ")} stay with pi's own clipboard action`,
229
+ );
110
230
  }
111
231
 
112
232
  // Input event handler — attach images on submit (once)
@@ -197,8 +317,29 @@ export function shutdownImagePaste(): void {
197
317
  export default function (pi: ExtensionAPI): void {
198
318
  registerImagePaste(pi);
199
319
  // Session lifecycle: pi requires per-session ctx for image pasting.
200
- pi.on("session_start", (_event, ctx) => {
320
+ //
321
+ // Registered at the TOP LEVEL of this factory — never inside another
322
+ // session_start handler, which would accumulate handlers on /reload
323
+ // (AGENTS.md). This is also the standalone call site for the native-paste
324
+ // restoration (ADR 0030 amendment, `suppressNativePaste: false` gives
325
+ // ctrl+v back to pi); the composed suite runs the same call from its own
326
+ // session_start handler in meta/src/index.ts, so exactly one path runs it
327
+ // per session — whichever one loaded the package. Giving the key back has two
328
+ // halves and they live in different places: this call removes our unbinding
329
+ // from keybindings.json, and the gate in `registerImagePaste` above stops us
330
+ // claiming the key. Both read the same setting per session.
331
+ pi.on("session_start", async (_event, ctx) => {
201
332
  initImagePasteSession(ctx);
333
+ // A broken keybindings.json must never fail a session start; the detail
334
+ // goes to the log sink, never the console.
335
+ try {
336
+ await runNativePasteRestore(ctx);
337
+ } catch (error) {
338
+ diag(
339
+ "image-paste",
340
+ `[archimedes:image-paste] native paste restore failed: ${describeError(error)}`,
341
+ );
342
+ }
202
343
  });
203
344
  pi.on("session_shutdown", (_event, _ctx) => {
204
345
  shutdownImagePaste();