@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 +1 -1
- package/package.json +2 -2
- package/src/keybinding-offer.test.ts +96 -45
- package/src/keybinding-offer.ts +32 -17
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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] ===
|
|
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] ===
|
|
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
|
-
// ──
|
|
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("
|
|
332
|
-
it("yes
|
|
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
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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(
|
|
518
|
+
callArgs(notify).some(
|
|
519
|
+
(c) => c[0] === CREATED_NOTIFY || c[0] === CREATED_RELOAD_HINT,
|
|
520
|
+
),
|
|
470
521
|
).toBe(false);
|
|
471
522
|
});
|
|
472
523
|
});
|
package/src/keybinding-offer.ts
CHANGED
|
@@ -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
|
|
63
|
-
* re-reads keybindings.json and
|
|
64
|
-
*
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
//
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
// it
|
|
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,
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
}
|