@pi-archimedes/image-paste 2.10.4 → 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/README.md +11 -3
- package/package.json +4 -4
- package/src/clipboard.test.ts +497 -3
- package/src/clipboard.ts +311 -4
- package/src/default-export.test.ts +90 -2
- package/src/index.ts +176 -35
- package/src/keybinding-offer.test.ts +1671 -10
- package/src/keybinding-offer.ts +741 -38
- package/src/paste-dispatch.test.ts +516 -0
- package/src/paste-mutex.test.ts +165 -0
- package/src/shortcut-gate.test.ts +220 -0
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 {
|
|
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
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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();
|