@pi-archimedes/image-paste 2.10.4 → 2.11.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 +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/README.md
CHANGED
|
@@ -25,7 +25,10 @@ New to Pi? Pi itself is a one-time global install and needs Node.js ≥ 22.19.0.
|
|
|
25
25
|
- **Markers on paste** — pasting inserts a visible `[Image #1]` placeholder at the cursor; each paste gets the next number.
|
|
26
26
|
- **Attachment on submit** — when you send the message, your text is scanned for markers and the matching queued images are attached to it. Delete a marker and its image is not attached — the text is the source of truth.
|
|
27
27
|
- **Inline preview** — a preview of the attached images is sent and rendered in the TUI. Previews appear where your terminal renders inline images; on a terminal that can't show them, the image is still attached — only the preview falls back.
|
|
28
|
+
- **Same key, wider scope** — the paste key is Pi's own (`app.clipboard.pasteImage`), and taking it means covering everything it did, so with no image on the clipboard the handler falls back to what Pi does: copied **file paths** are inserted at the cursor with Pi's own spacing, and otherwise the clipboard **text** is inserted. One keypress produces one outcome, in that order: image, then paths, then text. Nothing on the clipboard leaves the draft untouched and says nothing, exactly as Pi does.
|
|
28
29
|
- **Size guard** — 20 MiB **per image**, rejected with a clear message before it wastes context or hangs the submit.
|
|
30
|
+
- **Bounded reads** — every clipboard read has a deadline (the same budget Pi gives its own clipboard commands), so a helper that stops answering — an X11/Wayland session that went unresponsive, say — reports `Clipboard paste failed: … timed out` instead of silently taking the paste key out of service for the rest of the session.
|
|
31
|
+
- **Text stays text** — clipboard text is inserted as it is, with one exception: terminal escape bytes are dropped. The insert an extension has is a bracketed paste, so an escape byte coming from the clipboard would otherwise close the paste mid-string and swallow everything behind it.
|
|
29
32
|
- **Model side** — the session's model must support images for the attachment to be usable.
|
|
30
33
|
|
|
31
34
|
## Paste shortcuts
|
|
@@ -37,13 +40,17 @@ New to Pi? Pi itself is a one-time global install and needs Node.js ≥ 22.19.0.
|
|
|
37
40
|
| Windows | `Alt+V`, `Ctrl+Alt+V` |
|
|
38
41
|
|
|
39
42
|
> [!NOTE]
|
|
40
|
-
> **Pi's built-in `app.clipboard.pasteImage` owns the table's first shortcut on every platform** — `Ctrl+V` on Linux/macOS, `Alt+V` on Windows — so the two
|
|
43
|
+
> **Pi's built-in `app.clipboard.pasteImage` owns the table's first shortcut on every platform** — `Ctrl+V` on Linux/macOS, `Alt+V` on Windows — so the two collide everywhere. They do **not** both run: an extension shortcut wins over a built-in that is not on Pi's reserved list, so Pi reports one `[Extension issues]` line (`Extension shortcut conflict: 'ctrl+v' is built-in shortcut for app.clipboard.pasteImage and <ext>. Using <ext>`) and its own action never fires — measured on Pi 1.2.0, one `Ctrl+V`, one insertion, this extension's. Clear the built-in binding in `~/.pi/agent/keybindings.json` so the preview-enhanced handler takes the paste cleanly:
|
|
41
44
|
>
|
|
42
45
|
> ```json
|
|
43
|
-
> { "app.clipboard.pasteImage": [] }
|
|
46
|
+
> { "$schema": "https://pi.dev/schemas/keybindings.schema.json", "app.clipboard.pasteImage": [] }
|
|
44
47
|
> ```
|
|
45
48
|
>
|
|
46
|
-
>
|
|
49
|
+
> The `$schema` line is optional — Pi skips that key when it loads the file — but it lets an editor validate the file against Pi's published keybindings schema, and it is exactly what the first-run offer below writes.
|
|
50
|
+
>
|
|
51
|
+
> **First-run offer (once ever, all platforms)** — when installed via the suite, on the first TUI session with image-paste enabled and with the suite going to hold the paste key (i.e. `suppressNativePaste` is not `false`), if `~/.pi/agent/keybindings.json` doesn't exist yet, the suite offers to create it with exactly the snippet above: accepting creates the file and then tells you to run `/reload` (measured on Pi 1.2.0, the context an event handler receives has no `reload`, so the automatic path is a notification and the cleared binding applies once you run it), declining or cancelling (Esc) never asks again — re-open by deleting `archimedes.imagePaste.keybindingsPromptDone` from `~/.pi/agent/settings.json`. With `suppressNativePaste: false` the offer never appears — an install that will not bind the key has nothing to unbind — and that way of skipping does not spend `keybindingsPromptDone`, so turning the flag back on still brings the offer.
|
|
52
|
+
>
|
|
53
|
+
> **Handing the key back to Pi** — `archimedes.imagePaste.suppressNativePaste: false` does two things, and both are needed: the package removes its own `app.clipboard.pasteImage` entry from `~/.pi/agent/keybindings.json` (that entry only, and only when the file holds nothing but it and this package's own `"$schema"` line — anything else is left byte-for-byte alone with a warning that names an entry only when the file really holds one), **and** it registers no paste shortcut at all. The second half is what makes the first one reachable: while a shortcut is registered Pi keeps it and its restored binding never runs. So with the flag off you get Pi's paste and lose this package's image attach, queue and preview on the paste key. `/new` or `/reload` applies a change to the setting — no restart. If the file could not be cleaned it is left exactly as it is and you get one warning naming it. What the key then does depends on the file: if it still carries `"app.clipboard.pasteImage": []` (or Pi's old name for that same action, `"pasteImage": []`, which Pi still reads as an unbinding), nothing answers the key until you delete that entry; if it does not — only `$schema`, other keys, or JSON that does not parse or cannot be read (Pi discards a file it cannot use and every action keeps its default) — Pi's own paste is already answering it. The warning names the file in every case, and the entry only where one is actually in there: a file of foreign keys, or a `$schema`-only file, is told there is nothing of ours to remove and nothing for it to edit, and a file that cannot be read or parsed is handed back to you to check, with Pi's fallback stated so you know what it is doing in the meantime.
|
|
47
54
|
|
|
48
55
|
## Per-platform requirements
|
|
49
56
|
|
|
@@ -58,6 +65,7 @@ New to Pi? Pi itself is a one-time global install and needs Node.js ≥ 22.19.0.
|
|
|
58
65
|
| Setting | Type | Default | Description |
|
|
59
66
|
|---------|------|---------|-------------|
|
|
60
67
|
| `enabled` | bool | `true` | On/off for the extension, via `/plugins` (suite-managed) |
|
|
68
|
+
| `suppressNativePaste` | bool | `true` | `false` gives the paste key back to Pi: the suite's `app.clipboard.pasteImage` entry is removed from `~/.pi/agent/keybindings.json` (only that entry, only if the file holds nothing else but the suite's own `"$schema"` line) **and** no paste shortcut is registered. It also keeps the first-run offer silent — there is nothing to unbind — without spending `keybindingsPromptDone`, so turning it back on still brings the offer. You get Pi's paste and lose image attach / queue / preview on those keys. Applies at the next `/new` or `/reload`. Anything that is not the strict boolean `false` — `"false"`, `0` — keeps the default. |
|
|
61
69
|
| `keybindingsPromptDone` | bool | `false` | Set true once the first-run keybindings.json offer has been answered (accept or decline); delete to re-open the offer |
|
|
62
70
|
|
|
63
71
|
In the suite on/off is managed by the suite: toggle via `/plugins` (`archimedes.imagePaste.enabled`, default on).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-archimedes/image-paste",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.11.1",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/danielcherubini/pi-archimedes.git"
|
|
@@ -19,15 +19,15 @@
|
|
|
19
19
|
"./keybinding-offer": "./src/keybinding-offer.ts"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@pi-archimedes/core": "2.
|
|
22
|
+
"@pi-archimedes/core": "2.11.1"
|
|
23
23
|
},
|
|
24
24
|
"peerDependencies": {
|
|
25
25
|
"@earendil-works/pi-coding-agent": ">=0.86.0",
|
|
26
26
|
"@earendil-works/pi-tui": ">=0.86.0"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
|
-
"@earendil-works/pi-coding-agent": "
|
|
30
|
-
"@earendil-works/pi-tui": "
|
|
29
|
+
"@earendil-works/pi-coding-agent": "1.2.0",
|
|
30
|
+
"@earendil-works/pi-tui": "1.2.0",
|
|
31
31
|
"typescript": "^6.0.0"
|
|
32
32
|
},
|
|
33
33
|
"pi": {
|
package/src/clipboard.test.ts
CHANGED
|
@@ -28,7 +28,24 @@ vi.mock("node:child_process", async (importOriginal) => {
|
|
|
28
28
|
};
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
// A clipboard reader has no UI handle, and the console is not a surface: the
|
|
32
|
+
// oversized-image report goes to the diagnostics sink (core/src/log.ts).
|
|
33
|
+
const mockDiag = vi.hoisted(() => vi.fn());
|
|
34
|
+
vi.mock("@pi-archimedes/core/log", async (importOriginal) => {
|
|
35
|
+
const real = await importOriginal<typeof import("@pi-archimedes/core/log")>();
|
|
36
|
+
return { ...real, diag: mockDiag };
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
import {
|
|
40
|
+
buildPathInsertion,
|
|
41
|
+
clipboardTextForEditor,
|
|
42
|
+
isImageFilePath,
|
|
43
|
+
NATIVE_READ_TIMEOUT_MS,
|
|
44
|
+
readClipboardFilePaths,
|
|
45
|
+
readClipboardImage,
|
|
46
|
+
readClipboardImageViaNativeModule,
|
|
47
|
+
readClipboardText,
|
|
48
|
+
} from "./clipboard.js";
|
|
32
49
|
|
|
33
50
|
function createMockChildProcess(stdoutData = "", exitCode = 0) {
|
|
34
51
|
const child = new EventEmitter() as any;
|
|
@@ -183,7 +200,7 @@ describe("readClipboardImage", () => {
|
|
|
183
200
|
});
|
|
184
201
|
});
|
|
185
202
|
|
|
186
|
-
it("
|
|
203
|
+
it("diagnoses the log when PowerShell stdout exceeds MAX_BUFFER_BYTES", async () => {
|
|
187
204
|
mockGetNativeClipboard.mockReturnValue(undefined);
|
|
188
205
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
189
206
|
|
|
@@ -200,9 +217,11 @@ describe("readClipboardImage", () => {
|
|
|
200
217
|
|
|
201
218
|
const result = await readClipboardImage({ platform: "win32", environment: {} });
|
|
202
219
|
|
|
203
|
-
expect(
|
|
220
|
+
expect(mockDiag).toHaveBeenCalledWith(
|
|
221
|
+
"image-paste",
|
|
204
222
|
"[archimedes] Clipboard image exceeded maximum buffer size (50MB)",
|
|
205
223
|
);
|
|
224
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
206
225
|
expect(result).toBeNull();
|
|
207
226
|
warnSpy.mockRestore();
|
|
208
227
|
});
|
|
@@ -309,3 +328,478 @@ describe("readClipboardImage", () => {
|
|
|
309
328
|
});
|
|
310
329
|
});
|
|
311
330
|
});
|
|
331
|
+
|
|
332
|
+
// ── File paths on the clipboard (pi's "paste files" branch) ─────────────────
|
|
333
|
+
//
|
|
334
|
+
// pi reads them with `getNativeClipboard()?.getFilePaths?.()`
|
|
335
|
+
// (`dist/utils/clipboard.js:83-86`). The optional call is load-bearing for
|
|
336
|
+
// this package: the peer floor is pi/pi-tui `>=0.86.0` and `getFilePaths` is
|
|
337
|
+
// newer than that, and it is absent on the Linux helper even in 1.2.0
|
|
338
|
+
// (measured on this machine: `typeof getNativeClipboard().getFilePaths ===
|
|
339
|
+
// "undefined"`, `dist/native-platform.d.ts:8` declares it optional).
|
|
340
|
+
|
|
341
|
+
describe("readClipboardFilePaths", () => {
|
|
342
|
+
beforeEach(() => {
|
|
343
|
+
vi.clearAllMocks();
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it("returns the helper's paths verbatim", async () => {
|
|
347
|
+
mockGetNativeClipboard.mockReturnValue({ getFilePaths: vi.fn().mockResolvedValue(["/tmp/a.txt", "/tmp/b.txt"]) });
|
|
348
|
+
|
|
349
|
+
await expect(readClipboardFilePaths()).resolves.toEqual(["/tmp/a.txt", "/tmp/b.txt"]);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("returns null on a helper without getFilePaths (the Linux 1.2.0 shape) instead of throwing", async () => {
|
|
353
|
+
mockGetNativeClipboard.mockReturnValue({});
|
|
354
|
+
|
|
355
|
+
await expect(readClipboardFilePaths()).resolves.toBeNull();
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it("returns null when no clipboard helper loads at all", async () => {
|
|
359
|
+
mockGetNativeClipboard.mockReturnValue(undefined);
|
|
360
|
+
|
|
361
|
+
await expect(readClipboardFilePaths()).resolves.toBeNull();
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it.each([[null], [undefined], [[]]])("normalises an empty file list (%p) to null", async (returnValue) => {
|
|
365
|
+
mockGetNativeClipboard.mockReturnValue({ getFilePaths: vi.fn().mockResolvedValue(returnValue) });
|
|
366
|
+
|
|
367
|
+
await expect(readClipboardFilePaths()).resolves.toBeNull();
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it("lets a transfer failure reach the caller, as pi does", async () => {
|
|
371
|
+
mockGetNativeClipboard.mockReturnValue({
|
|
372
|
+
getFilePaths: vi.fn().mockRejectedValue(new Error("clipboard transfer failed")),
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
// pi does not catch here either — `handleClipboardPaste`'s own try/catch
|
|
376
|
+
// turns it into the failure notice, and so does ours.
|
|
377
|
+
await expect(readClipboardFilePaths()).rejects.toThrow("clipboard transfer failed");
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
// ── Clipboard text (pi's fallback branch) ──────────────────────────────────
|
|
382
|
+
//
|
|
383
|
+
// Mirrors `readClipboardText()` in pi's `dist/utils/clipboard.js:58-81`, which
|
|
384
|
+
// is not reachable from a package (pi's exports are "." and "./rpc-entry";
|
|
385
|
+
// `dist/index.d.ts:38` re-exports only `copyToClipboard`). The candidate order
|
|
386
|
+
// is pi's and it is not decoration: on this Wayland machine the X11 helper
|
|
387
|
+
// loads but every read fails on `Invalid MIT-MAGIC-COOKIE-1 key`, so a
|
|
388
|
+
// native-first reader would lose text that `wl-paste` returns instantly.
|
|
389
|
+
|
|
390
|
+
describe("readClipboardText", () => {
|
|
391
|
+
beforeEach(() => {
|
|
392
|
+
vi.clearAllMocks();
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
function textCommands(): string[] {
|
|
396
|
+
return mockSpawnSync.mock.calls.map((call) => String(call[0]));
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
it("reads Wayland text through wl-paste --no-newline --type text", async () => {
|
|
400
|
+
mockSpawnSync.mockImplementation((command: string) => ({
|
|
401
|
+
status: command === "wl-paste" ? 0 : 1,
|
|
402
|
+
stdout: Buffer.from("wayland text"),
|
|
403
|
+
error: undefined,
|
|
404
|
+
}));
|
|
405
|
+
|
|
406
|
+
const result = await readClipboardText({ platform: "linux", environment: { WAYLAND_DISPLAY: "wayland-0" } });
|
|
407
|
+
|
|
408
|
+
expect(mockSpawnSync.mock.calls[0]?.slice(0, 2)).toEqual(["wl-paste", ["--no-newline", "--type", "text"]]);
|
|
409
|
+
expect(result).toBe("wayland text");
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it("tries xclip then xsel on X11, in pi's order", async () => {
|
|
413
|
+
mockSpawnSync.mockImplementation((command: string) => ({
|
|
414
|
+
status: command === "xclip" ? 1 : 0,
|
|
415
|
+
stdout: Buffer.from("xsel text"),
|
|
416
|
+
error: undefined,
|
|
417
|
+
}));
|
|
418
|
+
|
|
419
|
+
const result = await readClipboardText({ platform: "linux", environment: { DISPLAY: ":0" } });
|
|
420
|
+
|
|
421
|
+
expect(textCommands()).toEqual(["xclip", "xsel"]);
|
|
422
|
+
expect(mockSpawnSync.mock.calls[0]?.slice(0, 2)).toEqual(["xclip", ["-selection", "clipboard", "-out"]]);
|
|
423
|
+
expect(mockSpawnSync.mock.calls[1]?.slice(0, 2)).toEqual(["xsel", ["--clipboard", "--output"]]);
|
|
424
|
+
expect(result).toBe("xsel text");
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it("prefers termux-clipboard-get under Termux", async () => {
|
|
428
|
+
mockSpawnSync.mockImplementation(() => ({ status: 0, stdout: Buffer.from("termux text"), error: undefined }));
|
|
429
|
+
|
|
430
|
+
const result = await readClipboardText({
|
|
431
|
+
platform: "android",
|
|
432
|
+
environment: { TERMUX_VERSION: "0.118.0" },
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
expect(textCommands()).toEqual(["termux-clipboard-get"]);
|
|
436
|
+
expect(result).toBe("termux text");
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
it("shells out nothing on macOS and reads through the native helper", async () => {
|
|
440
|
+
const getText = vi.fn().mockResolvedValue("native text");
|
|
441
|
+
mockGetNativeClipboard.mockReturnValue({ getText });
|
|
442
|
+
|
|
443
|
+
const result = await readClipboardText({ platform: "darwin", environment: {} });
|
|
444
|
+
|
|
445
|
+
expect(mockSpawnSync).not.toHaveBeenCalled();
|
|
446
|
+
expect(getText).toHaveBeenCalled();
|
|
447
|
+
expect(result).toBe("native text");
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it("stops at a command that succeeded with empty output instead of falling through to the helper", async () => {
|
|
451
|
+
// pi returns `bytes.toString("utf8") || null` for the first command that
|
|
452
|
+
// ran cleanly: an empty text/plain is an answer, not a failure to retry
|
|
453
|
+
// somewhere else (an empty clipboard must stay empty).
|
|
454
|
+
mockSpawnSync.mockImplementation(() => ({ status: 0, stdout: Buffer.alloc(0), error: undefined }));
|
|
455
|
+
const getText = vi.fn().mockResolvedValue("stale native image text");
|
|
456
|
+
mockGetNativeClipboard.mockReturnValue({ getText });
|
|
457
|
+
|
|
458
|
+
const result = await readClipboardText({ platform: "linux", environment: { WAYLAND_DISPLAY: "wayland-0" } });
|
|
459
|
+
|
|
460
|
+
expect(result).toBeNull();
|
|
461
|
+
expect(getText).not.toHaveBeenCalled();
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
it("falls through a candidate that exited non-zero, then to the native helper", async () => {
|
|
465
|
+
mockSpawnSync.mockImplementation(() => ({ status: 1, stdout: Buffer.alloc(0), error: undefined }));
|
|
466
|
+
const getText = vi.fn().mockResolvedValue("native fallback");
|
|
467
|
+
mockGetNativeClipboard.mockReturnValue({ getText });
|
|
468
|
+
|
|
469
|
+
const result = await readClipboardText({ platform: "linux", environment: { DISPLAY: ":0" } });
|
|
470
|
+
|
|
471
|
+
expect(textCommands()).toEqual(["xclip", "xsel"]);
|
|
472
|
+
expect(result).toBe("native fallback");
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
it("falls through a missing command (ENOENT) rather than reporting empty text", async () => {
|
|
476
|
+
mockSpawnSync.mockImplementation(() => ({
|
|
477
|
+
status: null,
|
|
478
|
+
stdout: Buffer.alloc(0),
|
|
479
|
+
error: Object.assign(new Error("spawn xclip ENOENT"), { code: "ENOENT" }),
|
|
480
|
+
}));
|
|
481
|
+
const getText = vi.fn().mockResolvedValue("native fallback");
|
|
482
|
+
mockGetNativeClipboard.mockReturnValue({ getText });
|
|
483
|
+
|
|
484
|
+
const result = await readClipboardText({ platform: "linux", environment: { DISPLAY: ":0" } });
|
|
485
|
+
|
|
486
|
+
expect(textCommands()).toEqual(["xclip", "xsel"]);
|
|
487
|
+
expect(result).toBe("native fallback");
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
it("returns null when the native helper has no text, is absent, or throws", async () => {
|
|
491
|
+
mockGetNativeClipboard.mockReturnValue({ getText: vi.fn().mockResolvedValue(null) });
|
|
492
|
+
await expect(readClipboardText({ platform: "darwin", environment: {} })).resolves.toBeNull();
|
|
493
|
+
|
|
494
|
+
mockGetNativeClipboard.mockReturnValue({ getText: vi.fn().mockRejectedValue(new Error("no display")) });
|
|
495
|
+
await expect(readClipboardText({ platform: "darwin", environment: {} })).resolves.toBeNull();
|
|
496
|
+
|
|
497
|
+
mockGetNativeClipboard.mockReturnValue(undefined);
|
|
498
|
+
await expect(readClipboardText({ platform: "darwin", environment: {} })).resolves.toBeNull();
|
|
499
|
+
});
|
|
500
|
+
|
|
501
|
+
it("treats an empty string from the native helper as no text", async () => {
|
|
502
|
+
mockGetNativeClipboard.mockReturnValue({ getText: vi.fn().mockResolvedValue("") });
|
|
503
|
+
|
|
504
|
+
await expect(readClipboardText({ platform: "darwin", environment: {} })).resolves.toBeNull();
|
|
505
|
+
});
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
// ── pi's path-insertion spacing rule, mirrored ─────────────────────────────
|
|
509
|
+
//
|
|
510
|
+
// Quoted from pi 1.2.0 `dist/modes/interactive/interactive-mode.js`:
|
|
511
|
+
//
|
|
512
|
+
// 2504 const paths = this.isBashMode ? filePaths.map(quoteIfNeeded).join(" ") : filePaths.join("\n");
|
|
513
|
+
// 2505 const cursor = this.editor.getCursor?.();
|
|
514
|
+
// 2506 const currentLine = cursor ? (this.editor.getText().split("\n")[cursor.line] ?? "") : "";
|
|
515
|
+
// 2507 const characterBeforeCursor = cursor && cursor.col > 0 ? currentLine[cursor.col - 1] : "";
|
|
516
|
+
// 2508 const characterAfterCursor = cursor ? currentLine[cursor.col] : "";
|
|
517
|
+
// 2509 const leadingSpace = characterBeforeCursor && !/\s/.test(characterBeforeCursor) ? " " : "";
|
|
518
|
+
// 2510 const trailingSpace = characterAfterCursor && !/\s/.test(characterAfterCursor) ? " " : "";
|
|
519
|
+
// 2511 this.editor.insertTextAtCursor?.(`${leadingSpace}${paths}${trailingSpace}`);
|
|
520
|
+
//
|
|
521
|
+
// `buildPathInsertion` is that block, with the editor read replaced by
|
|
522
|
+
// arguments so it can be asserted per case. `applyPathInsertion` in the test
|
|
523
|
+
// is the `insertTextAtCursor` half: text before the cursor + insertion + text
|
|
524
|
+
// after it.
|
|
525
|
+
|
|
526
|
+
function applyPathInsertion(editorText: string, insertion: string, cursor: { line: number; col: number }): string {
|
|
527
|
+
const lines = editorText.split("\n");
|
|
528
|
+
const line = lines[cursor.line] ?? "";
|
|
529
|
+
lines[cursor.line] = line.slice(0, cursor.col) + insertion + line.slice(cursor.col);
|
|
530
|
+
return lines.join("\n");
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
const endOf = (editorText: string): { line: number; col: number } => {
|
|
534
|
+
const lines = editorText.split("\n");
|
|
535
|
+
return { line: lines.length - 1, col: lines[lines.length - 1]!.length };
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
describe("isImageFilePath", () => {
|
|
539
|
+
// The predicate a "should this path be read as an image?" decision would
|
|
540
|
+
// make. Its first caller is a test (see the no-double-attach case in
|
|
541
|
+
// paste-dispatch.test.ts), because the dispatch deliberately never reads an
|
|
542
|
+
// image off disk — but the check still has to answer correctly, and once did
|
|
543
|
+
// not: the extension set is dotted (".png") while `split(".").pop()` returns
|
|
544
|
+
// the extension bare ("png"), so every path came back false.
|
|
545
|
+
it.each([["/tmp/screenshot.png"], ["/tmp/Photo.JPG"], ["./capture.jpeg"], ["image.gif"]])(
|
|
546
|
+
"recognises %s", (filePath) => {
|
|
547
|
+
expect(isImageFilePath(filePath)).toBe(true);
|
|
548
|
+
},
|
|
549
|
+
);
|
|
550
|
+
|
|
551
|
+
it.each([["/tmp/report.pdf"], ["/tmp/script.ts"], ["/tmp/no-extension"], ["archive.tar"]])(
|
|
552
|
+
"rejects %s",
|
|
553
|
+
(filePath) => {
|
|
554
|
+
expect(isImageFilePath(filePath)).toBe(false);
|
|
555
|
+
},
|
|
556
|
+
);
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
describe("native clipboard reads are bounded, so ctrl+v cannot hang", () => {
|
|
560
|
+
// The pi clipboard contract has no deadline: `getImage`/`getText`/`getFilePaths`
|
|
561
|
+
// are plain promises, and an unresponsive X11/Wayland session blocks inside
|
|
562
|
+
// the native call. The shortcut handler holds a `_pasting` mutex for the
|
|
563
|
+
// lifetime of a dispatch, so one read that never settles makes every later
|
|
564
|
+
// `ctrl+v` a silent no-op for the rest of the session — a dead key, with
|
|
565
|
+
// nothing on screen to explain it. Every native read therefore carries the
|
|
566
|
+
// same budget as one CLI read (`READ_TIMEOUT_MS`), which is what these
|
|
567
|
+
// promises are measured against.
|
|
568
|
+
const neverSettles = <T>(): Promise<T> => new Promise<T>(() => {});
|
|
569
|
+
const missingCLITools = (): void => {
|
|
570
|
+
mockSpawnSync.mockImplementation(() => ({
|
|
571
|
+
status: null,
|
|
572
|
+
stdout: Buffer.alloc(0),
|
|
573
|
+
error: Object.assign(new Error("spawnSync ENOENT"), { code: "ENOENT" }),
|
|
574
|
+
}));
|
|
575
|
+
};
|
|
576
|
+
|
|
577
|
+
it("readClipboardImage rejects with a timeout rather than hanging on a silent helper", async () => {
|
|
578
|
+
vi.useFakeTimers();
|
|
579
|
+
try {
|
|
580
|
+
missingCLITools();
|
|
581
|
+
mockGetNativeClipboard.mockReturnValue({ getImage: neverSettles });
|
|
582
|
+
const read = readClipboardImage({
|
|
583
|
+
platform: "linux",
|
|
584
|
+
environment: { WAYLAND_DISPLAY: "wayland-0" },
|
|
585
|
+
});
|
|
586
|
+
const outcome = read.then(
|
|
587
|
+
() => "resolved",
|
|
588
|
+
(error: unknown) => (error instanceof Error ? error.message : String(error)),
|
|
589
|
+
);
|
|
590
|
+
|
|
591
|
+
// Not a hair-trigger: a slow-but-living helper is not cut off early.
|
|
592
|
+
await vi.advanceTimersByTimeAsync(NATIVE_READ_TIMEOUT_MS / 2);
|
|
593
|
+
await expect(Promise.race([outcome, Promise.resolve("still running")])).resolves.toBe(
|
|
594
|
+
"still running",
|
|
595
|
+
);
|
|
596
|
+
|
|
597
|
+
await vi.advanceTimersByTimeAsync(NATIVE_READ_TIMEOUT_MS);
|
|
598
|
+
await expect(outcome).resolves.toMatch(/timed out/i);
|
|
599
|
+
} finally {
|
|
600
|
+
vi.useRealTimers();
|
|
601
|
+
}
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
it("readClipboardFilePaths falls through to the next branch when the helper never answers", async () => {
|
|
605
|
+
vi.useFakeTimers();
|
|
606
|
+
try {
|
|
607
|
+
mockGetNativeClipboard.mockReturnValue({ getFilePaths: neverSettles });
|
|
608
|
+
const read = readClipboardFilePaths();
|
|
609
|
+
let settled = false;
|
|
610
|
+
const outcome = read.then((paths) => {
|
|
611
|
+
settled = true;
|
|
612
|
+
return paths;
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
await vi.advanceTimersByTimeAsync(NATIVE_READ_TIMEOUT_MS / 2);
|
|
616
|
+
expect(settled).toBe(false);
|
|
617
|
+
|
|
618
|
+
await vi.advanceTimersByTimeAsync(NATIVE_READ_TIMEOUT_MS);
|
|
619
|
+
// `null`, not a rejection: a hung file-path read must not cost the user
|
|
620
|
+
// the text that is also on the clipboard.
|
|
621
|
+
await expect(outcome).resolves.toBeNull();
|
|
622
|
+
} finally {
|
|
623
|
+
vi.useRealTimers();
|
|
624
|
+
}
|
|
625
|
+
});
|
|
626
|
+
|
|
627
|
+
it("readClipboardText reads no text when the helper never answers", async () => {
|
|
628
|
+
vi.useFakeTimers();
|
|
629
|
+
try {
|
|
630
|
+
// No CLI candidates on this platform, so the helper is the only reader.
|
|
631
|
+
mockGetNativeClipboard.mockReturnValue({ getText: neverSettles });
|
|
632
|
+
const read = readClipboardText({ platform: "darwin", environment: {} });
|
|
633
|
+
let settled = false;
|
|
634
|
+
const outcome = read.then((text) => {
|
|
635
|
+
settled = true;
|
|
636
|
+
return text;
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
await vi.advanceTimersByTimeAsync(NATIVE_READ_TIMEOUT_MS / 2);
|
|
640
|
+
expect(settled).toBe(false);
|
|
641
|
+
|
|
642
|
+
await vi.advanceTimersByTimeAsync(NATIVE_READ_TIMEOUT_MS);
|
|
643
|
+
await expect(outcome).resolves.toBeNull();
|
|
644
|
+
} finally {
|
|
645
|
+
vi.useRealTimers();
|
|
646
|
+
}
|
|
647
|
+
});
|
|
648
|
+
|
|
649
|
+
it("still returns bytes that arrive inside the deadline", async () => {
|
|
650
|
+
vi.useFakeTimers();
|
|
651
|
+
try {
|
|
652
|
+
missingCLITools();
|
|
653
|
+
mockGetNativeClipboard.mockReturnValue({
|
|
654
|
+
getImage: () => {
|
|
655
|
+
return new Promise<Uint8Array>((resolve) => {
|
|
656
|
+
setTimeout(() => resolve(new Uint8Array([9, 8, 7])), 100);
|
|
657
|
+
});
|
|
658
|
+
},
|
|
659
|
+
});
|
|
660
|
+
const read = readClipboardImage({
|
|
661
|
+
platform: "linux",
|
|
662
|
+
environment: { WAYLAND_DISPLAY: "wayland-0" },
|
|
663
|
+
});
|
|
664
|
+
await vi.advanceTimersByTimeAsync(200);
|
|
665
|
+
await expect(read).resolves.toEqual({ bytes: new Uint8Array([9, 8, 7]), mimeType: "image/png" });
|
|
666
|
+
} finally {
|
|
667
|
+
vi.useRealTimers();
|
|
668
|
+
}
|
|
669
|
+
});
|
|
670
|
+
it("still gives PowerShell a turn when the native helper hangs (win32)", async () => {
|
|
671
|
+
vi.useFakeTimers();
|
|
672
|
+
try {
|
|
673
|
+
mockGetNativeClipboard.mockReturnValue({ getImage: neverSettles });
|
|
674
|
+
// PowerShell answers "no image" (non-zero exit / empty stdout).
|
|
675
|
+
mockSpawn.mockImplementation(() => createMockChildProcess("", 1));
|
|
676
|
+
|
|
677
|
+
const read = readClipboardImage({ platform: "win32", environment: {} });
|
|
678
|
+
await vi.advanceTimersByTimeAsync(NATIVE_READ_TIMEOUT_MS + 50);
|
|
679
|
+
|
|
680
|
+
// The timeout must not short-circuit the platform fallback: an answer
|
|
681
|
+
// from PowerShell is an answer, so the read resolves quietly instead of
|
|
682
|
+
// reporting the hung native helper.
|
|
683
|
+
await expect(read).resolves.toBeNull();
|
|
684
|
+
expect(mockSpawn).toHaveBeenCalled();
|
|
685
|
+
} finally {
|
|
686
|
+
vi.useRealTimers();
|
|
687
|
+
}
|
|
688
|
+
});
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
describe("clipboardTextForEditor: what may go through pasteToEditor", () => {
|
|
692
|
+
// `ctx.ui.pasteToEditor` is a bracketed paste (interactive-mode.js:2075), so
|
|
693
|
+
// the paste framing is the one thing clipboard content must not carry: pi-tui
|
|
694
|
+
// ends the paste at the first `\x1b[201~` (components/editor.js:576-587) and
|
|
695
|
+
// the rest of the clipboard is lost. Measured end-to-end in
|
|
696
|
+
// paste-dispatch.test.ts; this is the pure rule.
|
|
697
|
+
it("drops the escape byte that would close the paste early, keeping the rest", () => {
|
|
698
|
+
expect(clipboardTextForEditor("hi\u001b[201~!echo pwned\r")).toBe("hi[201~!echo pwned\r");
|
|
699
|
+
});
|
|
700
|
+
|
|
701
|
+
it("drops a bare escape byte and leaves every other character alone", () => {
|
|
702
|
+
expect(clipboardTextForEditor("hi\u001b[31mred")).toBe("hi[31mred");
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
it("returns ordinary text (including newlines and tabs) untouched", () => {
|
|
706
|
+
const ordinary = "two lines\nand a\ttab, a NUL \u0000 and a trailing newline\n";
|
|
707
|
+
expect(clipboardTextForEditor(ordinary)).toBe(ordinary);
|
|
708
|
+
});
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
describe("buildPathInsertion: pi's spacing rule (interactive-mode.js:2504-2511)", () => {
|
|
712
|
+
it("empty editor: the path alone, no padding", () => {
|
|
713
|
+
const insertion = buildPathInsertion(["/tmp/a.txt"], "");
|
|
714
|
+
|
|
715
|
+
expect(insertion).toBe("/tmp/a.txt");
|
|
716
|
+
expect(applyPathInsertion("", insertion, endOf(""))).toBe("/tmp/a.txt");
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
it("draft ending in a word: one leading space, no trailing space", () => {
|
|
720
|
+
const insertion = buildPathInsertion(["/tmp/a.txt"], "look at");
|
|
721
|
+
|
|
722
|
+
expect(insertion).toBe(" /tmp/a.txt");
|
|
723
|
+
expect(applyPathInsertion("look at", insertion, endOf("look at"))).toBe("look at /tmp/a.txt");
|
|
724
|
+
});
|
|
725
|
+
|
|
726
|
+
it("draft ending in a newline: no padding at all (the cursor is on an empty line)", () => {
|
|
727
|
+
const insertion = buildPathInsertion(["/tmp/a.txt"], "look at\n");
|
|
728
|
+
|
|
729
|
+
expect(insertion).toBe("/tmp/a.txt");
|
|
730
|
+
expect(applyPathInsertion("look at\n", insertion, endOf("look at\n"))).toBe("look at\n/tmp/a.txt");
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
it("cursor mid-line between word characters: spaces on both sides", () => {
|
|
734
|
+
const editorText = "alpha";
|
|
735
|
+
const cursor = { line: 0, col: 4 };
|
|
736
|
+
|
|
737
|
+
const insertion = buildPathInsertion(["/tmp/a.txt"], editorText, cursor);
|
|
738
|
+
|
|
739
|
+
expect(insertion).toBe(" /tmp/a.txt ");
|
|
740
|
+
expect(applyPathInsertion(editorText, insertion, cursor)).toBe("alph /tmp/a.txt a");
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
it("cursor after a space and before a word: no leading space, trailing space kept", () => {
|
|
744
|
+
const editorText = "a b";
|
|
745
|
+
const cursor = { line: 0, col: 2 };
|
|
746
|
+
|
|
747
|
+
expect(buildPathInsertion(["/tmp/a.txt"], editorText, cursor)).toBe("/tmp/a.txt ");
|
|
748
|
+
});
|
|
749
|
+
|
|
750
|
+
it("cursor at the start of a line whose predecessor ends in a word: no leading space (the rule is per line)", () => {
|
|
751
|
+
const editorText = "one\ntwo";
|
|
752
|
+
const cursor = { line: 1, col: 0 };
|
|
753
|
+
|
|
754
|
+
// No leading space: the character before the cursor is *nothing on this
|
|
755
|
+
// line*, not the 'e' on the line above — pi reads currentLine, not the
|
|
756
|
+
// whole text (:2506-2507). The trailing space does appear, because the
|
|
757
|
+
// text after the cursor on this line ('t') is not whitespace (:2510).
|
|
758
|
+
expect(buildPathInsertion(["/tmp/a.txt"], editorText, cursor)).toBe("/tmp/a.txt ");
|
|
759
|
+
expect(applyPathInsertion(editorText, buildPathInsertion(["/tmp/a.txt"], editorText, cursor), cursor)).toBe(
|
|
760
|
+
"one\n/tmp/a.txt two",
|
|
761
|
+
);
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
it("multi-path clipboard: newline-joined inside one pair of spaces", () => {
|
|
765
|
+
const editorText = "compare";
|
|
766
|
+
const cursor = { line: 0, col: 3 };
|
|
767
|
+
|
|
768
|
+
const insertion = buildPathInsertion(["/tmp/one.txt", "/tmp/two.txt"], editorText, cursor);
|
|
769
|
+
|
|
770
|
+
expect(insertion).toBe(" /tmp/one.txt\n/tmp/two.txt ");
|
|
771
|
+
expect(applyPathInsertion(editorText, insertion, cursor)).toBe("com /tmp/one.txt\n/tmp/two.txt pare");
|
|
772
|
+
});
|
|
773
|
+
|
|
774
|
+
it("bash draft: shell-quoted paths joined with spaces (pi's isBashMode branch)", () => {
|
|
775
|
+
const editorText = "!ls ";
|
|
776
|
+
|
|
777
|
+
// interactive-mode.js:2504 — `filePaths.map(quoteIfNeeded).join(" ")`, and
|
|
778
|
+
// pi's isBashMode is the draft's `!` prefix (:2470).
|
|
779
|
+
expect(buildPathInsertion(["/tmp/a file.txt", "/tmp/plain.txt"], editorText)).toBe(
|
|
780
|
+
"'/tmp/a file.txt' /tmp/plain.txt",
|
|
781
|
+
);
|
|
782
|
+
});
|
|
783
|
+
|
|
784
|
+
it("bash draft escapes an embedded single quote exactly as quoteIfNeeded does", () => {
|
|
785
|
+
// interactive-mode.js:170 — `'${value.replace(/'/g, `'\\''`)}'`: every '
|
|
786
|
+
// becomes '\'' and the whole thing is wrapped in single quotes.
|
|
787
|
+
expect(buildPathInsertion(["'/q'"], "!echo ")).toBe("''\\''/q'\\'''");
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
it("bash draft quotes an empty path and leaves safe characters bare", () => {
|
|
791
|
+
// quoteIfNeeded requires length > 0 to skip quoting, so "" becomes ''.
|
|
792
|
+
expect(buildPathInsertion([""], "!echo ")).toBe("''");
|
|
793
|
+
expect(buildPathInsertion(["a-zA-Z0-9_-.@~:/"], "!echo ")).toBe("a-zA-Z0-9_-.@~:/");
|
|
794
|
+
});
|
|
795
|
+
|
|
796
|
+
it("defaults to the end of the draft, which is all the extension API can observe", () => {
|
|
797
|
+
const editorText = "one\ntwo";
|
|
798
|
+
|
|
799
|
+
// Same answer as asking for the end-of-text cursor explicitly: pi's rule
|
|
800
|
+
// applied at the only position ctx.ui exposes (no cursor accessor exists).
|
|
801
|
+
expect(buildPathInsertion(["/tmp/a.txt"], editorText)).toBe(
|
|
802
|
+
buildPathInsertion(["/tmp/a.txt"], editorText, endOf(editorText)),
|
|
803
|
+
);
|
|
804
|
+
});
|
|
805
|
+
});
|