@pi-archimedes/image-paste 2.7.1 → 2.7.2

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
@@ -43,7 +43,7 @@ New to Pi? Pi itself is a one-time global install and needs Node.js ≥ 22.19.0.
43
43
  > { "app.clipboard.pasteImage": [] }
44
44
  > ```
45
45
  >
46
- > **First-run offer (once ever, all platforms)** — when installed via the suite, on the first TUI session with image-paste enabled, 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 the TUI reloads automatically (the cleared binding applies immediately; if the reload fails, run `/reload`), declining or cancelling (Esc) never asks again — re-open by deleting `archimedes.imagePaste.keybindingsPromptDone` from `~/.pi/agent/settings.json`.
46
+ > **First-run offer (once ever, all platforms)** — when installed via the suite, on the first TUI session with image-paste enabled, 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 attempts an automatic reload where the runtime supports it (on current Pi 0.85.1 you're notified to run `/reload`, and the cleared binding applies once it runs), declining or cancelling (Esc) never asks again — re-open by deleting `archimedes.imagePaste.keybindingsPromptDone` from `~/.pi/agent/settings.json`.
47
47
 
48
48
  ## Per-platform requirements
49
49
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-archimedes/image-paste",
3
- "version": "2.7.1",
3
+ "version": "2.7.2",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/danielcherubini/pi-archimedes.git"
@@ -19,7 +19,7 @@
19
19
  "./keybinding-offer": "./src/keybinding-offer.ts"
20
20
  },
21
21
  "dependencies": {
22
- "@pi-archimedes/core": "2.7.1"
22
+ "@pi-archimedes/core": "2.7.2"
23
23
  },
24
24
  "peerDependencies": {
25
25
  "@earendil-works/pi-coding-agent": ">=0.1.0",
@@ -43,7 +43,7 @@ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
43
43
  // re-exported from the package root.
44
44
  type Mode = "tui" | "rpc" | "json" | "print";
45
45
 
46
- const { offerKeybindingFix, CREATED_NOTIFY } = await import("./keybinding-offer.js");
46
+ const { offerKeybindingFix, CREATED_NOTIFY, CREATED_RELOAD_HINT } = await import("./keybinding-offer.js");
47
47
 
48
48
  // ── fixtures ─────────────────────────────────────────────────────────────────
49
49
 
@@ -86,15 +86,21 @@ function cleanupArtifacts(): void {
86
86
  }
87
87
  }
88
88
 
89
- function makeCtx(mode: Mode) {
89
+ function makeCtx(mode: Mode, opts?: { withReload?: boolean }) {
90
90
  const confirm = vi.fn();
91
91
  const notify = vi.fn();
92
- const reload = vi.fn(async () => {});
92
+ // Intentionally mirrors the base ExtensionContext the real session_start
93
+ // handler receives: no command-only methods like `reload` (Pi 0.85.1
94
+ // attaches reload to the command context only), so tests stay honest about
95
+ // what the real ctx provides. The auto-reload branch is covered separately
96
+ // by passing { withReload: true }, which simulates a future Pi version
97
+ // that puts reload() on the event ctx.
98
+ const reload = opts?.withReload ? vi.fn(async () => {}) : undefined;
93
99
  const ctx = {
94
100
  mode,
95
101
  hasUI: true,
96
102
  ui: { confirm, notify },
97
- reload,
103
+ ...(reload ? { reload } : {}),
98
104
  } as unknown as ExtensionContext;
99
105
  return { ctx, confirm, notify, reload };
100
106
  }
@@ -219,9 +225,10 @@ describe("offerKeybindingFix — gate matrix", () => {
219
225
 
220
226
  // ── notifications ──
221
227
  if (shouldAsk && outcome === "yes") {
228
+ // Base-shape ctx has no reload, so the offer emits the /reload hint
222
229
  expect(
223
230
  callArgs(notify).some(
224
- (c) => c[0] === CREATED_NOTIFY && c[1] === "info",
231
+ (c) => c[0] === CREATED_RELOAD_HINT && c[1] === "info",
225
232
  ),
226
233
  ).toBe(true);
227
234
  } else {
@@ -320,22 +327,62 @@ describe("flag write throws → notify, no crash (file write already succeeded)"
320
327
 
321
328
  const args = callArgs(notify);
322
329
  expect(
323
- args.some((c) => c[0] === CREATED_NOTIFY && c[1] === "info"),
324
- ).toBe(true); // the file WAS created
330
+ args.some((c) => c[0] === CREATED_RELOAD_HINT && c[1] === "info"),
331
+ ).toBe(true); // the file WAS created (base-shape ctx → /reload hint)
325
332
  expect(args.some((c) => c[1] === "warning" && String(c[0]).length > 0)).toBe(true); // flag save error: non-empty warning message
326
333
  });
327
334
  });
328
335
 
329
- // ── auto-reload on accept ────────────────────────────────────────────────────
336
+ // ── accept with base-shape ctx (no reload — Pi 0.85.1 event ctx) ──────────
337
+ // The real session_start ctx has no reload property (Pi 0.85.1 attaches it
338
+ // to the command context only), so the offer must fall back to a /reload
339
+ // hint instead of claiming it reloaded.
330
340
 
331
- describe("auto-reload on accept", () => {
332
- it("yes path: ctx.reload() called exactly once after flag is persisted and CREATED_NOTIFY emitted", async () => {
341
+ describe("accept with base-shape ctx (no reload)", () => {
342
+ it("yes: fallback /reload hint notified, file written, flag persisted, no throw", async () => {
343
+ const { ctx, confirm, notify } = makeCtx("tui");
344
+ confirm.mockResolvedValue(true);
345
+
346
+ await expect(offerKeybindingFix(ctx)).resolves.toBeUndefined();
347
+
348
+ // File written with the exact snippet
349
+ expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(SNIPPET);
350
+ // Flag persisted
351
+ expect(readFlag()).toBe(true);
352
+ // The truthful fallback message — no "reloading now" claim
353
+ expect(
354
+ callArgs(notify).some(
355
+ (c) => c[0] === CREATED_RELOAD_HINT && c[1] === "info",
356
+ ),
357
+ ).toBe(true);
358
+ expect(callArgs(notify).some((c) => c[0] === CREATED_NOTIFY)).toBe(false);
359
+ });
360
+
361
+ it("no path: no created notification at all", async () => {
362
+ const { ctx, confirm, notify } = makeCtx("tui");
363
+ confirm.mockResolvedValue(false);
364
+ await offerKeybindingFix(ctx);
365
+ expect(
366
+ callArgs(notify).some(
367
+ (c) => c[0] === CREATED_NOTIFY || c[0] === CREATED_RELOAD_HINT,
368
+ ),
369
+ ).toBe(false);
370
+ });
371
+ });
372
+
373
+ // ── auto-reload branch: simulates a future Pi version that puts ────────────
374
+ // reload() on the event ctx. A reload-carrying ctx (makeCtx with
375
+ // { withReload: true }) covers that branch; the base-shape tests above
376
+ // cover current Pi.
377
+
378
+ describe("auto-reload branch (future Pi with reload on the event ctx)", () => {
379
+ it("yes path: reload called exactly once after flag is persisted and CREATED_NOTIFY emitted", async () => {
333
380
  // Track invocation order so we can assert flag is persisted before reload.
334
381
  const order: string[] = [];
335
- const { ctx, confirm, notify, reload } = makeCtx("tui");
382
+ const { ctx, confirm, notify, reload } = makeCtx("tui", { withReload: true });
336
383
  confirm.mockResolvedValue(true);
337
384
  // Wrap the reload spy to record when it's called relative to flag write.
338
- reload.mockImplementation(async () => {
385
+ reload!.mockImplementation(async () => {
339
386
  // At this point the flag must already be persisted to disk.
340
387
  order.push(readFlag() ? "flag-then-reload" : "reload-before-flag");
341
388
  });
@@ -361,33 +408,56 @@ describe("auto-reload on accept", () => {
361
408
  expect(CREATED_NOTIFY).toContain("reloading now");
362
409
  });
363
410
 
364
- it("no path: ctx.reload() NOT called", async () => {
365
- const { ctx, confirm, reload } = makeCtx("tui");
411
+ it("no path: reload NOT called", async () => {
412
+ const { ctx, confirm, reload } = makeCtx("tui", { withReload: true });
366
413
  confirm.mockResolvedValue(false);
367
414
  await offerKeybindingFix(ctx);
368
415
  expect(reload).not.toHaveBeenCalled();
369
416
  });
370
417
 
371
- it("cancel (resolves false) path: ctx.reload() NOT called", async () => {
372
- const { ctx, confirm, reload } = makeCtx("tui");
418
+ it("cancel (resolves false) path: reload NOT called", async () => {
419
+ const { ctx, confirm, reload } = makeCtx("tui", { withReload: true });
373
420
  confirm.mockResolvedValue(false); // Esc/timeout: same as no
374
421
  await offerKeybindingFix(ctx);
375
422
  expect(reload).not.toHaveBeenCalled();
376
423
  });
377
424
 
378
- it("file-write-fails path: ctx.reload() NOT called", async () => {
425
+ it("file-write-fails path: reload NOT called", async () => {
379
426
  // Block the file write by making the tmp path a directory
380
427
  fs.mkdirSync(tmpPath(), { recursive: true });
381
- const { ctx, confirm, reload } = makeCtx("tui");
428
+ const { ctx, confirm, reload } = makeCtx("tui", { withReload: true });
429
+ confirm.mockResolvedValue(true);
430
+ await offerKeybindingFix(ctx);
431
+ expect(reload).not.toHaveBeenCalled();
432
+ });
433
+
434
+ it("TOCTOU path: reload NOT called (file appeared before the rename)", async () => {
435
+ // Same spy materialization as the concurrent-creation test: the file
436
+ // appears between gate 4 and the pre-rename re-check, so the offer ends
437
+ // before the reload step is ever reached.
438
+ let kbHits = 0;
439
+ existsSyncSpy.mockImplementation((p: unknown) => {
440
+ const s = String(p);
441
+ if (s === keybindingsPath()) {
442
+ kbHits += 1;
443
+ if (kbHits >= 2) {
444
+ if (!fs.existsSync(s)) fs.writeFileSync(s, CONCURRENT_CONTENT, "utf-8");
445
+ return true;
446
+ }
447
+ }
448
+ return fs.existsSync(s);
449
+ });
450
+
451
+ const { ctx, confirm, reload } = makeCtx("tui", { withReload: true });
382
452
  confirm.mockResolvedValue(true);
383
453
  await offerKeybindingFix(ctx);
384
454
  expect(reload).not.toHaveBeenCalled();
385
455
  });
386
456
 
387
457
  it("reload rejects: offer still resolves, flag persisted, file present, CREATED_NOTIFY emitted", async () => {
388
- const { ctx, confirm, notify, reload } = makeCtx("tui");
458
+ const { ctx, confirm, notify, reload } = makeCtx("tui", { withReload: true });
389
459
  confirm.mockResolvedValue(true);
390
- reload.mockRejectedValue(new Error("Reload failed"));
460
+ reload!.mockRejectedValue(new Error("Reload failed"));
391
461
 
392
462
  await expect(offerKeybindingFix(ctx)).resolves.toBeUndefined();
393
463
 
@@ -406,29 +476,6 @@ describe("auto-reload on accept", () => {
406
476
  });
407
477
  });
408
478
 
409
- describe("TOCTOU path: ctx.reload() NOT called", () => {
410
- it("file created between gate 4 and pre-rename re-check: reload NOT called", async () => {
411
- let kbHits = 0;
412
- existsSyncSpy.mockImplementation((p: unknown) => {
413
- const s = String(p);
414
- if (s === keybindingsPath()) {
415
- kbHits += 1;
416
- if (kbHits >= 2) {
417
- if (!fs.existsSync(s)) fs.writeFileSync(s, CONCURRENT_CONTENT, "utf-8");
418
- return true;
419
- }
420
- }
421
- return fs.existsSync(s);
422
- });
423
-
424
- const { ctx, confirm, reload } = makeCtx("tui");
425
- confirm.mockResolvedValue(true);
426
- await offerKeybindingFix(ctx);
427
-
428
- expect(reload).not.toHaveBeenCalled();
429
- });
430
- });
431
-
432
479
  describe("concurrent creation is never clobbered", () => {
433
480
  it("file created between gate 4 and the pre-rename re-check: left intact, flag set", async () => {
434
481
  // Simulate: the first keybindings.json check (gate 4) sees nothing; the
@@ -464,9 +511,13 @@ describe("concurrent creation is never clobbered", () => {
464
511
  expect(fs.existsSync(tmpPath())).toBe(false);
465
512
  // The offer ends: the offer was made and the fix is in force (file exists)
466
513
  expect(readFlag()).toBe(true);
467
- // We did not create the file, so we do not claim to
514
+ // We did not create the file, so we do not claim to — and the offer
515
+ // ends before any reload step (no "reloading now" / /reload-hint
516
+ // notification either)
468
517
  expect(
469
- callArgs(notify).some((c) => c[0] === CREATED_NOTIFY),
518
+ callArgs(notify).some(
519
+ (c) => c[0] === CREATED_NOTIFY || c[0] === CREATED_RELOAD_HINT,
520
+ ),
470
521
  ).toBe(false);
471
522
  });
472
523
  });
@@ -19,6 +19,9 @@ const CONFIRM_MESSAGE =
19
19
  const SNIPPET_JSON = '{ "app.clipboard.pasteImage": [] }';
20
20
 
21
21
  export const CREATED_NOTIFY = "Created ~/.pi/agent/keybindings.json — reloading now";
22
+ /** Fallback wording for runtimes whose event ctx has no reload (Pi 0.85.1). */
23
+ export const CREATED_RELOAD_HINT =
24
+ "Created ~/.pi/agent/keybindings.json — run /reload to apply";
22
25
 
23
26
  interface PromptConfig {
24
27
  keybindingsPromptDone: boolean;
@@ -59,9 +62,11 @@ function markPromptDone(ctx: ExtensionContext): void {
59
62
  * offer to create it with the docs snippet so Pi's built-in clipboard paste
60
63
  * doesn't double-fire with image-paste's Ctrl+V handler. Accepting writes the
61
64
  * file (atomic tmp+rename, pre-rename existence re-check) and then sets the
62
- * one-shot flag and reloads the TUI (`ctx.reload()` — the exact /reload flow:
63
- * re-reads keybindings.json and re-binds shortcuts) so the cleared built-in
64
- * binding applies immediately. Declining or cancelling (Esc/timeout — `confirm`
65
+ * one-shot flag and, when the runtime exposes `reload()` on the session ctx,
66
+ * reloads the TUI (the exact /reload flow: re-reads keybindings.json and
67
+ * re-binds shortcuts) so the cleared built-in binding applies immediately. On
68
+ * Pi 0.85.1 the event ctx has no `reload`, so the user is notified to run
69
+ * `/reload` instead. Declining or cancelling (Esc/timeout — `confirm`
65
70
  * resolves `false` either way) sets the flag without touching the file. A
66
71
  * failed file write leaves the flag unset, so the offer self-heals next session.
67
72
  *
@@ -146,20 +151,30 @@ export async function offerKeybindingFix(ctx: ExtensionContext): Promise<void> {
146
151
  // File written successfully → THEN set the flag (deliberately after the
147
152
  // write, per the design).
148
153
  markPromptDone(ctx);
149
- ctx.ui.notify(CREATED_NOTIFY, "info");
150
-
151
- // Auto-apply: ctx.reload() runs the exact /reload TUI flow (re-reads
152
- // keybindings.json + re-binds extension shortcuts). Must be the LAST
153
- // use of ctx — the reload invalidates this extension instance. If the
154
- // reload itself fails, the TUI shows its own "Reload failed" status;
155
- // the file + flag are already persisted, so a manual /reload heals
156
- // it and the offer is not repeated (flag gate).
154
+
155
+ // Auto-apply: when the runtime exposes `reload()` on this ctx, run the
156
+ // exact /reload TUI flow (re-reads keybindings.json + re-binds extension
157
+ // shortcuts) so the cleared built-in binding applies immediately. Pi
158
+ // 0.85.1 attaches `reload()` ONLY to the command context — the base
159
+ // context that event handlers (like this session_start handler) receive
160
+ // has no `reload` property, so auto-reload activates only on Pi versions
161
+ // that add it to the event ctx; otherwise the user is told to run
162
+ // /reload. reload() must be the LAST use of ctx — the reload invalidates
163
+ // this extension instance. If the reload itself fails, the TUI shows its
164
+ // own "Reload failed" status; the file + flag are already persisted, so a
165
+ // manual /reload heals it and the offer is not repeated (flag gate).
157
166
  // Cast: reload() is typed on ExtensionCommandContext; the session_start
158
- // handler receives the base ExtensionContext, but at TUI runtime the
159
- // reload action is always present (verified: runner.js:611).
160
- try {
161
- await (ctx as ExtensionCommandContext).reload();
162
- } catch {
163
- // Swallowed on purpose: see comment above. The offer never throws.
167
+ // handler receives the base ExtensionContext, hence the presence guard.
168
+ const reload = (ctx as ExtensionCommandContext).reload;
169
+ if (typeof reload === "function") {
170
+ ctx.ui.notify(CREATED_NOTIFY, "info");
171
+ try {
172
+ await reload();
173
+ } catch {
174
+ // Swallowed on purpose: the TUI shows its own "Reload failed" status;
175
+ // the offer never throws.
176
+ }
177
+ } else {
178
+ ctx.ui.notify(CREATED_RELOAD_HINT, "info");
164
179
  }
165
180
  }