@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/src/clipboard.ts
CHANGED
|
@@ -4,11 +4,24 @@ import { readFile } from "node:fs/promises";
|
|
|
4
4
|
|
|
5
5
|
import { convertToPng } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import { getNativeClipboard } from "@earendil-works/pi-tui";
|
|
7
|
+
import { diag } from "@pi-archimedes/core/log";
|
|
7
8
|
|
|
8
9
|
import type { ClipboardImage } from "./types.js";
|
|
9
10
|
|
|
10
11
|
const LIST_TYPES_TIMEOUT_MS = 1000;
|
|
11
12
|
const READ_TIMEOUT_MS = 5000;
|
|
13
|
+
/**
|
|
14
|
+
* How long a read through the native clipboard helper may take before it counts
|
|
15
|
+
* as failed — the same budget one CLI read gets, because a hung X11/Wayland
|
|
16
|
+
* session blocks in exactly the same place a hung `wl-paste` does.
|
|
17
|
+
*
|
|
18
|
+
* The deadline cannot cancel the native call; what it bounds is how long the
|
|
19
|
+
* keypress waits for it. That matters more than a slow read would suggest: the
|
|
20
|
+
* `ctrl+v` handler in `index.ts` holds a `_pasting` mutex for the lifetime of a
|
|
21
|
+
* dispatch, so an unbounded read that never settles leaves that mutex held and
|
|
22
|
+
* every later `ctrl+v` a silent no-op until the session restarts.
|
|
23
|
+
*/
|
|
24
|
+
export const NATIVE_READ_TIMEOUT_MS = READ_TIMEOUT_MS;
|
|
12
25
|
const MAX_BUFFER_BYTES = 50 * 1024 * 1024;
|
|
13
26
|
const SUPPORTED_IMAGE_MIME_TYPES = [
|
|
14
27
|
"image/png",
|
|
@@ -27,6 +40,42 @@ interface CommandResult {
|
|
|
27
40
|
export interface ClipboardReadResult {
|
|
28
41
|
available: boolean;
|
|
29
42
|
image: ClipboardImage | null;
|
|
43
|
+
/** Set when a reader existed but did not answer inside the deadline. */
|
|
44
|
+
timeout?: Error;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** A native clipboard read that outlived {@link NATIVE_READ_TIMEOUT_MS}. */
|
|
48
|
+
class NativeReadTimeoutError extends Error {
|
|
49
|
+
constructor(operation: string) {
|
|
50
|
+
super(`${operation} timed out after ${NATIVE_READ_TIMEOUT_MS}ms`);
|
|
51
|
+
this.name = "NativeReadTimeoutError";
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Give a native clipboard read a deadline. `undefined` (no helper, no such
|
|
57
|
+
* method) passes straight through; a rejection passes through unchanged, so a
|
|
58
|
+
* transfer failure still reads as a failure rather than as an empty clipboard.
|
|
59
|
+
*/
|
|
60
|
+
async function withinNativeReadDeadline<T>(
|
|
61
|
+
read: Promise<T> | undefined,
|
|
62
|
+
operation: string,
|
|
63
|
+
): Promise<T | undefined> {
|
|
64
|
+
if (read === undefined) {
|
|
65
|
+
return undefined;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
69
|
+
try {
|
|
70
|
+
return await new Promise<T>((resolve, reject) => {
|
|
71
|
+
timer = setTimeout(() => reject(new NativeReadTimeoutError(operation)), NATIVE_READ_TIMEOUT_MS);
|
|
72
|
+
read.then(resolve, reject);
|
|
73
|
+
});
|
|
74
|
+
} finally {
|
|
75
|
+
if (timer !== undefined) {
|
|
76
|
+
clearTimeout(timer);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
30
79
|
}
|
|
31
80
|
|
|
32
81
|
function isErrnoException(error: Error): error is NodeJS.ErrnoException {
|
|
@@ -76,7 +125,10 @@ export async function readClipboardImageViaNativeModule(
|
|
|
76
125
|
return { available: false, image: null };
|
|
77
126
|
}
|
|
78
127
|
|
|
79
|
-
const bytes = await
|
|
128
|
+
const bytes = await withinNativeReadDeadline(
|
|
129
|
+
native.getImage(),
|
|
130
|
+
"Native clipboard image read",
|
|
131
|
+
);
|
|
80
132
|
if (!bytes || bytes.length === 0) {
|
|
81
133
|
return { available: true, image: null };
|
|
82
134
|
}
|
|
@@ -106,7 +158,14 @@ export async function readClipboardImageViaNativeModule(
|
|
|
106
158
|
mimeType: "image/png",
|
|
107
159
|
},
|
|
108
160
|
};
|
|
109
|
-
} catch {
|
|
161
|
+
} catch (error) {
|
|
162
|
+
// A read that never answered is not the same event as a reader that is not
|
|
163
|
+
// installed, so it is carried out as a timeout rather than flattened into
|
|
164
|
+
// "unavailable": the caller reports the deadline, not "install a clipboard
|
|
165
|
+
// reader", and the keypress stops waiting.
|
|
166
|
+
if (error instanceof NativeReadTimeoutError) {
|
|
167
|
+
return { available: false, image: null, timeout: error };
|
|
168
|
+
}
|
|
110
169
|
return { available: false, image: null };
|
|
111
170
|
}
|
|
112
171
|
}
|
|
@@ -198,7 +257,7 @@ try {
|
|
|
198
257
|
if (stdout.length + data.length > MAX_BUFFER_BYTES) {
|
|
199
258
|
if (!exceededMaxBuffer) {
|
|
200
259
|
exceededMaxBuffer = true;
|
|
201
|
-
|
|
260
|
+
diag("image-paste", "[archimedes] Clipboard image exceeded maximum buffer size (50MB)");
|
|
202
261
|
}
|
|
203
262
|
return;
|
|
204
263
|
}
|
|
@@ -404,15 +463,263 @@ export async function readClipboardImage(options?: {
|
|
|
404
463
|
return null;
|
|
405
464
|
}
|
|
406
465
|
|
|
466
|
+
// Nothing answered. If the reason is that a helper never replied, say that —
|
|
467
|
+
// "install wl-clipboard or xclip" would be the wrong advice for a machine
|
|
468
|
+
// that has one and is not answering.
|
|
469
|
+
const timeout = readerResults.find((result) => result.timeout)?.timeout;
|
|
470
|
+
if (timeout) {
|
|
471
|
+
throw timeout;
|
|
472
|
+
}
|
|
473
|
+
|
|
407
474
|
throw new Error(getUnavailableReaderMessage(platform));
|
|
408
475
|
}
|
|
409
476
|
|
|
477
|
+
// ── Copied files on the clipboard ───────────────────────────────────────────
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Read copied file paths (a file manager's "Copy" on macOS/Windows) off the
|
|
481
|
+
* clipboard — the same call pi makes, `getNativeClipboard()?.getFilePaths?.()`
|
|
482
|
+
* (`dist/utils/clipboard.js:83-86`), on the same public accessor used for
|
|
483
|
+
* images above.
|
|
484
|
+
*
|
|
485
|
+
* The optional call is load-bearing, not defensive noise: this package's peer
|
|
486
|
+
* floor is pi/pi-tui `>=0.86.0`, `getFilePaths` arrived later than that, and it
|
|
487
|
+
* is still optional in 1.2.0 (`pi-tui/dist/native-platform.d.ts:8`). Measured on
|
|
488
|
+
* this Linux box with pi-tui 1.2.0 installed: the X11 helper has no
|
|
489
|
+
* `getFilePaths` at all (`typeof helper.getFilePaths === "undefined"`), so this
|
|
490
|
+
* returns `null` there and the caller falls through to text — which is exactly
|
|
491
|
+
* what pi's own path branch does on Linux.
|
|
492
|
+
*
|
|
493
|
+
* A rejected transfer is *not* swallowed: pi lets it reach
|
|
494
|
+
* `handleClipboardPaste`'s catch and show a failure, and the caller here does
|
|
495
|
+
* the same, so a clipboard that failed to answer is distinguishable from one
|
|
496
|
+
* that answered "no files". A read that never answered at all is the one
|
|
497
|
+
* exception, and it is a deadline rather than a verdict — see
|
|
498
|
+
* {@link NATIVE_READ_TIMEOUT_MS}.
|
|
499
|
+
*/
|
|
500
|
+
export async function readClipboardFilePaths(): Promise<string[] | null> {
|
|
501
|
+
let paths: string[] | null | undefined;
|
|
502
|
+
try {
|
|
503
|
+
paths = await withinNativeReadDeadline(
|
|
504
|
+
getNativeClipboard()?.getFilePaths?.(),
|
|
505
|
+
"Clipboard file path read",
|
|
506
|
+
);
|
|
507
|
+
} catch (error) {
|
|
508
|
+
if (error instanceof NativeReadTimeoutError) {
|
|
509
|
+
// Unlike a transfer failure, which is passed up as pi passes it up, a read
|
|
510
|
+
// that never answered falls through to the text branch: the caller's next
|
|
511
|
+
// question ("is there text?") is independent of this one, and a hung path
|
|
512
|
+
// read should not cost the user a text paste. pi is still sitting in this
|
|
513
|
+
// call when we return.
|
|
514
|
+
return null;
|
|
515
|
+
}
|
|
516
|
+
throw error;
|
|
517
|
+
}
|
|
518
|
+
return paths?.length ? paths : null;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// ── Clipboard text ──────────────────────────────────────────────────────────
|
|
522
|
+
|
|
523
|
+
interface TextReaderCommand {
|
|
524
|
+
command: string;
|
|
525
|
+
args: string[];
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* The command candidates for a plain-text read, in pi's order
|
|
530
|
+
* (`readClipboardText`, `dist/utils/clipboard.js:58-81`). pi's own function is
|
|
531
|
+
* not reachable from here — the package exports are `"."` and `"./rpc-entry"`
|
|
532
|
+
* and the root re-exports only `copyToClipboard` (`dist/index.d.ts:38`) — so
|
|
533
|
+
* this is a faithful re-implementation over the command runner this module
|
|
534
|
+
* already has, including the ordering reason: the native helper is the LAST
|
|
535
|
+
* resort, because on Wayland/X11 the CLI tools answer instantly while the X11
|
|
536
|
+
* helper can stall or fail on display auth. (Observed on this machine: the
|
|
537
|
+
* helper loads, then every read fails with `Invalid MIT-MAGIC-COOKIE-1 key`,
|
|
538
|
+
* while `wl-paste` returns text immediately.)
|
|
539
|
+
*
|
|
540
|
+
* No macOS/Windows candidates, deliberately: pi has none either — those
|
|
541
|
+
* platforms go straight to the native helper, so adding `pbpaste` here would be
|
|
542
|
+
* inventing a mechanism pi does not use.
|
|
543
|
+
*/
|
|
544
|
+
function textReaderCommands(
|
|
545
|
+
platform: NodeJS.Platform,
|
|
546
|
+
environment: NodeJS.ProcessEnv,
|
|
547
|
+
): TextReaderCommand[] {
|
|
548
|
+
const commands: TextReaderCommand[] = [];
|
|
549
|
+
// Termux reports platform "android", not "linux" (pi's note, clipboard.js:61).
|
|
550
|
+
if (environment.TERMUX_VERSION) {
|
|
551
|
+
commands.push({ command: "termux-clipboard-get", args: [] });
|
|
552
|
+
}
|
|
553
|
+
if (platform === "linux") {
|
|
554
|
+
if (environment.WAYLAND_DISPLAY) {
|
|
555
|
+
commands.push({ command: "wl-paste", args: ["--no-newline", "--type", "text"] });
|
|
556
|
+
}
|
|
557
|
+
if (environment.DISPLAY) {
|
|
558
|
+
commands.push({ command: "xclip", args: ["-selection", "clipboard", "-out"] });
|
|
559
|
+
commands.push({ command: "xsel", args: ["--clipboard", "--output"] });
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
return commands;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Read plain text from the clipboard, or `null` when there is none.
|
|
567
|
+
*
|
|
568
|
+
* pi's semantics, candidate for candidate: a candidate that cannot run (missing
|
|
569
|
+
* binary, non-zero exit, timeout) is skipped, but the first one that exits
|
|
570
|
+
* cleanly *answers* — even with empty output, which means "the clipboard holds
|
|
571
|
+
* no text" and must not be retried somewhere else (pi:
|
|
572
|
+
* `if (bytes !== undefined) return bytes.toString("utf8") || null`). The native
|
|
573
|
+
* helper is the final fallback and its failures read as "no text".
|
|
574
|
+
*/
|
|
575
|
+
export async function readClipboardText(options?: {
|
|
576
|
+
environment?: NodeJS.ProcessEnv;
|
|
577
|
+
platform?: NodeJS.Platform;
|
|
578
|
+
}): Promise<string | null> {
|
|
579
|
+
const environment = options?.environment ?? process.env;
|
|
580
|
+
const platform = options?.platform ?? process.platform;
|
|
581
|
+
|
|
582
|
+
for (const candidate of textReaderCommands(platform, environment)) {
|
|
583
|
+
const result = runCommand(candidate.command, candidate.args, READ_TIMEOUT_MS);
|
|
584
|
+
if (!result.ok) {
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
return result.stdout.toString("utf8") || null;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
try {
|
|
591
|
+
// Bounded for the same reason as the other two native reads: a helper that
|
|
592
|
+
// stops answering must not hold the keypress (and the mutex) open. A
|
|
593
|
+
// timeout reads as "no text" here, which is what the catch already means.
|
|
594
|
+
const text = await withinNativeReadDeadline(
|
|
595
|
+
getNativeClipboard()?.getText?.(),
|
|
596
|
+
"Clipboard text read",
|
|
597
|
+
);
|
|
598
|
+
return text || null;
|
|
599
|
+
} catch {
|
|
600
|
+
return null;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Clipboard text prepared for `ctx.ui.pasteToEditor`.
|
|
606
|
+
*
|
|
607
|
+
* `pasteToEditor` is not an insert: it is pi's bracketed-paste wiring,
|
|
608
|
+
* `this.editor.handleInput("\x1b[200~" + text + "\x1b[201~")`
|
|
609
|
+
* (`dist/modes/interactive/interactive-mode.js:2075`). pi-tui ends a paste at
|
|
610
|
+
* the first `\x1b[201~` it finds and re-feeds the rest of the chunk to
|
|
611
|
+
* `handleInput` as if it had been typed
|
|
612
|
+
* (`pi-tui/dist/components/editor.js:569-590`), so an escape byte arriving
|
|
613
|
+
* *from the clipboard* closes the paste early: measured, the payload
|
|
614
|
+
* `"hi\u001b[201~!echo pwned\r"` left the draft holding `"hi\n"` — the rest of
|
|
615
|
+
* the clipboard gone. pi's own text branch cannot lose it, because it writes
|
|
616
|
+
* bytes with `insertTextAtCursor` (`interactive-mode.js:2528`), and the
|
|
617
|
+
* extension API exposes no insert route — `pasteToEditor` is the only way in.
|
|
618
|
+
*
|
|
619
|
+
* So the framing byte is dropped from clipboard content and everything else
|
|
620
|
+
* is preserved verbatim. The remaining control characters need no handling
|
|
621
|
+
* here: pi-tui's paste filter already removes characters below 0x20 except
|
|
622
|
+
* `\n` and normalises line endings (`editor.js:1069-1074`, `:953-955`), which
|
|
623
|
+
* is the same treatment a paste typed at the terminal gets. (The divergence
|
|
624
|
+
* from pi is that pi *stores* those bytes; measured, pi keeps
|
|
625
|
+
* `"hi\u0000wo\u0007"` where this route gives `"hiwo"`.)
|
|
626
|
+
*/
|
|
627
|
+
export function clipboardTextForEditor(text: string): string {
|
|
628
|
+
return text.includes("\u001b") ? text.replace(/\u001b/g, "") : text;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// ── pi's path-insertion spacing rule ────────────────────────────────────────
|
|
632
|
+
|
|
633
|
+
/** Where the editor cursor sits, in the same shape pi's `getCursor()` returns. */
|
|
634
|
+
export interface EditorCursor {
|
|
635
|
+
line: number;
|
|
636
|
+
col: number;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* pi's shell-quoting helper, verbatim from
|
|
641
|
+
* `dist/modes/interactive/interactive-mode.js:166-171`:
|
|
642
|
+
*
|
|
643
|
+
* ```js
|
|
644
|
+
* function quoteIfNeeded(value) {
|
|
645
|
+
* if (value.length > 0 && !/[^a-zA-Z0-9_\-./~:@]/.test(value)) {
|
|
646
|
+
* return value;
|
|
647
|
+
* }
|
|
648
|
+
* return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
649
|
+
* }
|
|
650
|
+
* ```
|
|
651
|
+
*
|
|
652
|
+
* Re-implemented rather than imported because pi keeps it module-private in the
|
|
653
|
+
* interactive-mode bundle (used at `:182` and `:2504`, exported nowhere), and
|
|
654
|
+
* a shell-quoting divergence between the two implementations is worse than this
|
|
655
|
+
* 5-line copy with the source quoted above.
|
|
656
|
+
*/
|
|
657
|
+
function quoteIfNeeded(value: string): string {
|
|
658
|
+
if (value.length > 0 && !/[^a-zA-Z0-9_\-./~:@]/.test(value)) {
|
|
659
|
+
return value;
|
|
660
|
+
}
|
|
661
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/**
|
|
665
|
+
* The text to insert for a batch of clipboard file paths, built from pi's
|
|
666
|
+
* `handleClipboardPaste` (`dist/modes/interactive/interactive-mode.js:2504-2511`),
|
|
667
|
+
* quoted here so a future reader can diff it against pi rather than re-derive it:
|
|
668
|
+
*
|
|
669
|
+
* ```js
|
|
670
|
+
* const paths = this.isBashMode ? filePaths.map(quoteIfNeeded).join(" ") : filePaths.join("\n");
|
|
671
|
+
* const cursor = this.editor.getCursor?.();
|
|
672
|
+
* const currentLine = cursor ? (this.editor.getText().split("\n")[cursor.line] ?? "") : "";
|
|
673
|
+
* const characterBeforeCursor = cursor && cursor.col > 0 ? currentLine[cursor.col - 1] : "";
|
|
674
|
+
* const characterAfterCursor = cursor ? currentLine[cursor.col] : "";
|
|
675
|
+
* const leadingSpace = characterBeforeCursor && !/\s/.test(characterBeforeCursor) ? " " : "";
|
|
676
|
+
* const trailingSpace = characterAfterCursor && !/\s/.test(characterAfterCursor) ? " " : "";
|
|
677
|
+
* this.editor.insertTextAtCursor?.(`${leadingSpace}${paths}${trailingSpace}`);
|
|
678
|
+
* ```
|
|
679
|
+
*
|
|
680
|
+
* `cursor` defaults to the end of the draft. That default is the honest limit
|
|
681
|
+
* of this package, not a convenience: `ExtensionUIContext` exposes
|
|
682
|
+
* `pasteToEditor`, `setEditorText` and `getEditorText`
|
|
683
|
+
* (`dist/core/extensions/types.d.ts:132-137`) and no cursor accessor, and there
|
|
684
|
+
* is no route to the live editor instance from an extension — so an extension
|
|
685
|
+
* can read the draft but not where the caret is inside it. Everything the rule
|
|
686
|
+
* can be asked for is exact (see the per-case tests); where the caret is not at
|
|
687
|
+
* the end of the draft, pi's trailing space is the one thing we cannot know,
|
|
688
|
+
* and the run-on it would prevent is pinned as a known limitation in
|
|
689
|
+
* `paste-dispatch.test.ts` rather than hidden.
|
|
690
|
+
*
|
|
691
|
+
* pi's `isBashMode` is the draft's `!` prefix (`:2470`), so it is derived from
|
|
692
|
+
* the same draft text here.
|
|
693
|
+
*/
|
|
694
|
+
export function buildPathInsertion(
|
|
695
|
+
paths: readonly string[],
|
|
696
|
+
editorText: string,
|
|
697
|
+
cursor?: EditorCursor,
|
|
698
|
+
): string {
|
|
699
|
+
const lines = editorText.split("\n");
|
|
700
|
+
const lastLine = lines[lines.length - 1] ?? "";
|
|
701
|
+
const at: EditorCursor = cursor ?? { line: lines.length - 1, col: lastLine.length };
|
|
702
|
+
const isBashMode = editorText.trimStart().startsWith("!");
|
|
703
|
+
const joined = isBashMode ? paths.map(quoteIfNeeded).join(" ") : paths.join("\n");
|
|
704
|
+
|
|
705
|
+
const currentLine = lines[at.line] ?? "";
|
|
706
|
+
const characterBeforeCursor = at.col > 0 ? currentLine[at.col - 1] : "";
|
|
707
|
+
const characterAfterCursor = currentLine[at.col];
|
|
708
|
+
const leadingSpace = characterBeforeCursor && !/\s/.test(characterBeforeCursor) ? " " : "";
|
|
709
|
+
const trailingSpace = characterAfterCursor && !/\s/.test(characterAfterCursor) ? " " : "";
|
|
710
|
+
return `${leadingSpace}${joined}${trailingSpace}`;
|
|
711
|
+
}
|
|
712
|
+
|
|
410
713
|
// ── File-based image reading ────────────────────────────────────────────────
|
|
411
714
|
|
|
412
715
|
const IMAGE_EXTENSIONS = new Set([".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"]);
|
|
413
716
|
|
|
414
717
|
export function isImageFilePath(path: string): boolean {
|
|
415
|
-
|
|
718
|
+
const extension = path.toLowerCase().split(".").pop();
|
|
719
|
+
// The set is dotted (".png"); `split(".").pop()` returns the extension bare
|
|
720
|
+
// ("png"), so the dot has to be put back — comparing them as they were left
|
|
721
|
+
// every path (including a .png) reading as "not an image".
|
|
722
|
+
return extension !== undefined && IMAGE_EXTENSIONS.has(`.${extension}`);
|
|
416
723
|
}
|
|
417
724
|
|
|
418
725
|
export async function readFileAsImage(filePath: string): Promise<ClipboardImage | null> {
|
|
@@ -1,5 +1,18 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from "vitest";
|
|
2
|
-
|
|
1
|
+
import { describe, expect, it, vi, beforeEach } from "vitest";
|
|
2
|
+
|
|
3
|
+
// The standalone session_start handler drives the native-paste restore, so the
|
|
4
|
+
// wiring test mocks that module (hermetic: nothing may touch the real
|
|
5
|
+
// ~/.pi/agent/keybindings.json from a unit test) and asserts the call. The
|
|
6
|
+
// shortcut gate lives in the same module and is mocked to "suite keeps the key"
|
|
7
|
+
// here so this file tests wiring only; the gate itself is tested, against real
|
|
8
|
+
// settings, in shortcut-gate.test.ts.
|
|
9
|
+
vi.mock("./keybinding-offer.js", () => ({
|
|
10
|
+
runNativePasteRestore: vi.fn(async () => ({ kind: "suppressed" })),
|
|
11
|
+
suiteMayClaimPasteKey: vi.fn(() => true),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
const { runNativePasteRestore } = await import("./keybinding-offer.js");
|
|
15
|
+
const defaultExport = (await import("./index.js")).default;
|
|
3
16
|
|
|
4
17
|
describe("image-paste default export (standalone factory)", () => {
|
|
5
18
|
it("is a function that registers paste + per-session lifecycle on the pi extension", () => {
|
|
@@ -38,4 +51,79 @@ describe("image-paste default export (standalone factory)", () => {
|
|
|
38
51
|
expect(() => (startHandler as (...a: unknown[]) => unknown)({}, { hasUI: true })).not.toThrow();
|
|
39
52
|
expect(() => (shutdownHandler as (...a: unknown[]) => unknown)({}, {})).not.toThrow();
|
|
40
53
|
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
// ── the standalone call site for restoreNativePaste (ADR 0030 amendment) ───
|
|
57
|
+
//
|
|
58
|
+
// A standalone install has no meta factory, so image-paste's OWN
|
|
59
|
+
// session_start registration is what hands ctrl+v back when
|
|
60
|
+
// archimedes.imagePaste.suppressNativePaste is false. The handler must be
|
|
61
|
+
// registered at the top level of the factory — a handler nested inside another
|
|
62
|
+
// session_start accumulates on /reload (AGENTS.md).
|
|
63
|
+
describe("image-paste default export — native-paste restore call site", () => {
|
|
64
|
+
beforeEach(() => {
|
|
65
|
+
vi.mocked(runNativePasteRestore).mockClear();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("registers its session_start handler at the top level of the factory (never nested)", async () => {
|
|
69
|
+
const on = vi.fn();
|
|
70
|
+
defaultExport({ on, registerShortcut: vi.fn(), registerMessageRenderer: vi.fn() } as any);
|
|
71
|
+
|
|
72
|
+
// Registered during the factory call itself — before any session ran.
|
|
73
|
+
const countStarts = () =>
|
|
74
|
+
on.mock.calls.filter((c: Array<unknown>) => c[0] === "session_start").length;
|
|
75
|
+
const registeredByFactory = countStarts();
|
|
76
|
+
expect(registeredByFactory).toBeGreaterThanOrEqual(1);
|
|
77
|
+
|
|
78
|
+
// …and running sessions must not register MORE of them. A `pi.on`
|
|
79
|
+
// nested inside a session_start handler adds one handler per session on
|
|
80
|
+
// every /reload (AGENTS.md), which a plain "was it registered" check
|
|
81
|
+
// cannot see — the nested version registers exactly one at factory time
|
|
82
|
+
// too. This is the half of the claim that has teeth.
|
|
83
|
+
const startHandler = on.mock.calls.find((c: Array<unknown>) => c[0] === "session_start")?.[1] as (
|
|
84
|
+
event: unknown,
|
|
85
|
+
ctx: unknown,
|
|
86
|
+
) => unknown;
|
|
87
|
+
await startHandler({}, { hasUI: true });
|
|
88
|
+
await startHandler({}, { hasUI: true });
|
|
89
|
+
expect(countStarts(), "a session_start handler registered another one (nested)").toBe(
|
|
90
|
+
registeredByFactory,
|
|
91
|
+
);
|
|
92
|
+
// One call per session is the other half of the same promise.
|
|
93
|
+
expect(runNativePasteRestore).toHaveBeenCalledTimes(2);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("runs the restore once per session_start with that session's ctx", async () => {
|
|
97
|
+
const on = vi.fn();
|
|
98
|
+
defaultExport({ on, registerShortcut: vi.fn(), registerMessageRenderer: vi.fn() } as any);
|
|
99
|
+
|
|
100
|
+
const startHandler = on.mock.calls.find((c: Array<unknown>) => c[0] === "session_start")?.[1] as (
|
|
101
|
+
event: unknown,
|
|
102
|
+
ctx: unknown,
|
|
103
|
+
) => unknown;
|
|
104
|
+
const ctx = { hasUI: true, mode: "tui" };
|
|
105
|
+
await startHandler({}, ctx);
|
|
106
|
+
|
|
107
|
+
expect(runNativePasteRestore).toHaveBeenCalledTimes(1);
|
|
108
|
+
expect(vi.mocked(runNativePasteRestore).mock.calls[0]?.[0]).toBe(ctx);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("session_start still resolves when the restore rejects (a broken keybindings.json must not kill the session)", async () => {
|
|
112
|
+
vi.mocked(runNativePasteRestore).mockRejectedValueOnce(new Error("boom"));
|
|
113
|
+
const on = vi.fn();
|
|
114
|
+
defaultExport({ on, registerShortcut: vi.fn(), registerMessageRenderer: vi.fn() } as any);
|
|
115
|
+
|
|
116
|
+
const startHandler = on.mock.calls.find((c: Array<unknown>) => c[0] === "session_start")?.[1] as (
|
|
117
|
+
event: unknown,
|
|
118
|
+
ctx: unknown,
|
|
119
|
+
) => Promise<void>;
|
|
120
|
+
|
|
121
|
+
let thrown: unknown = null;
|
|
122
|
+
try {
|
|
123
|
+
await startHandler({}, { hasUI: true });
|
|
124
|
+
} catch (error) {
|
|
125
|
+
thrown = error;
|
|
126
|
+
}
|
|
127
|
+
expect(thrown).toBeNull();
|
|
128
|
+
});
|
|
41
129
|
});
|