@pi-archimedes/image-paste 2.8.0 → 2.9.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 CHANGED
@@ -47,9 +47,9 @@ New to Pi? Pi itself is a one-time global install and needs Node.js ≥ 22.19.0.
47
47
 
48
48
  ## Per-platform requirements
49
49
 
50
- - **Linux** — a graphical session (`DISPLAY` or `WAYLAND_DISPLAY`) and one of `wl-clipboard` (tried first on Wayland sessions), `xclip` (tried first on X11), or the `@mariozechner/clipboard` native module. Termux is not supported.
51
- - **macOS** — the only image reader on macOS is the `@mariozechner/clipboard` native module (no other CLI fallback); it ships inside the `pi-coding-agent` installation but must be importable from the extension's location, so if your Pi install's layout puts it out of resolution reach, a read reports the reader as unavailable — make the module resolvable beside the extension and `/reload`.
52
- - **Windows** — the `@mariozechner/clipboard` native module first, with a PowerShell fallback.
50
+ - **Linux** — a graphical session (`DISPLAY` or `WAYLAND_DISPLAY`) and one of `wl-clipboard` (tried first on Wayland sessions), `xclip` (tried first on X11), or `@earendil-works/pi-tui` native clipboard bindings. Termux is not supported.
51
+ - **macOS** — `@earendil-works/pi-tui` bundled native clipboard helper.
52
+ - **Windows** — `@earendil-works/pi-tui` native clipboard helper first, with a PowerShell fallback.
53
53
 
54
54
  ## Settings
55
55
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/image-paste",
3
- "version": "2.8.0",
3
+ "version": "2.9.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.8.0"
22
+ "@pi-archimedes/core": "2.9.1"
23
23
  },
24
24
  "peerDependencies": {
25
- "@earendil-works/pi-coding-agent": ">=0.1.0",
26
- "@earendil-works/pi-tui": ">=0.1.0"
25
+ "@earendil-works/pi-coding-agent": ">=0.86.0",
26
+ "@earendil-works/pi-tui": ">=0.86.0"
27
27
  },
28
28
  "devDependencies": {
29
- "@earendil-works/pi-coding-agent": "^0.85.1",
30
- "@earendil-works/pi-tui": "^0.85.1",
29
+ "@earendil-works/pi-coding-agent": "^0.87.0",
30
+ "@earendil-works/pi-tui": "^0.87.0",
31
31
  "typescript": "^6.0.0"
32
32
  },
33
33
  "pi": {
@@ -0,0 +1,311 @@
1
+ import { EventEmitter } from "node:events";
2
+ import { beforeEach, describe, expect, it, vi } from "vitest";
3
+
4
+ const { mockGetImage, mockGetNativeClipboard, mockConvertToPng, mockSpawn, mockSpawnSync } = vi.hoisted(() => {
5
+ return {
6
+ mockGetImage: vi.fn(),
7
+ mockGetNativeClipboard: vi.fn(),
8
+ mockConvertToPng: vi.fn(),
9
+ mockSpawn: vi.fn(),
10
+ mockSpawnSync: vi.fn(),
11
+ };
12
+ });
13
+
14
+ vi.mock("@earendil-works/pi-tui", () => ({
15
+ getNativeClipboard: mockGetNativeClipboard,
16
+ }));
17
+
18
+ vi.mock("@earendil-works/pi-coding-agent", () => ({
19
+ convertToPng: mockConvertToPng,
20
+ }));
21
+
22
+ vi.mock("node:child_process", async (importOriginal) => {
23
+ const actual = await importOriginal<typeof import("node:child_process")>();
24
+ return {
25
+ ...actual,
26
+ spawn: mockSpawn,
27
+ spawnSync: mockSpawnSync,
28
+ };
29
+ });
30
+
31
+ import { readClipboardImage, readClipboardImageViaNativeModule } from "./clipboard.js";
32
+
33
+ function createMockChildProcess(stdoutData = "", exitCode = 0) {
34
+ const child = new EventEmitter() as any;
35
+ child.stdout = new EventEmitter();
36
+ child.kill = vi.fn();
37
+ setTimeout(() => {
38
+ if (stdoutData) {
39
+ child.stdout.emit("data", Buffer.from(stdoutData));
40
+ }
41
+ child.emit("close", exitCode);
42
+ }, 5);
43
+ return child;
44
+ }
45
+
46
+ describe("readClipboardImage", () => {
47
+ beforeEach(() => {
48
+ vi.clearAllMocks();
49
+ });
50
+
51
+ describe("macOS path", () => {
52
+ it("returns PNG image when getNativeClipboard() returns PNG bytes", async () => {
53
+ const pngBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
54
+ mockGetImage.mockResolvedValue(pngBytes);
55
+ mockGetNativeClipboard.mockReturnValue({ getImage: mockGetImage });
56
+
57
+ const result = await readClipboardImage({ platform: "darwin", environment: {} });
58
+
59
+ expect(mockGetNativeClipboard).toHaveBeenCalled();
60
+ expect(mockGetImage).toHaveBeenCalled();
61
+ expect(result).toEqual({
62
+ bytes: pngBytes,
63
+ mimeType: "image/png",
64
+ });
65
+ });
66
+
67
+ it("returns null when getNativeClipboard() returns empty (null)", async () => {
68
+ mockGetImage.mockResolvedValue(null);
69
+ mockGetNativeClipboard.mockReturnValue({ getImage: mockGetImage });
70
+
71
+ const result = await readClipboardImage({ platform: "darwin", environment: {} });
72
+
73
+ expect(mockGetNativeClipboard).toHaveBeenCalled();
74
+ expect(mockGetImage).toHaveBeenCalled();
75
+ expect(result).toBeNull();
76
+ });
77
+
78
+ it("returns null when getNativeClipboard() returns empty Uint8Array", async () => {
79
+ mockGetImage.mockResolvedValue(new Uint8Array(0));
80
+ mockGetNativeClipboard.mockReturnValue({ getImage: mockGetImage });
81
+
82
+ const result = await readClipboardImage({ platform: "darwin", environment: {} });
83
+
84
+ expect(mockGetNativeClipboard).toHaveBeenCalled();
85
+ expect(mockGetImage).toHaveBeenCalled();
86
+ expect(result).toBeNull();
87
+ });
88
+ });
89
+
90
+ describe("Windows path", () => {
91
+ it("converts BMP bytes to PNG via convertToPng and returns { bytes, mimeType }", async () => {
92
+ const bmpBytes = new Uint8Array([0x42, 0x4d, 0x10, 0x20, 0x30]);
93
+ mockGetImage.mockResolvedValue(bmpBytes);
94
+ mockGetNativeClipboard.mockReturnValue({ getImage: mockGetImage });
95
+
96
+ const pngBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 1, 2, 3]);
97
+ const base64Png = Buffer.from(pngBytes).toString("base64");
98
+ mockConvertToPng.mockResolvedValue({ data: base64Png, mimeType: "image/png" });
99
+
100
+ const result = await readClipboardImage({ platform: "win32", environment: {} });
101
+
102
+ expect(mockGetNativeClipboard).toHaveBeenCalled();
103
+ expect(mockGetImage).toHaveBeenCalled();
104
+ expect(mockConvertToPng).toHaveBeenCalledWith(
105
+ Buffer.from(bmpBytes).toString("base64"),
106
+ "image/bmp",
107
+ );
108
+ expect(result).toEqual({
109
+ bytes: pngBytes,
110
+ mimeType: "image/png",
111
+ });
112
+ expect(mockSpawn).not.toHaveBeenCalled();
113
+ });
114
+
115
+ it("returns non-BMP bytes directly as image/png without convertToPng", async () => {
116
+ const pngBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 1, 2, 3]);
117
+ mockGetImage.mockResolvedValue(pngBytes);
118
+ mockGetNativeClipboard.mockReturnValue({ getImage: mockGetImage });
119
+
120
+ const result = await readClipboardImage({ platform: "win32", environment: {} });
121
+
122
+ expect(mockConvertToPng).not.toHaveBeenCalled();
123
+ expect(result).toEqual({
124
+ bytes: pngBytes,
125
+ mimeType: "image/png",
126
+ });
127
+ expect(mockSpawn).not.toHaveBeenCalled();
128
+ });
129
+
130
+ it("falls back to PowerShell when convertToPng returns null", async () => {
131
+ const bmpBytes = new Uint8Array([0x42, 0x4d, 0x10, 0x20]);
132
+ mockGetImage.mockResolvedValue(bmpBytes);
133
+ mockGetNativeClipboard.mockReturnValue({ getImage: mockGetImage });
134
+ mockConvertToPng.mockResolvedValue(null);
135
+
136
+ const fallbackBytes = new Uint8Array([1, 2, 3, 4]);
137
+ mockSpawn.mockImplementation(() =>
138
+ createMockChildProcess(Buffer.from(fallbackBytes).toString("base64"), 0),
139
+ );
140
+
141
+ const result = await readClipboardImage({ platform: "win32", environment: {} });
142
+
143
+ expect(mockConvertToPng).toHaveBeenCalled();
144
+ expect(mockSpawn).toHaveBeenCalled();
145
+ expect(result).toEqual({
146
+ bytes: fallbackBytes,
147
+ mimeType: "image/png",
148
+ });
149
+ });
150
+
151
+ it("falls back to PowerShell when native.getImage() throws", async () => {
152
+ mockGetImage.mockRejectedValue(new Error("Native failure"));
153
+ mockGetNativeClipboard.mockReturnValue({ getImage: mockGetImage });
154
+
155
+ const fallbackBytes = new Uint8Array([5, 6, 7, 8]);
156
+ mockSpawn.mockImplementation(() =>
157
+ createMockChildProcess(Buffer.from(fallbackBytes).toString("base64"), 0),
158
+ );
159
+
160
+ const result = await readClipboardImage({ platform: "win32", environment: {} });
161
+
162
+ expect(mockSpawn).toHaveBeenCalled();
163
+ expect(result).toEqual({
164
+ bytes: fallbackBytes,
165
+ mimeType: "image/png",
166
+ });
167
+ });
168
+
169
+ it("falls back to PowerShell when getNativeClipboard() returns undefined", async () => {
170
+ mockGetNativeClipboard.mockReturnValue(undefined);
171
+
172
+ const fallbackBytes = new Uint8Array([9, 10, 11]);
173
+ mockSpawn.mockImplementation(() =>
174
+ createMockChildProcess(Buffer.from(fallbackBytes).toString("base64"), 0),
175
+ );
176
+
177
+ const result = await readClipboardImage({ platform: "win32", environment: {} });
178
+
179
+ expect(mockSpawn).toHaveBeenCalled();
180
+ expect(result).toEqual({
181
+ bytes: fallbackBytes,
182
+ mimeType: "image/png",
183
+ });
184
+ });
185
+
186
+ it("logs a console.warn when PowerShell stdout exceeds MAX_BUFFER_BYTES", async () => {
187
+ mockGetNativeClipboard.mockReturnValue(undefined);
188
+ const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
189
+
190
+ const child = new EventEmitter() as any;
191
+ child.stdout = new EventEmitter();
192
+ child.kill = vi.fn();
193
+ mockSpawn.mockImplementation(() => {
194
+ setTimeout(() => {
195
+ child.stdout.emit("data", Buffer.alloc(50 * 1024 * 1024 + 1));
196
+ child.emit("close", 0);
197
+ }, 5);
198
+ return child;
199
+ });
200
+
201
+ const result = await readClipboardImage({ platform: "win32", environment: {} });
202
+
203
+ expect(warnSpy).toHaveBeenCalledWith(
204
+ "[archimedes] Clipboard image exceeded maximum buffer size (50MB)",
205
+ );
206
+ expect(result).toBeNull();
207
+ warnSpy.mockRestore();
208
+ });
209
+ });
210
+
211
+ describe("Unavailable reader error messages", () => {
212
+ it("reports pi-tui native clipboard or CLI tools instead of @mariozechner/clipboard on darwin", async () => {
213
+ mockGetNativeClipboard.mockReturnValue(undefined);
214
+
215
+ await expect(
216
+ readClipboardImage({ platform: "darwin", environment: {} }),
217
+ ).rejects.toThrow(/@earendil-works\/pi-tui native clipboard or CLI tools/);
218
+ });
219
+
220
+ it("reports pi-tui native clipboard or CLI tools instead of @mariozechner/clipboard on win32", async () => {
221
+ mockGetNativeClipboard.mockReturnValue(undefined);
222
+ const child = new EventEmitter() as any;
223
+ child.stdout = new EventEmitter();
224
+ child.kill = vi.fn();
225
+ mockSpawn.mockImplementation(() => {
226
+ setTimeout(() => {
227
+ const err = new Error("Command not found") as NodeJS.ErrnoException;
228
+ err.code = "ENOENT";
229
+ child.emit("error", err);
230
+ }, 5);
231
+ return child;
232
+ });
233
+
234
+ await expect(
235
+ readClipboardImage({ platform: "win32", environment: {} }),
236
+ ).rejects.toThrow(/@earendil-works\/pi-tui native clipboard or CLI tools/);
237
+ });
238
+ });
239
+
240
+ describe("Linux paths", () => {
241
+ it("prefers Wayland (wl-paste attempted first) when WAYLAND_DISPLAY is set", async () => {
242
+ mockSpawnSync.mockImplementation((command: string, args: string[]) => {
243
+ if (command === "wl-paste" && args[0] === "--list-types") {
244
+ return { status: 0, stdout: Buffer.from("image/png\n"), error: undefined };
245
+ }
246
+ if (command === "wl-paste" && args[0] === "--type") {
247
+ return { status: 0, stdout: Buffer.from([1, 2, 3, 4]), error: undefined };
248
+ }
249
+ return { status: 1, stdout: Buffer.alloc(0), error: undefined };
250
+ });
251
+
252
+ const result = await readClipboardImage({
253
+ platform: "linux",
254
+ environment: { WAYLAND_DISPLAY: "wayland-0" },
255
+ });
256
+
257
+ expect(mockSpawnSync).toHaveBeenCalled();
258
+ expect(mockSpawnSync.mock.calls[0]![0]).toBe("wl-paste");
259
+ expect(result).toEqual({
260
+ bytes: new Uint8Array([1, 2, 3, 4]),
261
+ mimeType: "image/png",
262
+ });
263
+ });
264
+
265
+ it("prefers X11 (xclip attempted first) when DISPLAY is set without Wayland", async () => {
266
+ mockSpawnSync.mockImplementation((command: string, args: string[]) => {
267
+ if (command === "xclip" && args.includes("TARGETS")) {
268
+ return { status: 0, stdout: Buffer.from("image/png\n"), error: undefined };
269
+ }
270
+ if (command === "xclip" && args.includes("image/png")) {
271
+ return { status: 0, stdout: Buffer.from([5, 6, 7]), error: undefined };
272
+ }
273
+ return { status: 1, stdout: Buffer.alloc(0), error: undefined };
274
+ });
275
+
276
+ const result = await readClipboardImage({
277
+ platform: "linux",
278
+ environment: { DISPLAY: ":0" },
279
+ });
280
+
281
+ expect(mockSpawnSync).toHaveBeenCalled();
282
+ expect(mockSpawnSync.mock.calls[0]![0]).toBe("xclip");
283
+ expect(result).toEqual({
284
+ bytes: new Uint8Array([5, 6, 7]),
285
+ mimeType: "image/png",
286
+ });
287
+ });
288
+
289
+ it("throws appropriate graphical session error when DISPLAY and WAYLAND_DISPLAY are missing", async () => {
290
+ await expect(
291
+ readClipboardImage({ platform: "linux", environment: {} }),
292
+ ).rejects.toThrow(
293
+ "Clipboard image paste requires a graphical desktop session with DISPLAY or WAYLAND_DISPLAY.",
294
+ );
295
+ });
296
+
297
+ it("TERMUX_VERSION returns { available: false, image: null } from native module and null from readClipboardImage", async () => {
298
+ const nativeResult = await readClipboardImageViaNativeModule("linux", {
299
+ TERMUX_VERSION: "0.118.0",
300
+ DISPLAY: ":0",
301
+ });
302
+ expect(nativeResult).toEqual({ available: false, image: null });
303
+
304
+ const result = await readClipboardImage({
305
+ platform: "linux",
306
+ environment: { TERMUX_VERSION: "0.118.0", DISPLAY: ":0" },
307
+ });
308
+ expect(result).toBeNull();
309
+ });
310
+ });
311
+ });
package/src/clipboard.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
- import { createRequire } from "node:module";
3
2
  import { existsSync } from "node:fs";
4
3
  import { readFile } from "node:fs/promises";
5
4
 
6
- import type { ClipboardImage } from "./types.js";
5
+ import { convertToPng } from "@earendil-works/pi-coding-agent";
6
+ import { getNativeClipboard } from "@earendil-works/pi-tui";
7
7
 
8
- const require = createRequire(import.meta.url);
8
+ import type { ClipboardImage } from "./types.js";
9
9
 
10
10
  const LIST_TYPES_TIMEOUT_MS = 1000;
11
11
  const READ_TIMEOUT_MS = 5000;
@@ -18,25 +18,13 @@ const SUPPORTED_IMAGE_MIME_TYPES = [
18
18
  "image/bmp",
19
19
  ] as const;
20
20
 
21
- let cachedClipboardModule: ClipboardModule | null | undefined;
22
-
23
- /** Reset the clipboard module cache — allows retry after install. */
24
- export function resetClipboardModuleCache(): void {
25
- cachedClipboardModule = undefined;
26
- }
27
-
28
- interface ClipboardModule {
29
- hasImage: () => boolean;
30
- getImageBinary: () => Promise<Array<number> | Uint8Array>;
31
- }
32
-
33
21
  interface CommandResult {
34
22
  ok: boolean;
35
23
  stdout: Buffer;
36
24
  missingCommand: boolean;
37
25
  }
38
26
 
39
- interface ClipboardReadResult {
27
+ export interface ClipboardReadResult {
40
28
  available: boolean;
41
29
  image: ClipboardImage | null;
42
30
  }
@@ -74,54 +62,53 @@ function selectPreferredImageMimeType(mimeTypes: readonly string[]): string | nu
74
62
  return firstImage?.raw ?? null;
75
63
  }
76
64
 
77
- function loadClipboardModule(
78
- platform: NodeJS.Platform = process.platform,
79
- environment: NodeJS.ProcessEnv = process.env,
80
- ): ClipboardModule | null {
81
- if (cachedClipboardModule !== undefined) {
82
- return cachedClipboardModule;
83
- }
84
-
85
- if (environment.TERMUX_VERSION || !hasGraphicalSession(platform, environment)) {
86
- cachedClipboardModule = null;
87
- return cachedClipboardModule;
88
- }
89
-
90
- try {
91
- cachedClipboardModule = require("@mariozechner/clipboard") as ClipboardModule;
92
- } catch {
93
- cachedClipboardModule = null;
94
- }
95
-
96
- return cachedClipboardModule;
97
- }
98
-
99
- async function readClipboardImageViaNativeModule(
65
+ export async function readClipboardImageViaNativeModule(
100
66
  platform: NodeJS.Platform,
101
67
  environment: NodeJS.ProcessEnv,
102
68
  ): Promise<ClipboardReadResult> {
103
- const clipboard = loadClipboardModule(platform, environment);
104
- if (!clipboard) {
69
+ if (environment.TERMUX_VERSION || !hasGraphicalSession(platform, environment)) {
105
70
  return { available: false, image: null };
106
71
  }
107
72
 
108
- if (!clipboard.hasImage()) {
109
- return { available: true, image: null };
110
- }
73
+ try {
74
+ const native = getNativeClipboard();
75
+ if (!native) {
76
+ return { available: false, image: null };
77
+ }
111
78
 
112
- const imageData = await clipboard.getImageBinary();
113
- if (!imageData || imageData.length === 0) {
114
- return { available: true, image: null };
115
- }
79
+ const bytes = await native.getImage();
80
+ if (!bytes || bytes.length === 0) {
81
+ return { available: true, image: null };
82
+ }
116
83
 
117
- const bytes = imageData instanceof Uint8Array ? imageData : Uint8Array.from(imageData);
118
- return {
119
- available: true,
120
- image: {
121
- bytes,
122
- mimeType: "image/png",
123
- },
124
- };
84
+ const rawBytes = bytes instanceof Uint8Array ? bytes : Uint8Array.from(bytes);
85
+
86
+ if (platform === "win32") {
87
+ if (rawBytes.length >= 2 && rawBytes[0] === 0x42 && rawBytes[1] === 0x4d) {
88
+ const png = await convertToPng(Buffer.from(rawBytes).toString("base64"), "image/bmp");
89
+ if (png) {
90
+ return {
91
+ available: true,
92
+ image: {
93
+ bytes: Uint8Array.from(Buffer.from(png.data, "base64")),
94
+ mimeType: png.mimeType,
95
+ },
96
+ };
97
+ }
98
+ return { available: false, image: null };
99
+ }
100
+ }
101
+
102
+ return {
103
+ available: true,
104
+ image: {
105
+ bytes: rawBytes,
106
+ mimeType: "image/png",
107
+ },
108
+ };
109
+ } catch {
110
+ return { available: false, image: null };
111
+ }
125
112
  }
126
113
 
127
114
  function runCommand(
@@ -209,7 +196,10 @@ try {
209
196
  let exceededMaxBuffer = false;
210
197
  child.stdout?.on("data", (data: Buffer) => {
211
198
  if (stdout.length + data.length > MAX_BUFFER_BYTES) {
212
- exceededMaxBuffer = true;
199
+ if (!exceededMaxBuffer) {
200
+ exceededMaxBuffer = true;
201
+ console.warn("[archimedes] Clipboard image exceeded maximum buffer size (50MB)");
202
+ }
213
203
  return;
214
204
  }
215
205
  stdout += data.toString("utf8");
@@ -345,11 +335,11 @@ function readClipboardImageViaXclip(): ClipboardReadResult {
345
335
  function getUnavailableReaderMessage(platform: NodeJS.Platform): string {
346
336
  switch (platform) {
347
337
  case "linux":
348
- return "No Linux clipboard image reader is available. Install wl-clipboard or xclip, or ensure @mariozechner/clipboard is installed.";
338
+ return "No Linux clipboard image reader is available. Install wl-clipboard or xclip, or ensure @earendil-works/pi-tui native clipboard or CLI tools are available.";
349
339
  case "darwin":
350
- return "No macOS clipboard image reader is available. Ensure @mariozechner/clipboard is installed.";
340
+ return "No macOS clipboard image reader is available. Ensure @earendil-works/pi-tui native clipboard or CLI tools are available.";
351
341
  case "win32":
352
- return "No Windows clipboard image reader is available. Ensure PowerShell is available or @mariozechner/clipboard is installed.";
342
+ return "No Windows clipboard image reader is available. Ensure PowerShell is available or @earendil-works/pi-tui native clipboard or CLI tools are available.";
353
343
  default:
354
344
  return `Clipboard image paste is not supported on platform: ${platform}`;
355
345
  }
@@ -174,7 +174,11 @@ describe("offerKeybindingFix — gate matrix", () => {
174
174
  if (outcome === "yes") confirm.mockResolvedValue(true);
175
175
  else confirm.mockResolvedValue(false); // no + cancel: confirm resolves false
176
176
 
177
- await offerKeybindingFix(ctx);
177
+ // All matrix rows use a base-shape ctx (no reload), so the offer
178
+ // resolves `false` — the `true` contract is covered by the
179
+ // auto-reload branch tests below.
180
+ const result = await offerKeybindingFix(ctx);
181
+ expect(result).toBe(false);
178
182
 
179
183
  const shouldAsk =
180
184
  enabled === true &&
@@ -251,7 +255,8 @@ describe("flag write preserves existing settings", () => {
251
255
  });
252
256
  const { ctx, confirm } = makeCtx("tui");
253
257
  confirm.mockResolvedValue(outcome === "yes");
254
- await offerKeybindingFix(ctx);
258
+ // Base-shape ctx (no reload) → resolves `false` on both outcomes.
259
+ expect(await offerKeybindingFix(ctx)).toBe(false);
255
260
  }
256
261
 
257
262
  it("yes: snippet file written; other archimedes.imagePaste keys survive the flag set", async () => {
@@ -291,7 +296,8 @@ describe("file write throws → flag stays false (self-heals next session)", ()
291
296
  const { ctx, confirm, notify } = makeCtx("tui");
292
297
  confirm.mockResolvedValue(true);
293
298
 
294
- await expect(offerKeybindingFix(ctx)).resolves.toBeUndefined();
299
+ // Write failed → the offer never reloaded → `false`.
300
+ await expect(offerKeybindingFix(ctx)).resolves.toBe(false);
295
301
 
296
302
  expect(confirm).toHaveBeenCalledTimes(1);
297
303
  expect(fs.existsSync(keybindingsPath())).toBe(false);
@@ -316,7 +322,8 @@ describe("flag write throws → notify, no crash (file write already succeeded)"
316
322
  const { ctx, confirm, notify } = makeCtx("tui");
317
323
  confirm.mockResolvedValue(true);
318
324
 
319
- await expect(offerKeybindingFix(ctx)).resolves.toBeUndefined();
325
+ // Base-shape ctx (no reload) → `false`, even though the file was written.
326
+ await expect(offerKeybindingFix(ctx)).resolves.toBe(false);
320
327
 
321
328
  // The file write strictly precedes the flag set and survived:
322
329
  expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(SNIPPET);
@@ -343,7 +350,9 @@ describe("accept with base-shape ctx (no reload)", () => {
343
350
  const { ctx, confirm, notify } = makeCtx("tui");
344
351
  confirm.mockResolvedValue(true);
345
352
 
346
- await expect(offerKeybindingFix(ctx)).resolves.toBeUndefined();
353
+ // No reload on this ctx → the user is told to run /reload manually,
354
+ // so the offer did NOT trigger a reload → `false`.
355
+ await expect(offerKeybindingFix(ctx)).resolves.toBe(false);
347
356
 
348
357
  // File written with the exact snippet
349
358
  expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(SNIPPET);
@@ -390,8 +399,10 @@ describe("auto-reload branch (future Pi with reload on the event ctx)", () => {
390
399
  if ((args[0] as string) === CREATED_NOTIFY) order.push("notify");
391
400
  });
392
401
 
393
- await offerKeybindingFix(ctx);
402
+ const result = await offerKeybindingFix(ctx);
394
403
 
404
+ // The successful accept + reload() path resolves `true`
405
+ expect(result).toBe(true);
395
406
  // reload called exactly once
396
407
  expect(reload).toHaveBeenCalledTimes(1);
397
408
  // flag was already set when reload ran
@@ -411,14 +422,15 @@ describe("auto-reload branch (future Pi with reload on the event ctx)", () => {
411
422
  it("no path: reload NOT called", async () => {
412
423
  const { ctx, confirm, reload } = makeCtx("tui", { withReload: true });
413
424
  confirm.mockResolvedValue(false);
414
- await offerKeybindingFix(ctx);
425
+ // Decline: no reload → `false`.
426
+ expect(await offerKeybindingFix(ctx)).toBe(false);
415
427
  expect(reload).not.toHaveBeenCalled();
416
428
  });
417
429
 
418
430
  it("cancel (resolves false) path: reload NOT called", async () => {
419
431
  const { ctx, confirm, reload } = makeCtx("tui", { withReload: true });
420
432
  confirm.mockResolvedValue(false); // Esc/timeout: same as no
421
- await offerKeybindingFix(ctx);
433
+ expect(await offerKeybindingFix(ctx)).toBe(false);
422
434
  expect(reload).not.toHaveBeenCalled();
423
435
  });
424
436
 
@@ -427,7 +439,7 @@ describe("auto-reload branch (future Pi with reload on the event ctx)", () => {
427
439
  fs.mkdirSync(tmpPath(), { recursive: true });
428
440
  const { ctx, confirm, reload } = makeCtx("tui", { withReload: true });
429
441
  confirm.mockResolvedValue(true);
430
- await offerKeybindingFix(ctx);
442
+ expect(await offerKeybindingFix(ctx)).toBe(false);
431
443
  expect(reload).not.toHaveBeenCalled();
432
444
  });
433
445
 
@@ -450,16 +462,19 @@ describe("auto-reload branch (future Pi with reload on the event ctx)", () => {
450
462
 
451
463
  const { ctx, confirm, reload } = makeCtx("tui", { withReload: true });
452
464
  confirm.mockResolvedValue(true);
453
- await offerKeybindingFix(ctx);
465
+ expect(await offerKeybindingFix(ctx)).toBe(false);
454
466
  expect(reload).not.toHaveBeenCalled();
455
467
  });
456
468
 
457
- it("reload rejects: offer still resolves, flag persisted, file present, CREATED_NOTIFY emitted", async () => {
469
+ it("reload rejects: offer still resolves (false), flag persisted, file present, CREATED_NOTIFY emitted", async () => {
458
470
  const { ctx, confirm, notify, reload } = makeCtx("tui", { withReload: true });
459
471
  confirm.mockResolvedValue(true);
460
472
  reload!.mockRejectedValue(new Error("Reload failed"));
461
473
 
462
- await expect(offerKeybindingFix(ctx)).resolves.toBeUndefined();
474
+ // The reload FAILED: the TUI shows its own "Reload failed" status and the
475
+ // session continues on the (still-valid) ctx, so the offer reports `false`
476
+ // — the onboarding still runs on this session.
477
+ await expect(offerKeybindingFix(ctx)).resolves.toBe(false);
463
478
 
464
479
  // File must have been written
465
480
  expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(SNIPPET);
@@ -502,7 +517,8 @@ describe("concurrent creation is never clobbered", () => {
502
517
  const { ctx, confirm, notify } = makeCtx("tui");
503
518
  confirm.mockResolvedValue(true);
504
519
 
505
- await expect(offerKeybindingFix(ctx)).resolves.toBeUndefined();
520
+ // The offer ended before the reload step → `false`.
521
+ await expect(offerKeybindingFix(ctx)).resolves.toBe(false);
506
522
 
507
523
  // The concurrently created file is never clobbered by the snippet
508
524
  expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(CONCURRENT_CONTENT);
@@ -75,8 +75,19 @@ function markPromptDone(ctx: ExtensionContext): void {
75
75
  * tears down). This counts as a decline — the flag is set and the offer will
76
76
  * not appear again. To reset it, delete `archimedes.imagePaste.keybindingsPromptDone`
77
77
  * from `~/.pi/agent/settings.json`.
78
+ *
79
+ * **Return contract:** resolves `true` iff the offer triggered a successful
80
+ * `ctx.reload()` (the successful-accept path on a ctx that exposes `reload()`
81
+ * and the reload completed). Every other outcome — the gates (1–4), decline,
82
+ * a failed file write, the file-exists race, a base-shape ctx without
83
+ * `reload` (manual /reload hint), and a reload that itself failed (the TUI
84
+ * shows its own "Reload failed" and the session continues on the still-valid
85
+ * ctx) — resolves `false`. The meta factory uses this to sequence the
86
+ * first-run flows: if `true`, the reloaded session runs the flow again (where
87
+ * the offer is a no-op — keybindings.json now exists), so it skips the
88
+ * onboarding on the now-stale ctx.
78
89
  */
79
- export async function offerKeybindingFix(ctx: ExtensionContext): Promise<void> {
90
+ export async function offerKeybindingFix(ctx: ExtensionContext): Promise<boolean> {
80
91
  // Gate 1: extension on.
81
92
  // NOTE: reading archimedes.imagePaste.enabled in-package is a sanctioned
82
93
  // exception to the AGENTS.md "Plugin on/off" rule and ADR 0012 — this
@@ -84,24 +95,24 @@ export async function offerKeybindingFix(ctx: ExtensionContext): Promise<void> {
84
95
  // registration, so there is no registration gate to catch it here.
85
96
  // See docs/decisions/0012-plugin-gate-in-package-namespace.md § Exception.
86
97
  if (!isConfigEnabled(NAMESPACE)) {
87
- return;
98
+ return false;
88
99
  }
89
100
 
90
101
  // Gate 2: interactive TUI only. Deliberate: the flag is NOT consumed in
91
102
  // non-TUI modes, so a later TUI session still gets the offer.
92
103
  if (ctx.mode !== "tui") {
93
- return;
104
+ return false;
94
105
  }
95
106
 
96
107
  // Gate 3: not yet consumed
97
108
  if (loadConfig<PromptConfig>(NAMESPACE, { ...PROMPT_DEFAULTS }).keybindingsPromptDone === true) {
98
- return;
109
+ return false;
99
110
  }
100
111
 
101
112
  // Gate 4: file absent — never merge into or rewrite an existing user file
102
113
  const keybindingsPath = join(getAgentDir(), "keybindings.json");
103
114
  if (existsSync(keybindingsPath)) {
104
- return;
115
+ return false;
105
116
  }
106
117
 
107
118
  // Gate 5: ask (confirm signature is TITLE first — docs/extensions.md:165)
@@ -111,7 +122,7 @@ export async function offerKeybindingFix(ctx: ExtensionContext): Promise<void> {
111
122
  // as decline): set the flag and do nothing else.
112
123
  if (!confirmed) {
113
124
  markPromptDone(ctx);
114
- return;
125
+ return false;
115
126
  }
116
127
 
117
128
  // Yes: write the file atomically (tmp + rename) — strictly BEFORE the flag
@@ -130,7 +141,7 @@ export async function offerKeybindingFix(ctx: ExtensionContext): Promise<void> {
130
141
  // ignore
131
142
  }
132
143
  markPromptDone(ctx);
133
- return;
144
+ return false;
134
145
  }
135
146
  renameSync(tmpPath, keybindingsPath);
136
147
  } catch (error) {
@@ -145,7 +156,7 @@ export async function offerKeybindingFix(ctx: ExtensionContext): Promise<void> {
145
156
  `Could not create keybindings.json: ${messageOf(error)}`,
146
157
  "warning",
147
158
  );
148
- return;
159
+ return false;
149
160
  }
150
161
 
151
162
  // File written successfully → THEN set the flag (deliberately after the
@@ -170,11 +181,18 @@ export async function offerKeybindingFix(ctx: ExtensionContext): Promise<void> {
170
181
  ctx.ui.notify(CREATED_NOTIFY, "info");
171
182
  try {
172
183
  await reload();
184
+ // The reload completed: the reloaded session runs the flow again (where
185
+ // the offer is a no-op — keybindings.json now exists), so report that a
186
+ // reload was triggered and the caller can skip the onboarding on this
187
+ // now-stale ctx.
188
+ return true;
173
189
  } catch {
174
190
  // Swallowed on purpose: the TUI shows its own "Reload failed" status;
175
- // the offer never throws.
191
+ // the offer never throws. The reload FAILED, so the session continues on
192
+ // the still-valid ctx → report `false` (the onboarding still runs).
193
+ return false;
176
194
  }
177
- } else {
178
- ctx.ui.notify(CREATED_RELOAD_HINT, "info");
179
195
  }
196
+ ctx.ui.notify(CREATED_RELOAD_HINT, "info");
197
+ return false;
180
198
  }