@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/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 native.getImage();
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
- console.warn("[archimedes] Clipboard image exceeded maximum buffer size (50MB)");
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
- return IMAGE_EXTENSIONS.has(path.toLowerCase().split(".").pop() ?? "");
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
- import defaultExport from "./index.js";
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
  });