@pi-archimedes/image-paste 2.6.3 → 2.7.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 +13 -0
- package/package.json +12 -1
- package/src/keybinding-offer.test.ts +472 -0
- package/src/keybinding-offer.ts +165 -0
package/README.md
CHANGED
|
@@ -42,6 +42,8 @@ New to Pi? Pi itself is a one-time global install and needs Node.js ≥ 22.19.0.
|
|
|
42
42
|
> ```json
|
|
43
43
|
> { "app.clipboard.pasteImage": [] }
|
|
44
44
|
> ```
|
|
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`.
|
|
45
47
|
|
|
46
48
|
## Per-platform requirements
|
|
47
49
|
|
|
@@ -49,6 +51,17 @@ New to Pi? Pi itself is a one-time global install and needs Node.js ≥ 22.19.0.
|
|
|
49
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`.
|
|
50
52
|
- **Windows** — the `@mariozechner/clipboard` native module first, with a PowerShell fallback.
|
|
51
53
|
|
|
54
|
+
## Settings
|
|
55
|
+
|
|
56
|
+
`~/.pi/agent/settings.json`, under `archimedes.imagePaste` (strict JSON):
|
|
57
|
+
|
|
58
|
+
| Setting | Type | Default | Description |
|
|
59
|
+
|---------|------|---------|-------------|
|
|
60
|
+
| `enabled` | bool | `true` | On/off for the extension, via `/plugins` (suite-managed) |
|
|
61
|
+
| `keybindingsPromptDone` | bool | `false` | Set true once the first-run keybindings.json offer has been answered (accept or decline); delete to re-open the offer |
|
|
62
|
+
|
|
63
|
+
In the suite on/off is managed by the suite: toggle via `/plugins` (`archimedes.imagePaste.enabled`, default on).
|
|
64
|
+
|
|
52
65
|
## Part of the suite
|
|
53
66
|
|
|
54
67
|
In [pi-archimedes](https://github.com/danielcherubini/pi-archimedes), image-paste works alongside the framed editor, the status bar, and the todo board; on/off is managed by the suite (`/plugins`, `archimedes.imagePaste.enabled`, default on).
|
package/package.json
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-archimedes/image-paste",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.1",
|
|
4
|
+
"repository": {
|
|
5
|
+
"type": "git",
|
|
6
|
+
"url": "https://github.com/danielcherubini/pi-archimedes.git"
|
|
7
|
+
},
|
|
4
8
|
"type": "module",
|
|
5
9
|
"keywords": [
|
|
6
10
|
"pi-package"
|
|
@@ -10,6 +14,13 @@
|
|
|
10
14
|
"src"
|
|
11
15
|
],
|
|
12
16
|
"main": "./src/index.ts",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": "./src/index.ts",
|
|
19
|
+
"./keybinding-offer": "./src/keybinding-offer.ts"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@pi-archimedes/core": "2.7.1"
|
|
23
|
+
},
|
|
13
24
|
"peerDependencies": {
|
|
14
25
|
"@earendil-works/pi-coding-agent": ">=0.1.0",
|
|
15
26
|
"@earendil-works/pi-tui": ">=0.1.0"
|
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterAll,
|
|
3
|
+
beforeEach,
|
|
4
|
+
describe,
|
|
5
|
+
expect,
|
|
6
|
+
it,
|
|
7
|
+
vi,
|
|
8
|
+
} from "vitest";
|
|
9
|
+
|
|
10
|
+
// ── hoisted: must use require() since vi.hoisted runs before imports ────────
|
|
11
|
+
|
|
12
|
+
const { tempDir, fs, join, existsSyncSpy } = vi.hoisted(() => {
|
|
13
|
+
const fs = require("node:fs");
|
|
14
|
+
const { join } = require("node:path");
|
|
15
|
+
const { tmpdir } = require("node:os");
|
|
16
|
+
const { randomUUID } = require("node:crypto");
|
|
17
|
+
const dir = join(tmpdir(), `keybinding-offer-test-${randomUUID()}`);
|
|
18
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
19
|
+
// Pass-through by default; specific tests re-target it (the concurrent-file
|
|
20
|
+
// case needs keybindings.json to appear between gate 4 and the pre-rename
|
|
21
|
+
// re-check).
|
|
22
|
+
const existsSyncSpy = vi.fn((p: unknown) => fs.existsSync(String(p)));
|
|
23
|
+
return { tempDir: dir, fs, join, existsSyncSpy };
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// ── mocks: registered before the module under test is first imported ─────────
|
|
27
|
+
// The mock MUST be registered before keybinding-offer.js (and the settings-io
|
|
28
|
+
// it pulls in) is loaded: module-scope path constants capture getAgentDir() at
|
|
29
|
+
// load time (settings-io.ts:5).
|
|
30
|
+
|
|
31
|
+
vi.mock("@earendil-works/pi-coding-agent", () => ({
|
|
32
|
+
getAgentDir: () => tempDir,
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
vi.mock("node:fs", async (importOriginal) => {
|
|
36
|
+
const actual = await importOriginal<typeof import("node:fs")>();
|
|
37
|
+
return { ...actual, existsSync: existsSyncSpy };
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
41
|
+
|
|
42
|
+
// Identical to the package's ExtensionMode (dist types:208-209), which is not
|
|
43
|
+
// re-exported from the package root.
|
|
44
|
+
type Mode = "tui" | "rpc" | "json" | "print";
|
|
45
|
+
|
|
46
|
+
const { offerKeybindingFix, CREATED_NOTIFY } = await import("./keybinding-offer.js");
|
|
47
|
+
|
|
48
|
+
// ── fixtures ─────────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
const NAMESPACE = "archimedes.imagePaste";
|
|
51
|
+
/** The exact snippet from packages/image-paste/README.md → "Paste shortcuts". */
|
|
52
|
+
const SNIPPET = '{ "app.clipboard.pasteImage": [] }';
|
|
53
|
+
/** What a concurrent process writes into keybindings.json at rename time. */
|
|
54
|
+
const CONCURRENT_CONTENT = '{ "app.clipboard.pasteImage": ["ctrl+v"] }';
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
const settingsPath = (): string => join(tempDir, "settings.json");
|
|
58
|
+
const keybindingsPath = (): string => join(tempDir, "keybindings.json");
|
|
59
|
+
const tmpPath = (): string => `${keybindingsPath()}.${process.pid}.tmp`;
|
|
60
|
+
|
|
61
|
+
function writeSettings(obj: object): void {
|
|
62
|
+
fs.writeFileSync(settingsPath(), JSON.stringify(obj), "utf-8");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function readSettings(): Record<string, unknown> {
|
|
66
|
+
if (!fs.existsSync(settingsPath())) return {};
|
|
67
|
+
try {
|
|
68
|
+
return JSON.parse(fs.readFileSync(settingsPath(), "utf-8")) as Record<string, unknown>;
|
|
69
|
+
} catch {
|
|
70
|
+
return {};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function readFlag(): boolean {
|
|
75
|
+
const ns = readSettings()[NAMESPACE] as Record<string, unknown> | undefined;
|
|
76
|
+
return ns?.keybindingsPromptDone === true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function cleanupArtifacts(): void {
|
|
80
|
+
for (const p of [settingsPath(), settingsPath() + ".tmp", keybindingsPath(), tmpPath()]) {
|
|
81
|
+
try {
|
|
82
|
+
fs.rmSync(p, { recursive: true, force: true });
|
|
83
|
+
} catch {
|
|
84
|
+
// ignore
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function makeCtx(mode: Mode) {
|
|
90
|
+
const confirm = vi.fn();
|
|
91
|
+
const notify = vi.fn();
|
|
92
|
+
const reload = vi.fn(async () => {});
|
|
93
|
+
const ctx = {
|
|
94
|
+
mode,
|
|
95
|
+
hasUI: true,
|
|
96
|
+
ui: { confirm, notify },
|
|
97
|
+
reload,
|
|
98
|
+
} as unknown as ExtensionContext;
|
|
99
|
+
return { ctx, confirm, notify, reload };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function callArgs(m: ReturnType<typeof vi.fn>): unknown[][] {
|
|
103
|
+
return m.mock.calls as unknown[][];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
beforeEach(() => {
|
|
107
|
+
cleanupArtifacts();
|
|
108
|
+
existsSyncSpy.mockReset();
|
|
109
|
+
existsSyncSpy.mockImplementation((p: unknown) => fs.existsSync(String(p)));
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
afterAll(() => {
|
|
113
|
+
try {
|
|
114
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
115
|
+
} catch {
|
|
116
|
+
// ignore
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// ── gate matrix ──────────────────────────────────────────────────────────────
|
|
121
|
+
// isConfigEnabled × {t,f} × ctx.mode ∈ {tui, rpc, json, print} × file {absent,
|
|
122
|
+
// present} × flag {unset, set} × confirm outcome {yes, no, cancel-resolves-false}
|
|
123
|
+
// (Esc/timeout: `confirm` resolves `false` either way → counts as decline).
|
|
124
|
+
|
|
125
|
+
interface Case {
|
|
126
|
+
enabled: boolean;
|
|
127
|
+
mode: Mode;
|
|
128
|
+
file: "absent" | "present";
|
|
129
|
+
flag: "unset" | "set";
|
|
130
|
+
outcome: "yes" | "no" | "cancel-resolves-false";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const CASES: Case[] = (() => {
|
|
134
|
+
const cases: Case[] = [];
|
|
135
|
+
for (const enabled of [false, true]) {
|
|
136
|
+
for (const mode of ["tui", "rpc", "json", "print"] as Mode[]) {
|
|
137
|
+
for (const file of ["absent", "present"] as const) {
|
|
138
|
+
for (const flag of ["unset", "set"] as const) {
|
|
139
|
+
for (const outcome of ["yes", "no", "cancel-resolves-false"] as const) {
|
|
140
|
+
cases.push({ enabled, mode, file, flag, outcome });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return cases;
|
|
147
|
+
})();
|
|
148
|
+
|
|
149
|
+
describe("offerKeybindingFix — gate matrix", () => {
|
|
150
|
+
it.each(CASES)(
|
|
151
|
+
"$enabled/$mode/$file/$flag → $outcome",
|
|
152
|
+
async ({ enabled, mode, file, flag, outcome }) => {
|
|
153
|
+
// ── setup ──
|
|
154
|
+
const ns: Record<string, unknown> = {};
|
|
155
|
+
if (enabled === false) ns.enabled = false;
|
|
156
|
+
if (flag === "set") ns.keybindingsPromptDone = true;
|
|
157
|
+
const settingsBefore = { ...ns };
|
|
158
|
+
if (Object.keys(settingsBefore).length > 0) {
|
|
159
|
+
writeSettings({ [NAMESPACE]: settingsBefore });
|
|
160
|
+
}
|
|
161
|
+
const settingsBeforeJson = readSettings();
|
|
162
|
+
|
|
163
|
+
if (file === "present") {
|
|
164
|
+
fs.writeFileSync(keybindingsPath(), CONCURRENT_CONTENT, "utf-8");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const { ctx, confirm, notify } = makeCtx(mode);
|
|
168
|
+
if (outcome === "yes") confirm.mockResolvedValue(true);
|
|
169
|
+
else confirm.mockResolvedValue(false); // no + cancel: confirm resolves false
|
|
170
|
+
|
|
171
|
+
await offerKeybindingFix(ctx);
|
|
172
|
+
|
|
173
|
+
const shouldAsk =
|
|
174
|
+
enabled === true &&
|
|
175
|
+
mode === "tui" &&
|
|
176
|
+
flag === "unset" &&
|
|
177
|
+
file === "absent";
|
|
178
|
+
|
|
179
|
+
// ── gate behavior ──
|
|
180
|
+
expect(confirm).toHaveBeenCalledTimes(shouldAsk ? 1 : 0);
|
|
181
|
+
// never fires when the plugin is disabled
|
|
182
|
+
if (enabled === false) expect(confirm).not.toHaveBeenCalled();
|
|
183
|
+
// never fires when the file already exists
|
|
184
|
+
if (file === "present") expect(confirm).not.toHaveBeenCalled();
|
|
185
|
+
// never fires when the flag is already consumed
|
|
186
|
+
if (flag === "set") expect(confirm).not.toHaveBeenCalled();
|
|
187
|
+
// never fires in non-TUI modes
|
|
188
|
+
if (mode !== "tui") expect(confirm).not.toHaveBeenCalled();
|
|
189
|
+
|
|
190
|
+
if (shouldAsk) {
|
|
191
|
+
// confirm is called with the TITLE FIRST (docs/extensions.md:165)
|
|
192
|
+
const [title, message] = callArgs(confirm)[0] as [string, string];
|
|
193
|
+
expect(title).toBe("First run");
|
|
194
|
+
expect(typeof message).toBe("string");
|
|
195
|
+
expect(message).toContain("keybindings.json");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── file effects ──
|
|
199
|
+
const kbExists = fs.existsSync(keybindingsPath());
|
|
200
|
+
if (shouldAsk && outcome === "yes") {
|
|
201
|
+
// yes → file written with the exact snippet content
|
|
202
|
+
expect(kbExists).toBe(true);
|
|
203
|
+
expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(SNIPPET);
|
|
204
|
+
} else {
|
|
205
|
+
// never written when the offer never fires; the pre-existing file is never
|
|
206
|
+
// clobbered
|
|
207
|
+
expect(kbExists).toBe(file === "present");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ── flag effects ──
|
|
211
|
+
if (shouldAsk) {
|
|
212
|
+
// offer fires once and sets the flag on every outcome (yes, no, cancel → decline)
|
|
213
|
+
expect(readFlag()).toBe(true);
|
|
214
|
+
} else {
|
|
215
|
+
// non-TUI / non-offer rows never consume the flag — settings byte-identical
|
|
216
|
+
expect(readSettings()).toEqual(settingsBeforeJson);
|
|
217
|
+
expect(readFlag()).toBe(flag === "set");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ── notifications ──
|
|
221
|
+
if (shouldAsk && outcome === "yes") {
|
|
222
|
+
expect(
|
|
223
|
+
callArgs(notify).some(
|
|
224
|
+
(c) => c[0] === CREATED_NOTIFY && c[1] === "info",
|
|
225
|
+
),
|
|
226
|
+
).toBe(true);
|
|
227
|
+
} else {
|
|
228
|
+
expect(notify).not.toHaveBeenCalled();
|
|
229
|
+
}
|
|
230
|
+
},
|
|
231
|
+
);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
// ── flag persistence regression: load-modify-save preserves other keys ──────
|
|
235
|
+
// saveConfig REPLACES the namespace object (settings-io.ts:29-33), so a bare
|
|
236
|
+
// saveConfig(ns, { keybindingsPromptDone: true }) would erase other keys under
|
|
237
|
+
// archimedes.imagePaste.
|
|
238
|
+
|
|
239
|
+
describe("flag write preserves existing settings", () => {
|
|
240
|
+
async function run(outcome: "yes" | "no") {
|
|
241
|
+
writeSettings({
|
|
242
|
+
[NAMESPACE]: { enabled: true, someOtherKey: 42, nested: { a: 1 } },
|
|
243
|
+
"other.ns": { z: 1 },
|
|
244
|
+
});
|
|
245
|
+
const { ctx, confirm } = makeCtx("tui");
|
|
246
|
+
confirm.mockResolvedValue(outcome === "yes");
|
|
247
|
+
await offerKeybindingFix(ctx);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
it("yes: snippet file written; other archimedes.imagePaste keys survive the flag set", async () => {
|
|
251
|
+
await run("yes");
|
|
252
|
+
expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(SNIPPET);
|
|
253
|
+
const data = readSettings();
|
|
254
|
+
const ns = data[NAMESPACE] as Record<string, unknown>;
|
|
255
|
+
expect(ns).toEqual({
|
|
256
|
+
enabled: true,
|
|
257
|
+
someOtherKey: 42,
|
|
258
|
+
nested: { a: 1 },
|
|
259
|
+
keybindingsPromptDone: true,
|
|
260
|
+
});
|
|
261
|
+
expect(data["other.ns"]).toEqual({ z: 1 });
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("no: decline sets the flag via load-modify-save; keys survive, file untouched", async () => {
|
|
265
|
+
await run("no");
|
|
266
|
+
expect(fs.existsSync(keybindingsPath())).toBe(false);
|
|
267
|
+
const ns = readSettings()[NAMESPACE] as Record<string, unknown>;
|
|
268
|
+
expect(ns).toEqual({
|
|
269
|
+
enabled: true,
|
|
270
|
+
someOtherKey: 42,
|
|
271
|
+
nested: { a: 1 },
|
|
272
|
+
keybindingsPromptDone: true,
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// ── error paths ──────────────────────────────────────────────────────────────
|
|
278
|
+
|
|
279
|
+
describe("file write throws → flag stays false (self-heals next session)", () => {
|
|
280
|
+
it("yes with an unwritable tmp target: no file, flag unset, error notified, no throw", async () => {
|
|
281
|
+
// Make the (pid-predictable) tmp path unwritable: a directory.
|
|
282
|
+
fs.mkdirSync(tmpPath(), { recursive: true });
|
|
283
|
+
|
|
284
|
+
const { ctx, confirm, notify } = makeCtx("tui");
|
|
285
|
+
confirm.mockResolvedValue(true);
|
|
286
|
+
|
|
287
|
+
await expect(offerKeybindingFix(ctx)).resolves.toBeUndefined();
|
|
288
|
+
|
|
289
|
+
expect(confirm).toHaveBeenCalledTimes(1);
|
|
290
|
+
expect(fs.existsSync(keybindingsPath())).toBe(false);
|
|
291
|
+
expect(readFlag()).toBe(false);
|
|
292
|
+
expect(
|
|
293
|
+
readSettings()[NAMESPACE] ?? {},
|
|
294
|
+
).toEqual({}); // nothing persisted to settings
|
|
295
|
+
|
|
296
|
+
expect(notify).toHaveBeenCalledTimes(1);
|
|
297
|
+
const [msg, type] = notify.mock.calls[0] as [string, string];
|
|
298
|
+
expect(type).toBe("warning");
|
|
299
|
+
expect(msg.length).toBeGreaterThan(0);
|
|
300
|
+
});
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
describe("flag write throws → notify, no crash (file write already succeeded)", () => {
|
|
304
|
+
it("yes with an unwritable settings target: file kept, flag unset, both notifies, no throw", async () => {
|
|
305
|
+
// Make settings.json an unwritable target: a directory (rename + direct
|
|
306
|
+
// write onto it both fail with EISDIR).
|
|
307
|
+
fs.mkdirSync(settingsPath(), { recursive: true });
|
|
308
|
+
|
|
309
|
+
const { ctx, confirm, notify } = makeCtx("tui");
|
|
310
|
+
confirm.mockResolvedValue(true);
|
|
311
|
+
|
|
312
|
+
await expect(offerKeybindingFix(ctx)).resolves.toBeUndefined();
|
|
313
|
+
|
|
314
|
+
// The file write strictly precedes the flag set and survived:
|
|
315
|
+
expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(SNIPPET);
|
|
316
|
+
// Flag persistence failed: settings.json is still the directory, no flag
|
|
317
|
+
expect(fs.statSync(settingsPath()).isDirectory()).toBe(true);
|
|
318
|
+
expect(readFlag()).toBe(false);
|
|
319
|
+
// Gate 4 blocks the re-offer anyway (file now exists) — by design
|
|
320
|
+
|
|
321
|
+
const args = callArgs(notify);
|
|
322
|
+
expect(
|
|
323
|
+
args.some((c) => c[0] === CREATED_NOTIFY && c[1] === "info"),
|
|
324
|
+
).toBe(true); // the file WAS created
|
|
325
|
+
expect(args.some((c) => c[1] === "warning" && String(c[0]).length > 0)).toBe(true); // flag save error: non-empty warning message
|
|
326
|
+
});
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
// ── auto-reload on accept ────────────────────────────────────────────────────
|
|
330
|
+
|
|
331
|
+
describe("auto-reload on accept", () => {
|
|
332
|
+
it("yes path: ctx.reload() called exactly once after flag is persisted and CREATED_NOTIFY emitted", async () => {
|
|
333
|
+
// Track invocation order so we can assert flag is persisted before reload.
|
|
334
|
+
const order: string[] = [];
|
|
335
|
+
const { ctx, confirm, notify, reload } = makeCtx("tui");
|
|
336
|
+
confirm.mockResolvedValue(true);
|
|
337
|
+
// Wrap the reload spy to record when it's called relative to flag write.
|
|
338
|
+
reload.mockImplementation(async () => {
|
|
339
|
+
// At this point the flag must already be persisted to disk.
|
|
340
|
+
order.push(readFlag() ? "flag-then-reload" : "reload-before-flag");
|
|
341
|
+
});
|
|
342
|
+
notify.mockImplementation((...args: unknown[]) => {
|
|
343
|
+
if ((args[0] as string) === CREATED_NOTIFY) order.push("notify");
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
await offerKeybindingFix(ctx);
|
|
347
|
+
|
|
348
|
+
// reload called exactly once
|
|
349
|
+
expect(reload).toHaveBeenCalledTimes(1);
|
|
350
|
+
// flag was already set when reload ran
|
|
351
|
+
expect(order).toContain("flag-then-reload");
|
|
352
|
+
expect(order).not.toContain("reload-before-flag");
|
|
353
|
+
// CREATED_NOTIFY emitted before reload
|
|
354
|
+
const notifyIdx = order.indexOf("notify");
|
|
355
|
+
const reloadIdx = order.indexOf("flag-then-reload");
|
|
356
|
+
expect(notifyIdx).toBeGreaterThanOrEqual(0);
|
|
357
|
+
expect(reloadIdx).toBeGreaterThan(notifyIdx);
|
|
358
|
+
// File written
|
|
359
|
+
expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(SNIPPET);
|
|
360
|
+
// CREATED_NOTIFY contains "reloading now"
|
|
361
|
+
expect(CREATED_NOTIFY).toContain("reloading now");
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
it("no path: ctx.reload() NOT called", async () => {
|
|
365
|
+
const { ctx, confirm, reload } = makeCtx("tui");
|
|
366
|
+
confirm.mockResolvedValue(false);
|
|
367
|
+
await offerKeybindingFix(ctx);
|
|
368
|
+
expect(reload).not.toHaveBeenCalled();
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
it("cancel (resolves false) path: ctx.reload() NOT called", async () => {
|
|
372
|
+
const { ctx, confirm, reload } = makeCtx("tui");
|
|
373
|
+
confirm.mockResolvedValue(false); // Esc/timeout: same as no
|
|
374
|
+
await offerKeybindingFix(ctx);
|
|
375
|
+
expect(reload).not.toHaveBeenCalled();
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
it("file-write-fails path: ctx.reload() NOT called", async () => {
|
|
379
|
+
// Block the file write by making the tmp path a directory
|
|
380
|
+
fs.mkdirSync(tmpPath(), { recursive: true });
|
|
381
|
+
const { ctx, confirm, reload } = makeCtx("tui");
|
|
382
|
+
confirm.mockResolvedValue(true);
|
|
383
|
+
await offerKeybindingFix(ctx);
|
|
384
|
+
expect(reload).not.toHaveBeenCalled();
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
it("reload rejects: offer still resolves, flag persisted, file present, CREATED_NOTIFY emitted", async () => {
|
|
388
|
+
const { ctx, confirm, notify, reload } = makeCtx("tui");
|
|
389
|
+
confirm.mockResolvedValue(true);
|
|
390
|
+
reload.mockRejectedValue(new Error("Reload failed"));
|
|
391
|
+
|
|
392
|
+
await expect(offerKeybindingFix(ctx)).resolves.toBeUndefined();
|
|
393
|
+
|
|
394
|
+
// File must have been written
|
|
395
|
+
expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(SNIPPET);
|
|
396
|
+
// Flag must have been set (reload runs after flag)
|
|
397
|
+
expect(readFlag()).toBe(true);
|
|
398
|
+
// CREATED_NOTIFY must have been emitted (fires before reload)
|
|
399
|
+
expect(
|
|
400
|
+
(notify.mock.calls as unknown[][]).some(
|
|
401
|
+
(c) => c[0] === CREATED_NOTIFY && c[1] === "info",
|
|
402
|
+
),
|
|
403
|
+
).toBe(true);
|
|
404
|
+
// reload was still attempted once
|
|
405
|
+
expect(reload).toHaveBeenCalledTimes(1);
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
|
|
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
|
+
describe("concurrent creation is never clobbered", () => {
|
|
433
|
+
it("file created between gate 4 and the pre-rename re-check: left intact, flag set", async () => {
|
|
434
|
+
// Simulate: the first keybindings.json check (gate 4) sees nothing; the
|
|
435
|
+
// second (pre-rename re-check) sees a file that a concurrent process just
|
|
436
|
+
// created — the spy materializes it on that hit.
|
|
437
|
+
let kbHits = 0;
|
|
438
|
+
existsSyncSpy.mockImplementation((p: unknown) => {
|
|
439
|
+
const s = String(p);
|
|
440
|
+
if (s === keybindingsPath()) {
|
|
441
|
+
kbHits += 1;
|
|
442
|
+
// NOTE: coupled to the module's two existsSync calls — gate-4 check,
|
|
443
|
+
// then the pre-rename re-check (keybinding-offer.ts). If the module
|
|
444
|
+
// de-duplicates or adds an existence check, update kbHits accordingly.
|
|
445
|
+
if (kbHits >= 2) {
|
|
446
|
+
if (!fs.existsSync(s)) {
|
|
447
|
+
fs.writeFileSync(s, CONCURRENT_CONTENT, "utf-8");
|
|
448
|
+
}
|
|
449
|
+
return true;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return fs.existsSync(s);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
const { ctx, confirm, notify } = makeCtx("tui");
|
|
456
|
+
confirm.mockResolvedValue(true);
|
|
457
|
+
|
|
458
|
+
await expect(offerKeybindingFix(ctx)).resolves.toBeUndefined();
|
|
459
|
+
|
|
460
|
+
// The concurrently created file is never clobbered by the snippet
|
|
461
|
+
expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(CONCURRENT_CONTENT);
|
|
462
|
+
expect(fs.readFileSync(keybindingsPath(), "utf-8")).not.toBe(SNIPPET);
|
|
463
|
+
// The tmp file is cleaned up
|
|
464
|
+
expect(fs.existsSync(tmpPath())).toBe(false);
|
|
465
|
+
// The offer ends: the offer was made and the fix is in force (file exists)
|
|
466
|
+
expect(readFlag()).toBe(true);
|
|
467
|
+
// We did not create the file, so we do not claim to
|
|
468
|
+
expect(
|
|
469
|
+
callArgs(notify).some((c) => c[0] === CREATED_NOTIFY),
|
|
470
|
+
).toBe(false);
|
|
471
|
+
});
|
|
472
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { existsSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
|
|
4
|
+
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
|
|
7
|
+
import { isConfigEnabled, loadConfig, updateConfig } from "@pi-archimedes/core/settings-io";
|
|
8
|
+
|
|
9
|
+
const NAMESPACE = "archimedes.imagePaste";
|
|
10
|
+
|
|
11
|
+
const CONFIRM_TITLE = "First run";
|
|
12
|
+
const CONFIRM_MESSAGE =
|
|
13
|
+
"~/.pi/agent/keybindings.json is missing. Without it, Pi's built-in " +
|
|
14
|
+
"app.clipboard.pasteImage binding (Ctrl+V on Linux/macOS, Alt+V on Windows) " +
|
|
15
|
+
"double-fires with image-paste's Ctrl+V handler. Create the file now with the " +
|
|
16
|
+
"docs snippet? It clears the built-in binding (/reload applies it).";
|
|
17
|
+
|
|
18
|
+
/** Exactly the snippet from packages/image-paste/README.md → "Paste shortcuts". */
|
|
19
|
+
const SNIPPET_JSON = '{ "app.clipboard.pasteImage": [] }';
|
|
20
|
+
|
|
21
|
+
export const CREATED_NOTIFY = "Created ~/.pi/agent/keybindings.json — reloading now";
|
|
22
|
+
|
|
23
|
+
interface PromptConfig {
|
|
24
|
+
keybindingsPromptDone: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const PROMPT_DEFAULTS: PromptConfig = { keybindingsPromptDone: false };
|
|
28
|
+
|
|
29
|
+
function messageOf(error: unknown): string {
|
|
30
|
+
return error instanceof Error ? error.message : String(error);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Persist `keybindingsPromptDone: true` via updateConfig: optimistic re-read
|
|
35
|
+
* check + bounded retry, so a concurrent write to settings.json (e.g. another
|
|
36
|
+
* session's /plugins toggle) is detected and re-read — the flag save cannot
|
|
37
|
+
* clobber a newer `enabled` value. Never throws: a failed save notifies and
|
|
38
|
+
* leaves the flag unset (gate 4 blocks the re-offer once the file exists anyway).
|
|
39
|
+
*/
|
|
40
|
+
function markPromptDone(ctx: ExtensionContext): void {
|
|
41
|
+
try {
|
|
42
|
+
updateConfig<PromptConfig>(
|
|
43
|
+
NAMESPACE,
|
|
44
|
+
PROMPT_DEFAULTS,
|
|
45
|
+
(cfg) => ({ ...cfg, keybindingsPromptDone: true }),
|
|
46
|
+
);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
ctx.ui.notify(
|
|
49
|
+
`Could not persist keybinding prompt flag: ${messageOf(error)}`,
|
|
50
|
+
"warning",
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* First-run keybinding offer (once ever, all platforms).
|
|
57
|
+
*
|
|
58
|
+
* On the first TUI session, if `~/.pi/agent/keybindings.json` does not exist,
|
|
59
|
+
* offer to create it with the docs snippet so Pi's built-in clipboard paste
|
|
60
|
+
* doesn't double-fire with image-paste's Ctrl+V handler. Accepting writes the
|
|
61
|
+
* 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
|
+
* resolves `false` either way) sets the flag without touching the file. A
|
|
66
|
+
* failed file write leaves the flag unset, so the offer self-heals next session.
|
|
67
|
+
*
|
|
68
|
+
* **Concurrent-session edge:** if the user runs `/new` or `/reload` while the
|
|
69
|
+
* confirm dialog is still open, `ctx.ui.confirm` resolves `false` (the TUI
|
|
70
|
+
* tears down). This counts as a decline — the flag is set and the offer will
|
|
71
|
+
* not appear again. To reset it, delete `archimedes.imagePaste.keybindingsPromptDone`
|
|
72
|
+
* from `~/.pi/agent/settings.json`.
|
|
73
|
+
*/
|
|
74
|
+
export async function offerKeybindingFix(ctx: ExtensionContext): Promise<void> {
|
|
75
|
+
// Gate 1: extension on.
|
|
76
|
+
// NOTE: reading archimedes.imagePaste.enabled in-package is a sanctioned
|
|
77
|
+
// exception to the AGENTS.md "Plugin on/off" rule and ADR 0012 — this
|
|
78
|
+
// function runs from the meta session_start handler before plugin
|
|
79
|
+
// registration, so there is no registration gate to catch it here.
|
|
80
|
+
// See docs/decisions/0012-plugin-gate-in-package-namespace.md § Exception.
|
|
81
|
+
if (!isConfigEnabled(NAMESPACE)) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Gate 2: interactive TUI only. Deliberate: the flag is NOT consumed in
|
|
86
|
+
// non-TUI modes, so a later TUI session still gets the offer.
|
|
87
|
+
if (ctx.mode !== "tui") {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Gate 3: not yet consumed
|
|
92
|
+
if (loadConfig<PromptConfig>(NAMESPACE, { ...PROMPT_DEFAULTS }).keybindingsPromptDone === true) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Gate 4: file absent — never merge into or rewrite an existing user file
|
|
97
|
+
const keybindingsPath = join(getAgentDir(), "keybindings.json");
|
|
98
|
+
if (existsSync(keybindingsPath)) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Gate 5: ask (confirm signature is TITLE first — docs/extensions.md:165)
|
|
103
|
+
const confirmed = await ctx.ui.confirm(CONFIRM_TITLE, CONFIRM_MESSAGE);
|
|
104
|
+
|
|
105
|
+
// No, or cancel (Esc/timeout — `confirm` resolves `false` either way counts
|
|
106
|
+
// as decline): set the flag and do nothing else.
|
|
107
|
+
if (!confirmed) {
|
|
108
|
+
markPromptDone(ctx);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Yes: write the file atomically (tmp + rename) — strictly BEFORE the flag
|
|
113
|
+
// is set, so a failed write leaves both gates open and the offer self-heals.
|
|
114
|
+
const tmpPath = `${keybindingsPath}.${process.pid}.tmp`;
|
|
115
|
+
try {
|
|
116
|
+
writeFileSync(tmpPath, SNIPPET_JSON, "utf-8");
|
|
117
|
+
// Re-check immediately before the rename so a concurrently created file is
|
|
118
|
+
// never clobbered (TOCTOU).
|
|
119
|
+
if (existsSync(keybindingsPath)) {
|
|
120
|
+
// Someone created the file in the meantime: leave it intact, clean up
|
|
121
|
+
// our tmp, and end the offer (gate 4 blocks re-offer anyway).
|
|
122
|
+
try {
|
|
123
|
+
unlinkSync(tmpPath);
|
|
124
|
+
} catch {
|
|
125
|
+
// ignore
|
|
126
|
+
}
|
|
127
|
+
markPromptDone(ctx);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
renameSync(tmpPath, keybindingsPath);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
// File write (or rename) failed: do NOT set the flag — the offer self-
|
|
133
|
+
// heals next session. Best-effort tmp cleanup first.
|
|
134
|
+
try {
|
|
135
|
+
unlinkSync(tmpPath);
|
|
136
|
+
} catch {
|
|
137
|
+
// ignore
|
|
138
|
+
}
|
|
139
|
+
ctx.ui.notify(
|
|
140
|
+
`Could not create keybindings.json: ${messageOf(error)}`,
|
|
141
|
+
"warning",
|
|
142
|
+
);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// File written successfully → THEN set the flag (deliberately after the
|
|
147
|
+
// write, per the design).
|
|
148
|
+
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).
|
|
157
|
+
// 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.
|
|
164
|
+
}
|
|
165
|
+
}
|