@pi-archimedes/image-paste 2.10.4 → 2.11.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 +11 -3
- package/package.json +4 -4
- package/src/clipboard.test.ts +497 -3
- package/src/clipboard.ts +311 -4
- package/src/default-export.test.ts +90 -2
- package/src/index.ts +176 -35
- package/src/keybinding-offer.test.ts +1671 -10
- package/src/keybinding-offer.ts +741 -38
- package/src/paste-dispatch.test.ts +516 -0
- package/src/paste-mutex.test.ts +165 -0
- package/src/shortcut-gate.test.ts +220 -0
package/src/keybinding-offer.ts
CHANGED
|
@@ -1,27 +1,72 @@
|
|
|
1
|
-
import { existsSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
|
|
4
4
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
5
5
|
import type { ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { diag } from "@pi-archimedes/core/log";
|
|
8
|
+
import {
|
|
9
|
+
isConfigEnabled,
|
|
10
|
+
loadConfig,
|
|
11
|
+
updateConfig,
|
|
12
|
+
writeAtomicPreservingSymlinks,
|
|
13
|
+
} from "@pi-archimedes/core/settings-io";
|
|
8
14
|
|
|
9
15
|
const NAMESPACE = "archimedes.imagePaste";
|
|
10
16
|
|
|
17
|
+
/** pi's native clipboard action, the one key this package takes over. */
|
|
18
|
+
const UNBIND_KEY = "app.clipboard.pasteImage";
|
|
19
|
+
|
|
20
|
+
/** pi's published keybindings schema — the `$schema` value the suite writes. */
|
|
21
|
+
const PI_KEYBINDINGS_SCHEMA_URL = "https://pi.dev/schemas/keybindings.schema.json";
|
|
22
|
+
|
|
11
23
|
const CONFIRM_TITLE = "First run";
|
|
24
|
+
// Wording is measured, not assumed: on Pi 1.2.0 the two bindings do NOT both
|
|
25
|
+
// run. `app.clipboard.pasteImage` is not on pi's reserved-keybinding list, so
|
|
26
|
+
// the extension shortcut wins (`runner.js:467-474`) and `handleInput` returns
|
|
27
|
+
// before the built-in's paste branch (`custom-editor.js:63-70`) — what the user
|
|
28
|
+
// gets without this file is one paste (ours) plus a standing `[Extension
|
|
29
|
+
// issues]` line, not two pastes.
|
|
12
30
|
const CONFIRM_MESSAGE =
|
|
13
31
|
"~/.pi/agent/keybindings.json is missing. Without it, Pi's built-in " +
|
|
14
32
|
"app.clipboard.pasteImage binding (Ctrl+V on Linux/macOS, Alt+V on Windows) " +
|
|
15
|
-
"
|
|
33
|
+
"shares the paste key with image-paste's handler: image-paste's handler wins, " +
|
|
34
|
+
"Pi never runs its own action, and every session starts with an " +
|
|
35
|
+
"[Extension issues] shortcut-conflict line. Create the file now with the " +
|
|
16
36
|
"docs snippet? It clears the built-in binding (/reload applies it).";
|
|
17
37
|
|
|
18
|
-
/**
|
|
19
|
-
|
|
38
|
+
/**
|
|
39
|
+
* Exactly the snippet from packages/image-paste/README.md → "Paste shortcuts".
|
|
40
|
+
*
|
|
41
|
+
* `$schema` points at pi's published keybindings schema. Read it as
|
|
42
|
+
* forward-compatibility and editor tooling, not as validation: pi 1.2.0 ships
|
|
43
|
+
* `KeybindingsSchema` (`dist/core/keybindings-schema.js:13`) with nothing in the
|
|
44
|
+
* shipped `dist` importing it, and what it validates at startup is *theme* JSON
|
|
45
|
+
* (`setThemeJsonValidator`, `dist/main.js:452`). What matters for us is that the
|
|
46
|
+
* loader ignores the key — `toKeybindingsConfig` does
|
|
47
|
+
* `if (key === "$schema") continue;` (`dist/core/keybindings.js:247-248`) — so
|
|
48
|
+
* the unbinding still applies, and the schema itself declares
|
|
49
|
+
* `"$schema": { "type": "string" }` (verified against the live
|
|
50
|
+
* https://pi.dev/schemas/keybindings.schema.json, whose `$id` is that URL).
|
|
51
|
+
*/
|
|
52
|
+
const SNIPPET_JSON =
|
|
53
|
+
`{ "$schema": "${PI_KEYBINDINGS_SCHEMA_URL}", "${UNBIND_KEY}": [] }`;
|
|
20
54
|
|
|
21
55
|
export const CREATED_NOTIFY = "Created ~/.pi/agent/keybindings.json — reloading now";
|
|
22
56
|
/** Fallback wording for runtimes whose event ctx has no reload (Pi 0.85.1). */
|
|
23
57
|
export const CREATED_RELOAD_HINT =
|
|
24
58
|
"Created ~/.pi/agent/keybindings.json — run /reload to apply";
|
|
59
|
+
/**
|
|
60
|
+
* The restore twin of the two texts above. Same flow (see
|
|
61
|
+
* {@link reloadAfterKeybindingChange}), different verb: saying "Created
|
|
62
|
+
* ~/.pi/agent/keybindings.json" immediately after emptying that file would be
|
|
63
|
+
* false, and this suite tells the user what actually happened.
|
|
64
|
+
*/
|
|
65
|
+
export const RESTORED_NOTIFY =
|
|
66
|
+
"Restored pi's native clipboard paste — app.clipboard.pasteImage removed from ~/.pi/agent/keybindings.json, reloading now";
|
|
67
|
+
/** Restore fallback for a ctx whose event context has no `reload`. */
|
|
68
|
+
export const RESTORED_RELOAD_HINT =
|
|
69
|
+
"Restored pi's native clipboard paste — run /reload to apply";
|
|
25
70
|
|
|
26
71
|
interface PromptConfig {
|
|
27
72
|
keybindingsPromptDone: boolean;
|
|
@@ -29,10 +74,95 @@ interface PromptConfig {
|
|
|
29
74
|
|
|
30
75
|
const PROMPT_DEFAULTS: PromptConfig = { keybindingsPromptDone: false };
|
|
31
76
|
|
|
77
|
+
/** `archimedes.imagePaste`, the keys this module reads. */
|
|
78
|
+
interface ImagePasteConfig {
|
|
79
|
+
suppressNativePaste: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Default `true` = today's behaviour: the suite keeps the paste key and pi's
|
|
84
|
+
* native action stays unbound. Only an explicit `false` asks for the key back
|
|
85
|
+
* (strict comparison — `"false"` and `0` are not `false`, same rule as
|
|
86
|
+
* `isConfigEnabled`).
|
|
87
|
+
*/
|
|
88
|
+
const IMAGE_PASTE_DEFAULTS: ImagePasteConfig = { suppressNativePaste: true };
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Whether the suite may claim pi's paste key(s) for itself this session.
|
|
92
|
+
*
|
|
93
|
+
* `true` (the default) is today's behaviour: image-paste registers the paste
|
|
94
|
+
* shortcuts and pi's native `app.clipboard.pasteImage` stays unbound. `false` —
|
|
95
|
+
* the strict boolean, same rule as {@link runNativePasteRestore} — asks for the
|
|
96
|
+
* key back, and then BOTH halves of the promise apply: the file restore removes
|
|
97
|
+
* our unbinding from `keybindings.json`, AND `registerImagePaste` registers no
|
|
98
|
+
* shortcut, so the restored native binding is actually reachable.
|
|
99
|
+
*
|
|
100
|
+
* Why the second half is needed (pi 1.2.0, measured): `app.clipboard.pasteImage`
|
|
101
|
+
* is not in `RESERVED_KEYBINDINGS_FOR_EXTENSION_CONFLICTS` (`dist/core/
|
|
102
|
+
* extensions/runner.js:9-27`), so `restrictOverride === false` and
|
|
103
|
+
* `ExtensionRunner.getShortcuts` keeps the extension shortcut and prints "…
|
|
104
|
+
* Using `<ext>`" (`dist/core/extensions/runner.js:467-474`); and
|
|
105
|
+
* `CustomEditor.handleInput` consults `onExtensionShortcut` before its
|
|
106
|
+
* `app.clipboard.pasteImage` branch and returns (`dist/modes/interactive/
|
|
107
|
+
* components/custom-editor.js:63-70`). While we hold the shortcut, an empty
|
|
108
|
+
* keybinding entry therefore changes nothing except adding a shortcut-conflict
|
|
109
|
+
* diagnostic — the key only comes back when we stop claiming it.
|
|
110
|
+
*
|
|
111
|
+
* Read per call, never cached at module scope: pi caches the extension factory
|
|
112
|
+
* (`dist/core/extensions/loader.js:461-489`) but re-invokes it per session, and
|
|
113
|
+
* the composed suite calls the registration from its own `session_start`
|
|
114
|
+
* handler, so a module-level flag would freeze the first session's answer.
|
|
115
|
+
*/
|
|
116
|
+
export function suiteMayClaimPasteKey(): boolean {
|
|
117
|
+
return loadConfig<ImagePasteConfig>(NAMESPACE, IMAGE_PASTE_DEFAULTS).suppressNativePaste !== false;
|
|
118
|
+
}
|
|
119
|
+
|
|
32
120
|
function messageOf(error: unknown): string {
|
|
33
121
|
return error instanceof Error ? error.message : String(error);
|
|
34
122
|
}
|
|
35
123
|
|
|
124
|
+
/**
|
|
125
|
+
* The reload path shared by the first-run offer and the restore: notify, then
|
|
126
|
+
* call `ExtensionCommandContext.reload` if this runtime puts it on the ctx
|
|
127
|
+
* (Pi 0.85.1 attaches it to the command context only, so the event ctx may have
|
|
128
|
+
* no `reload` — then the user is told to run `/reload` instead).
|
|
129
|
+
*
|
|
130
|
+
* `reload()` must be the LAST use of `ctx` — the reload invalidates this
|
|
131
|
+
* extension instance. A rejected reload is swallowed: the TUI shows its own
|
|
132
|
+
* "Reload failed" status and the session continues on the still-valid ctx.
|
|
133
|
+
*
|
|
134
|
+
* `opts.notify` (default `true`) suppresses BOTH messages, for a caller running
|
|
135
|
+
* on a UI-less ctx: the texts are UI messages and `runNativePasteRestore`'
|
|
136
|
+
* contract is to stay silent when `ctx.hasUI` is false. The reload itself is
|
|
137
|
+
* unaffected — a host that exposes `reload` still gets the file re-read.
|
|
138
|
+
*
|
|
139
|
+
* @returns `true` iff a reload was available and completed.
|
|
140
|
+
*/
|
|
141
|
+
async function reloadAfterKeybindingChange(
|
|
142
|
+
ctx: ExtensionContext,
|
|
143
|
+
notifyText: string,
|
|
144
|
+
hintText: string,
|
|
145
|
+
opts?: { notify?: boolean },
|
|
146
|
+
): Promise<boolean> {
|
|
147
|
+
const shouldNotify = opts?.notify ?? true;
|
|
148
|
+
// Cast: reload() is typed on ExtensionCommandContext; the session_start
|
|
149
|
+
// handler receives the base ExtensionContext, hence the presence guard.
|
|
150
|
+
const reload = (ctx as ExtensionCommandContext).reload;
|
|
151
|
+
if (typeof reload !== "function") {
|
|
152
|
+
if (shouldNotify) ctx.ui.notify(hintText, "info");
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
if (shouldNotify) ctx.ui.notify(notifyText, "info");
|
|
156
|
+
try {
|
|
157
|
+
await reload();
|
|
158
|
+
return true;
|
|
159
|
+
} catch {
|
|
160
|
+
// Swallowed on purpose: the TUI shows its own "Reload failed" status, and
|
|
161
|
+
// the file change is already persisted, so a manual /reload heals it.
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
36
166
|
/**
|
|
37
167
|
* Persist `keybindingsPromptDone: true` via updateConfig: optimistic re-read
|
|
38
168
|
* check + bounded retry, so a concurrent write to settings.json (e.g. another
|
|
@@ -60,7 +190,10 @@ function markPromptDone(ctx: ExtensionContext): void {
|
|
|
60
190
|
*
|
|
61
191
|
* On the first TUI session, if `~/.pi/agent/keybindings.json` does not exist,
|
|
62
192
|
* offer to create it with the docs snippet so Pi's built-in clipboard paste
|
|
63
|
-
* doesn't
|
|
193
|
+
* doesn't share the key with an action that would otherwise win — with both
|
|
194
|
+
* bindings live image-paste's handler is the one that runs, pi's own action
|
|
195
|
+
* never fires, and the session starts with a `[Extension issues]`
|
|
196
|
+
* shortcut-conflict line. Accepting writes the
|
|
64
197
|
* file (atomic tmp+rename, pre-rename existence re-check) and then sets the
|
|
65
198
|
* one-shot flag and, when the runtime exposes `reload()` on the session ctx,
|
|
66
199
|
* reloads the TUI (the exact /reload flow: re-reads keybindings.json and
|
|
@@ -70,6 +203,13 @@ function markPromptDone(ctx: ExtensionContext): void {
|
|
|
70
203
|
* resolves `false` either way) sets the flag without touching the file. A
|
|
71
204
|
* failed file write leaves the flag unset, so the offer self-heals next session.
|
|
72
205
|
*
|
|
206
|
+
* **Precondition (gate 1b, issue #133):** the offer only exists for an install
|
|
207
|
+
* that is going to hold the paste key. While `suppressNativePaste` is `false`
|
|
208
|
+
* the suite registers no paste shortcut and the restore hands the key back, so
|
|
209
|
+
* there is nothing to fix and the offer stays silent — without consuming
|
|
210
|
+
* `keybindingsPromptDone`, because the setting is reversible and closing it again
|
|
211
|
+
* must still be met by the offer. See the invariant comment at the gate.
|
|
212
|
+
*
|
|
73
213
|
* **Concurrent-session edge:** if the user runs `/new` or `/reload` while the
|
|
74
214
|
* confirm dialog is still open, `ctx.ui.confirm` resolves `false` (the TUI
|
|
75
215
|
* tears down). This counts as a decline — the flag is set and the offer will
|
|
@@ -78,8 +218,8 @@ function markPromptDone(ctx: ExtensionContext): void {
|
|
|
78
218
|
*
|
|
79
219
|
* **Return contract:** resolves `true` iff the offer triggered a successful
|
|
80
220
|
* `ctx.reload()` (the successful-accept path on a ctx that exposes `reload()`
|
|
81
|
-
* and the reload completed). Every other outcome — the gates (1–4),
|
|
82
|
-
* a failed file write, the file-exists race, a base-shape ctx without
|
|
221
|
+
* and the reload completed). Every other outcome — the gates (1, 1b, 2–4),
|
|
222
|
+
* decline, a failed file write, the file-exists race, a base-shape ctx without
|
|
83
223
|
* `reload` (manual /reload hint), and a reload that itself failed (the TUI
|
|
84
224
|
* shows its own "Reload failed" and the session continues on the still-valid
|
|
85
225
|
* ctx) — resolves `false`. The meta factory uses this to sequence the
|
|
@@ -98,6 +238,59 @@ export async function offerKeybindingFix(ctx: ExtensionContext): Promise<boolean
|
|
|
98
238
|
return false;
|
|
99
239
|
}
|
|
100
240
|
|
|
241
|
+
// Gate 1b: the suite must be the one holding the paste key, or there is
|
|
242
|
+
// nothing to offer.
|
|
243
|
+
//
|
|
244
|
+
// The subject of this whole flow is the file that unbinds pi's
|
|
245
|
+
// `app.clipboard.pasteImage` so our shortcut can own the key. With
|
|
246
|
+
// `archimedes.imagePaste.suppressNativePaste: false` the install has decided
|
|
247
|
+
// the opposite (ADR 0030's amendments): `registerImagePaste` registers no
|
|
248
|
+
// paste shortcut at all (the gate in packages/image-paste/src/index.ts) and
|
|
249
|
+
// `runNativePasteRestore` takes the unbinding back out of the file below. Offering to create it anyway asked
|
|
250
|
+
// permission for a file this install does not want, left `ctrl+v` bound to
|
|
251
|
+
// nobody (pi's action cleared by the file we just wrote, our handler not
|
|
252
|
+
// registered to take the key), and handed the next session's restore an
|
|
253
|
+
// artefact to empty — so "Created ~/.pi/agent/keybindings.json" and "Restored
|
|
254
|
+
// pi's native clipboard paste" both described one file, one session apart.
|
|
255
|
+
// Issue #133.
|
|
256
|
+
//
|
|
257
|
+
// WHY HERE: it is a settings read from the same namespace as gate 1, and both
|
|
258
|
+
// answer "does this install want this flow at all", so the two configuration
|
|
259
|
+
// preconditions sit together; the position also puts it before gate 4 stats
|
|
260
|
+
// keybindings.json, and that half is asserted — gate 4's check IS
|
|
261
|
+
// `existsSync(keybindingsPath)` and the spy over `node:fs`'s `existsSync` shows
|
|
262
|
+
// a hatch-off skip never names the path (moving this check after gate 4 fails
|
|
263
|
+
// that test). Gate 3's flag read is skipped by the same position but is not
|
|
264
|
+
// observable from outside — this gate reads settings.json itself, as gate 1
|
|
265
|
+
// already did — so nothing claims to prove that half. Inserting it as a
|
|
266
|
+
// numbered gate 2 was rejected: the existing gate numbers are cited outside this
|
|
267
|
+
// file — `docs/features/first-run-keybinding-offer.md`, ADR 0030's amendment
|
|
268
|
+
// ("gate 4 of the first-run offer refuses to touch a file that is already
|
|
269
|
+
// there") and the comments in `keybinding-offer.test.ts` — and renumbering would
|
|
270
|
+
// silently point every one of those citations at the wrong check. Gate 1 keeps
|
|
271
|
+
// first place (the ADR 0012 exception), and gate 2 keeps its documented reason
|
|
272
|
+
// for not consuming the flag in non-TUI modes.
|
|
273
|
+
//
|
|
274
|
+
// It must NOT consume `keybindingsPromptDone`. The hatch is reversible: the
|
|
275
|
+
// user who sets `suppressNativePaste` back to `true` has to meet the offer in
|
|
276
|
+
// a later session, which is the whole value of an escape hatch you can undo.
|
|
277
|
+
// Like gate 2's non-TUI skip, this one leaves the offer armed.
|
|
278
|
+
//
|
|
279
|
+
// INVARIANT (mirrored at the head of `runNativePasteRestore`): for a given
|
|
280
|
+
// configuration the two flows are mutually exclusive — the offer acts only
|
|
281
|
+
// when `suiteMayClaimPasteKey()` is true, the restore only when it is false.
|
|
282
|
+
// So no session can both create the file and hand it back, and one run can
|
|
283
|
+
// never tell the user "Created …" and "Restored …" about the same file. What
|
|
284
|
+
// this does NOT forbid is the sequenced pair across a settings change: accept
|
|
285
|
+
// the offer today, set `suppressNativePaste: false` tomorrow, and the restore
|
|
286
|
+
// gives the key back — that is the hatch doing what it says, not a breach. It
|
|
287
|
+
// forbids the #133 shape, where both fired under one unchanged setting.
|
|
288
|
+
// Pinned by the "the invariant" describe in `keybinding-offer.test.ts`, which
|
|
289
|
+
// drives both entry points in the order `meta/src/index.ts` runs them.
|
|
290
|
+
if (!suiteMayClaimPasteKey()) {
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
293
|
+
|
|
101
294
|
// Gate 2: interactive TUI only. Deliberate: the flag is NOT consumed in
|
|
102
295
|
// non-TUI modes, so a later TUI session still gets the offer.
|
|
103
296
|
if (ctx.mode !== "tui") {
|
|
@@ -163,36 +356,546 @@ export async function offerKeybindingFix(ctx: ExtensionContext): Promise<boolean
|
|
|
163
356
|
// write, per the design).
|
|
164
357
|
markPromptDone(ctx);
|
|
165
358
|
|
|
166
|
-
// Auto-apply
|
|
167
|
-
//
|
|
168
|
-
//
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
359
|
+
// Auto-apply goes through the shared reload path
|
|
360
|
+
// ({@link reloadAfterKeybindingChange}): notify, then reload() when this
|
|
361
|
+
// runtime puts it on the ctx. `true` means a reload was triggered, so the
|
|
362
|
+
// caller skips the onboarding on the now-stale ctx; a failed reload is
|
|
363
|
+
// swallowed there (the TUI shows its own "Reload failed"), and the offer
|
|
364
|
+
// never throws.
|
|
365
|
+
return reloadAfterKeybindingChange(ctx, CREATED_NOTIFY, CREATED_RELOAD_HINT);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// ── restoreNativePaste — ADR 0030, Amendment (2026-10-13) ───────────────────
|
|
369
|
+
//
|
|
370
|
+
// "Declining to suppress" cannot help the user who already accepted the
|
|
371
|
+
// first-run offer: they hold `~/.pi/agent/keybindings.json` containing our
|
|
372
|
+
// unbinding, which is exactly why pi's native `app.clipboard.pasteImage` is
|
|
373
|
+
// dead. So `archimedes.imagePaste.suppressNativePaste: false` has to give the
|
|
374
|
+
// key back in TWO halves: delete our own artefact from that file — the narrow
|
|
375
|
+
// exception to gate 4's "never rewrite an existing user keybindings file" rule —
|
|
376
|
+
// and stop claiming the key, which `registerImagePaste` does from the same
|
|
377
|
+
// setting (`suiteMayClaimPasteKey`). Removing the unbinding alone is not enough:
|
|
378
|
+
// while our shortcut is registered pi keeps it and the restored binding never
|
|
379
|
+
// runs. See
|
|
380
|
+
// docs/decisions/0030-image-paste-superset-not-supplement.md § Amendment.
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Why a file was left alone. The five are NOT the same five files, and the
|
|
384
|
+
* warning no longer pretends otherwise (issue #127): `binding-not-empty` and an
|
|
385
|
+
* `extra-key` file that also still carries an empty `app.clipboard.pasteImage`
|
|
386
|
+
* hold an entry the user can delete, while foreign-keys-only /
|
|
387
|
+
* `$schema`-only files hold none, and the three unreadable shapes cannot be
|
|
388
|
+
* inspected at all. `manualEdit` branches on this — and on
|
|
389
|
+
* {@link pasteEntryFacts}, because which entry (if any) is in there is a fact
|
|
390
|
+
* the reason alone does not carry.
|
|
391
|
+
*/
|
|
392
|
+
export type UntouchedReason =
|
|
393
|
+
/** Valid JSON that is not an object (an array, a string, a number…). */
|
|
394
|
+
| "not-an-object"
|
|
395
|
+
/** Any key we did not write, including a `$schema` with another URL. */
|
|
396
|
+
| "extra-key"
|
|
397
|
+
/** `app.clipboard.pasteImage` present with a value that is not `[]`. */
|
|
398
|
+
| "binding-not-empty"
|
|
399
|
+
/** The file does not parse. */
|
|
400
|
+
| "unparsable"
|
|
401
|
+
/** The file exists but cannot be read (EISDIR, EACCES…). */
|
|
402
|
+
| "unreadable";
|
|
403
|
+
|
|
404
|
+
/** What {@link restoreNativePaste} did to `keybindings.json`. */
|
|
405
|
+
export type RestoreNativePasteStatus =
|
|
406
|
+
/** No file at all — nothing to restore, nothing written. */
|
|
407
|
+
| { kind: "missing"; path: string }
|
|
408
|
+
/** The file already parses to `{}` — reported WITHOUT rewriting it. */
|
|
409
|
+
| { kind: "already-restored"; path: string }
|
|
410
|
+
/** Our artefact was removed and `{}` written; pi must re-read the file. */
|
|
411
|
+
| { kind: "restored"; path: string; reloadRequired: boolean }
|
|
412
|
+
/** Left byte-for-byte alone; `manualEdit` is what to do about it — for the
|
|
413
|
+
* refusals that hold one, the entry to remove; for the rest, no errand. */
|
|
414
|
+
| { kind: "untouched"; path: string; reason: UntouchedReason; manualEdit: string }
|
|
415
|
+
/**
|
|
416
|
+
* The rewrite itself failed (EACCES, EISDIR, ENOSPC…); the file is intact.
|
|
417
|
+
* A symlink LOOP is NOT in this case: `existsSync` follows links, so a loop —
|
|
418
|
+
* like a dangling link — reads as `"missing"` and the write is never
|
|
419
|
+
* attempted. Verified by test.
|
|
420
|
+
*/
|
|
421
|
+
| { kind: "failed"; path: string; error: string; manualEdit: string };
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* What {@link runNativePasteRestore} did — the restoration statuses plus
|
|
425
|
+
* `"suppressed"`, meaning the settings did not ask for the key back.
|
|
426
|
+
*/
|
|
427
|
+
export type NativePasteRestoreOutcome =
|
|
428
|
+
| RestoreNativePasteStatus
|
|
429
|
+
| { kind: "suppressed" };
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* The once-per-process notify guard. It lives on `globalThis`, NOT in module
|
|
433
|
+
* scope, and module scope cannot work here: pi re-evaluates the extension
|
|
434
|
+
* module on every load — `loadExtensionModule()` builds a fresh jiti with
|
|
435
|
+
* **`moduleCache: false`** each time it imports an extension (pi 1.2.0
|
|
436
|
+
* `dist/core/extensions/loader.js:477-481`, `moduleCache: false` at `:478`, the
|
|
437
|
+
* import at `:481`), and `ctx.reload()` clears the cached factory first
|
|
438
|
+
* (`dist/core/resource-loader.js:359` → `clearExtensionCache()`, defined at
|
|
439
|
+
* `dist/core/extensions/loader.js:89-93`). So a `let` at module scope is
|
|
440
|
+
* re-created on `/reload` and the user would be re-warned about the same
|
|
441
|
+
* untouched file.
|
|
442
|
+
*
|
|
443
|
+
* Both codebases already use this exact technique for this exact reason: pi
|
|
444
|
+
* keeps its theme instance on `globalThis` under a `Symbol.for` key — "Use
|
|
445
|
+
* globalThis to share theme across module loaders (node + jiti in dev mode)"
|
|
446
|
+
* (`dist/modes/interactive/theme/theme.js:500-517`; comment `:502`, key `:503`,
|
|
447
|
+
* read `:509`, writes `:516-517`) — and our own deleted `packages/core/src/bus.ts`
|
|
448
|
+
* was a `globalThis` singleton for the same reason.
|
|
449
|
+
*/
|
|
450
|
+
const RESTORE_NOTIFIED_KEY = Symbol.for("pi-archimedes:image-paste:restore-notified");
|
|
451
|
+
|
|
452
|
+
/** Spend the guard: `true` the first time per process, `false` afterwards. */
|
|
453
|
+
function claimRestoreNotice(): boolean {
|
|
454
|
+
const g = globalThis as Record<symbol, unknown>;
|
|
455
|
+
if (g[RESTORE_NOTIFIED_KEY] === true) return false;
|
|
456
|
+
g[RESTORE_NOTIFIED_KEY] = true;
|
|
457
|
+
return true;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* pi's pre-rename name for the paste action — `KEYBINDING_NAME_MIGRATIONS.pasteImage`
|
|
462
|
+
* (`dist/core/keybindings.js:225` in pi 1.2.0, and the same entry in 0.87.0),
|
|
463
|
+
* applied by `migrateKeybindingsConfig` (`:254-269`) before the bindings are
|
|
464
|
+
* built. So `"pasteImage": []` unbinds `app.clipboard.pasteImage` exactly as the
|
|
465
|
+
* current spelling does; measured on both versions, pi resolves the action to
|
|
466
|
+
* `[]` for that file.
|
|
467
|
+
*
|
|
468
|
+
* We still never rewrite such a file — the accept-set is only what this suite
|
|
469
|
+
* has actually written, which is the modern name — but a refusal that calls that
|
|
470
|
+
* file one "pi's own paste answers" is describing the opposite of what pi does
|
|
471
|
+
* with it. pi's startup migration rewrites legacy names on disk
|
|
472
|
+
* (`migrations.js:139-157`) before extensions load, and its own `catch {}` means
|
|
473
|
+
* a read-only agent dir leaves the legacy file in place; `keybindings.reload()`
|
|
474
|
+
* on `/reload` (`dist/modes/interactive/interactive-mode.js:5406`) re-reads
|
|
475
|
+
* without migrating, so a file replaced by a dotfiles sync mid-session reaches
|
|
476
|
+
* us un-migrated either way.
|
|
477
|
+
*/
|
|
478
|
+
const LEGACY_UNBIND_KEY = "pasteImage";
|
|
479
|
+
|
|
480
|
+
/** Which entry of a file we read keeps pi's paste action switched off. */
|
|
481
|
+
type PasteBlocker =
|
|
482
|
+
/** Nothing in the file unbinds the action, under either name. */
|
|
483
|
+
| "none"
|
|
484
|
+
/** `[]` under the name this suite writes. */
|
|
485
|
+
| "current"
|
|
486
|
+
/** `[]` under pi's pre-rename name (`{@link LEGACY_UNBIND_KEY}`). */
|
|
487
|
+
| "legacy";
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* What a readable file does to the paste key, as one value, so the guidance and
|
|
491
|
+
* the reason cannot be derived from different facts.
|
|
492
|
+
*/
|
|
493
|
+
interface PasteEntryFacts {
|
|
494
|
+
blocker: PasteBlocker;
|
|
495
|
+
/**
|
|
496
|
+
* `[]` under the old name, sitting behind a modern entry that pi prefers: the
|
|
497
|
+
* migrator drops the legacy entry while the modern key exists
|
|
498
|
+
* (`keybindings.js:260-264`), so the old name is invisible today and takes the
|
|
499
|
+
* binding back the moment the modern entry is deleted — i.e. right after the
|
|
500
|
+
* user does what the warning tells them to do.
|
|
501
|
+
*/
|
|
502
|
+
legacyBehindModern: boolean;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/** The facts for a file we never got far enough with to inspect. */
|
|
506
|
+
const UNSEEN_FILE_FACTS: PasteEntryFacts = { blocker: "none", legacyBehindModern: false };
|
|
507
|
+
|
|
508
|
+
function isEmptyBinding(value: unknown): boolean {
|
|
509
|
+
return Array.isArray(value) && value.length === 0;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/** The facts {@link guidanceLine} needs, read off the parsed object. */
|
|
513
|
+
function pasteEntryFacts(obj: Record<string, unknown>): PasteEntryFacts {
|
|
514
|
+
const current = isEmptyBinding(obj[UNBIND_KEY]);
|
|
515
|
+
const legacy = isEmptyBinding(obj[LEGACY_UNBIND_KEY]);
|
|
516
|
+
return {
|
|
517
|
+
// The modern name wins when both are present, because pi drops the legacy
|
|
518
|
+
// entry in that case — so it decides which entry is the blocker.
|
|
519
|
+
blocker: current ? "current" : legacy ? "legacy" : "none",
|
|
520
|
+
legacyBehindModern: !current && legacy && UNBIND_KEY in obj,
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* The instruction for the one refusal that really does hold OUR unbinding: the
|
|
526
|
+
* entry is in there, removing it is the fix, and until it goes nothing answers
|
|
527
|
+
* the key (the file empties it and `suppressNativePaste: false` took our
|
|
528
|
+
* shortcut). Shared by the `extra-key`-with-unbinding refusal and by `failed`,
|
|
529
|
+
* whose file is our artefact.
|
|
530
|
+
*/
|
|
531
|
+
function removeOurEntryGuidance(path: string): string {
|
|
532
|
+
return (
|
|
533
|
+
`To give the paste key back to pi, remove the "${UNBIND_KEY}" entry from ` +
|
|
534
|
+
`${path} and run /reload. ` +
|
|
535
|
+
"image-paste is not binding the paste key either (suppressNativePaste is off), " +
|
|
536
|
+
"so that key does nothing until you do."
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* The same refusal for the same dead key, reached through pi's old name for the
|
|
542
|
+
* action: an entry IS there and removing it IS the fix, but it is spelled
|
|
543
|
+
* `pasteImage`, so naming the modern key would send the user looking for a line
|
|
544
|
+
* that is not in the file. It is not our artefact, so it is not named as ours
|
|
545
|
+
* and never rewritten — only described.
|
|
546
|
+
*/
|
|
547
|
+
function removeLegacyEntryGuidance(path: string): string {
|
|
548
|
+
return (
|
|
549
|
+
`To give the paste key back to pi, remove the "${LEGACY_UNBIND_KEY}" entry from ` +
|
|
550
|
+
`${path} and run /reload — that is the old name for "${UNBIND_KEY}", and pi ` +
|
|
551
|
+
"still reads it as an unbinding of the paste action. image-paste is not " +
|
|
552
|
+
"binding the paste key either (suppressNativePaste is off), so that key does " +
|
|
553
|
+
"nothing until you do."
|
|
554
|
+
);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* The guidance line for a file the extension must not touch, naming the path and
|
|
559
|
+
* saying only what this file actually supports (issue #127, and the PR #129
|
|
560
|
+
* review of the first split).
|
|
561
|
+
*
|
|
562
|
+
* One warning used to cover every refusal, and it ordered the user to "remove the
|
|
563
|
+
* `app.clipboard.pasteImage` entry" — true only when such an entry is in there.
|
|
564
|
+
* For a file of foreign keys, a `$schema`-only file, and the three shapes we
|
|
565
|
+
* cannot read, that instruction sends someone hunting a line that is not in the
|
|
566
|
+
* file, in a file that needs no edit at all. So the wording branches:
|
|
567
|
+
*
|
|
568
|
+
* * `binding-not-empty` — an entry IS there, and removing it is still how the key
|
|
569
|
+
* gets back to pi, but it is the user's own binding, so it is named as theirs
|
|
570
|
+
* and the key is not called dead (their binding answers it). If pi's OLD name
|
|
571
|
+
* sits behind it with `[]`, the sentence says to clear that one too: pi drops
|
|
572
|
+
* the legacy entry only while the modern key exists, so deleting just the
|
|
573
|
+
* modern one would hand the key straight back to the old unbinding.
|
|
574
|
+
* * `extra-key` holding our `[]` — the original wording, and the case where
|
|
575
|
+
* "that key does nothing until you do" is true of a file we wrote.
|
|
576
|
+
* * `extra-key` holding `[]` under pi's old name — the same dead key and the same
|
|
577
|
+
* fix, but the entry is named the way the file spells it.
|
|
578
|
+
* * `extra-key` with neither — nothing of ours to clean up, nothing to edit, and
|
|
579
|
+
* pi answers the key on its own default binding, so no reload is asked for
|
|
580
|
+
* either. No claim about who wrote the file: `$schema` with pi's published URL
|
|
581
|
+
* is byte-for-byte what the offer writes, and it is also what a hand-written
|
|
582
|
+
* file can carry, so authorship is not a fact the refusal can see.
|
|
583
|
+
* * the three unreadable shapes — the file is named and the cause is named, and
|
|
584
|
+
* what happens next is stated rather than left unknown: pi throws away a file
|
|
585
|
+
* it cannot use and every action keeps its default
|
|
586
|
+
* (`loadRawConfig` → `undefined` → `{}` user bindings,
|
|
587
|
+
* `dist/core/keybindings.js:285-297`), which means pi's own paste IS answering
|
|
588
|
+
* the key and none of the file's other bindings are in force. Asking the user
|
|
589
|
+
* to go read a file pi is ignoring would leave them looking in the wrong place.
|
|
590
|
+
*
|
|
591
|
+
* {@link pasteEntryFacts} is the one value carrying what the reason alone cannot:
|
|
592
|
+
* `extra-key` covers "an unbinding beside a foreign key" and "no unbinding at
|
|
593
|
+
* all", which need opposite instructions, and it does not say which spelling of
|
|
594
|
+
* the action the file uses. The unreadable shapes get {@link UNSEEN_FILE_FACTS}.
|
|
595
|
+
*/
|
|
596
|
+
function guidanceLine(path: string, reason: UntouchedReason, facts: PasteEntryFacts): string {
|
|
597
|
+
switch (reason) {
|
|
598
|
+
case "binding-not-empty":
|
|
599
|
+
return (
|
|
600
|
+
`The "${UNBIND_KEY}" binding in ${path} is yours, not something this suite ` +
|
|
601
|
+
"wrote, so it is yours to hand back: remove that entry and run /reload. " +
|
|
602
|
+
"image-paste is not binding the paste key either (suppressNativePaste is off)." +
|
|
603
|
+
(facts.legacyBehindModern
|
|
604
|
+
? ` The file also carries the old name "${LEGACY_UNBIND_KEY}", which pi reads ` +
|
|
605
|
+
"as the same action, so clear that entry too — once the entry above is gone " +
|
|
606
|
+
"it is the one keeping the paste key unbound."
|
|
607
|
+
: "")
|
|
608
|
+
);
|
|
609
|
+
case "extra-key":
|
|
610
|
+
if (facts.blocker === "current") return removeOurEntryGuidance(path);
|
|
611
|
+
if (facts.blocker === "legacy") return removeLegacyEntryGuidance(path);
|
|
612
|
+
return (
|
|
613
|
+
`Nothing in ${path} is ours to clean up and nothing in it clears pi's ` +
|
|
614
|
+
"paste binding, so there is nothing for you to edit — pi's own paste " +
|
|
615
|
+
"answers that key on its default binding. image-paste registers no paste " +
|
|
616
|
+
"shortcut of its own while suppressNativePaste is off."
|
|
617
|
+
);
|
|
618
|
+
case "unreadable":
|
|
619
|
+
case "unparsable":
|
|
620
|
+
case "not-an-object": {
|
|
621
|
+
const cause =
|
|
622
|
+
reason === "unreadable"
|
|
623
|
+
? "it could not be read"
|
|
624
|
+
: reason === "unparsable"
|
|
625
|
+
? "it does not parse as JSON"
|
|
626
|
+
: "it is not a JSON object";
|
|
627
|
+
return (
|
|
628
|
+
`The file is left exactly as it is; ${cause}, and pi treats a file it ` +
|
|
629
|
+
"cannot use as if it held no bindings at all — every action keeps its " +
|
|
630
|
+
"default, so pi's own paste is answering the key and nothing else in that " +
|
|
631
|
+
`file is in force. Please check ${path} yourself, and run /reload once it ` +
|
|
632
|
+
"is readable again. image-paste registers no paste shortcut of its own " +
|
|
633
|
+
"while suppressNativePaste is off."
|
|
634
|
+
);
|
|
194
635
|
}
|
|
195
636
|
}
|
|
196
|
-
ctx.ui.notify(CREATED_RELOAD_HINT, "info");
|
|
197
|
-
return false;
|
|
198
637
|
}
|
|
638
|
+
|
|
639
|
+
/** The guidance for a file we read, from the same read that gives the reason. */
|
|
640
|
+
function guidanceForReadFile(
|
|
641
|
+
path: string,
|
|
642
|
+
obj: Record<string, unknown>,
|
|
643
|
+
reason: UntouchedReason,
|
|
644
|
+
): string {
|
|
645
|
+
return guidanceLine(path, reason, pasteEntryFacts(obj));
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/** The guidance for a file we never got far enough with to inspect. */
|
|
649
|
+
function guidanceForUnreadableFile(path: string, reason: UntouchedReason): string {
|
|
650
|
+
return guidanceLine(path, reason, UNSEEN_FILE_FACTS);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
function agentKeybindingsPath(): string {
|
|
654
|
+
return join(getAgentDir(), "keybindings.json");
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* True only for the artefact this suite has written, in either of its two
|
|
659
|
+
* shapes:
|
|
660
|
+
*
|
|
661
|
+
* * before PR #123 — `{ "app.clipboard.pasteImage": [] }`
|
|
662
|
+
* * since PR #123 — `{ "$schema": "https://pi.dev/schemas/keybindings.schema.json",
|
|
663
|
+
* "app.clipboard.pasteImage": [] }`
|
|
664
|
+
*
|
|
665
|
+
* **Accept-set decision (the amendment asks for it explicitly):** the `$schema`
|
|
666
|
+
* + `app.clipboard.pasteImage: []` pair IS accepted, because that is literally
|
|
667
|
+
* what the suite writes today — refusing it would leave every current install
|
|
668
|
+
* unable to give the key back without a hand edit. `$schema` counts as ours
|
|
669
|
+
* only with pi's published URL, which keeps the set closed without widening it.
|
|
670
|
+
* Nothing else is ever accepted: not a third key, not a different `$schema`,
|
|
671
|
+
* not a non-empty binding — and not pi's old name for the action either
|
|
672
|
+
* ({@link LEGACY_UNBIND_KEY}). A file holding `"pasteImage": []` clears the
|
|
673
|
+
* paste key just as effectively, but this suite never wrote that spelling, so it
|
|
674
|
+
* is refused (and explained) rather than rewritten.
|
|
675
|
+
*/
|
|
676
|
+
function isSuiteOwnedUnbinding(obj: Record<string, unknown>): boolean {
|
|
677
|
+
if (!isEmptyBinding(obj[UNBIND_KEY])) return false;
|
|
678
|
+
const keys = Object.keys(obj);
|
|
679
|
+
if (keys.length === 1) return true;
|
|
680
|
+
return (
|
|
681
|
+
keys.length === 2 &&
|
|
682
|
+
keys.every((key) => key === UNBIND_KEY || key === "$schema") &&
|
|
683
|
+
obj["$schema"] === PI_KEYBINDINGS_SCHEMA_URL
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/**
|
|
688
|
+
* Remove the suite's `app.clipboard.pasteImage` unbinding from
|
|
689
|
+
* `~/.pi/agent/keybindings.json` and return what it did. **It never logs and
|
|
690
|
+
* never notifies** — the caller surfaces the outcome (see
|
|
691
|
+
* {@link runNativePasteRestore}), so one run per session cannot produce two
|
|
692
|
+
* messages and a headless caller stays silent on its own.
|
|
693
|
+
*
|
|
694
|
+
* Rules (ADR 0030 amendment):
|
|
695
|
+
*
|
|
696
|
+
* * Only ever acts on the suite's own artefact, and the write only ever
|
|
697
|
+
* *removes* the key — the result is `{}`.
|
|
698
|
+
* * Anything else — extra keys, a non-empty binding, a foreign `$schema`, pi's
|
|
699
|
+
* old name for the action, a file that does not parse or cannot be read — is
|
|
700
|
+
* left byte-for-byte alone, and the returned `"untouched"` status carries the
|
|
701
|
+
* path plus the guidance that refusal warrants ({@link guidanceLine}) — the
|
|
702
|
+
* entry to remove only when an entry really is in there, spelled the way the
|
|
703
|
+
* file spells it.
|
|
704
|
+
* * Idempotent by content: `{}` is reported as `"already-restored"` **without a
|
|
705
|
+
* rewrite**, so the flag cannot touch a user file on every session start.
|
|
706
|
+
* * Writes atomically through {@link writeAtomicPreservingSymlinks}, so a
|
|
707
|
+
* `keybindings.json` that is a symlink to somewhere else stays a symlink
|
|
708
|
+
* (the offer's own tmp+rename-onto-path is deliberately NOT reused — it only
|
|
709
|
+
* ever *creates* a missing file, where link preservation cannot matter). A
|
|
710
|
+
* DANGLING link never reaches that helper either: `existsSync` follows the
|
|
711
|
+
* link, so the status is `"missing"` and nothing is written through it.
|
|
712
|
+
* * Never throws: a failed write comes back as `"failed"`.
|
|
713
|
+
*/
|
|
714
|
+
export function restoreNativePaste(): RestoreNativePasteStatus {
|
|
715
|
+
const path = agentKeybindingsPath();
|
|
716
|
+
if (!existsSync(path)) return { kind: "missing", path };
|
|
717
|
+
|
|
718
|
+
let raw: string;
|
|
719
|
+
try {
|
|
720
|
+
raw = readFileSync(path, "utf-8");
|
|
721
|
+
} catch {
|
|
722
|
+
return {
|
|
723
|
+
kind: "untouched",
|
|
724
|
+
path,
|
|
725
|
+
reason: "unreadable",
|
|
726
|
+
manualEdit: guidanceForUnreadableFile(path, "unreadable"),
|
|
727
|
+
};
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
let parsed: unknown;
|
|
731
|
+
try {
|
|
732
|
+
parsed = JSON.parse(raw);
|
|
733
|
+
} catch {
|
|
734
|
+
return {
|
|
735
|
+
kind: "untouched",
|
|
736
|
+
path,
|
|
737
|
+
reason: "unparsable",
|
|
738
|
+
manualEdit: guidanceForUnreadableFile(path, "unparsable"),
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
742
|
+
return {
|
|
743
|
+
kind: "untouched",
|
|
744
|
+
path,
|
|
745
|
+
reason: "not-an-object",
|
|
746
|
+
manualEdit: guidanceForUnreadableFile(path, "not-an-object"),
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
const obj = parsed as Record<string, unknown>;
|
|
751
|
+
// Already restored: report it and leave the bytes (and their mtime) alone.
|
|
752
|
+
if (Object.keys(obj).length === 0) return { kind: "already-restored", path };
|
|
753
|
+
|
|
754
|
+
if (!isSuiteOwnedUnbinding(obj)) {
|
|
755
|
+
// Root cause, for the log: the binding itself being someone else's beats
|
|
756
|
+
// "there is a key we did not write" (a foreign `$schema` alongside a real
|
|
757
|
+
// `[]` unbinding is the extra key, not a live binding). The legacy spelling
|
|
758
|
+
// is an extra key by this test — pi renames it later, and `guidanceLine`
|
|
759
|
+
// says so to the user.
|
|
760
|
+
const reason: UntouchedReason =
|
|
761
|
+
UNBIND_KEY in obj && !isEmptyBinding(obj[UNBIND_KEY]) ? "binding-not-empty" : "extra-key";
|
|
762
|
+
return {
|
|
763
|
+
kind: "untouched",
|
|
764
|
+
path,
|
|
765
|
+
reason,
|
|
766
|
+
manualEdit: guidanceForReadFile(path, obj, reason),
|
|
767
|
+
};
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
try {
|
|
771
|
+
writeAtomicPreservingSymlinks(path, "{}");
|
|
772
|
+
} catch (error) {
|
|
773
|
+
// The file IS our artefact — that is the only way to reach a write — so the
|
|
774
|
+
// unbinding really is in there and removing it really is the fix.
|
|
775
|
+
return {
|
|
776
|
+
kind: "failed",
|
|
777
|
+
path,
|
|
778
|
+
error: messageOf(error),
|
|
779
|
+
manualEdit: removeOurEntryGuidance(path),
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
return { kind: "restored", path, reloadRequired: true };
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/** One-line detail for `diag` — path and key included, never the console. */
|
|
786
|
+
function describeRestoreStatus(status: RestoreNativePasteStatus): string {
|
|
787
|
+
switch (status.kind) {
|
|
788
|
+
case "missing":
|
|
789
|
+
return `restoreNativePaste: nothing to restore — ${status.path} does not exist`;
|
|
790
|
+
case "already-restored":
|
|
791
|
+
return `restoreNativePaste: already restored — ${status.path} holds no keys (no rewrite)`;
|
|
792
|
+
case "restored":
|
|
793
|
+
return (
|
|
794
|
+
`restoreNativePaste: restored — removed "${UNBIND_KEY}" from ${status.path}, ` +
|
|
795
|
+
`wrote {} (reloadRequired=${status.reloadRequired})`
|
|
796
|
+
);
|
|
797
|
+
case "untouched":
|
|
798
|
+
return (
|
|
799
|
+
`restoreNativePaste: untouched (${status.reason}) — ${status.path} was left as-is. ` +
|
|
800
|
+
status.manualEdit
|
|
801
|
+
);
|
|
802
|
+
case "failed":
|
|
803
|
+
return `restoreNativePaste: failed — ${status.path}: ${status.error}. ${status.manualEdit}`;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
/**
|
|
808
|
+
* The `session_start` call site: read the setting, restore if asked, and surface
|
|
809
|
+
* the outcome. Runs from whichever path loaded the package — image-paste's own
|
|
810
|
+
* `session_start` registration for standalone installs, or meta's composed
|
|
811
|
+
* `session_start` for the suite — and from each exactly once per session.
|
|
812
|
+
* Handlers stay registered at the top level of the registering factory, never
|
|
813
|
+
* nested (AGENTS.md).
|
|
814
|
+
*
|
|
815
|
+
* Surfacing rule: the actionable "this file is yours" outcome gets one
|
|
816
|
+
* `ctx.ui.notify(…, "warning")` **per process** — the guard lives on
|
|
817
|
+
* `globalThis` (see {@link RESTORE_NOTIFIED_KEY}) because a module-level flag
|
|
818
|
+
* would be reset by `/reload`. `diag("image-paste", …)` carries the detail on
|
|
819
|
+
* every outcome and both may fire, but `diag` cannot be the only channel: it is
|
|
820
|
+
* a no-op unless `PI_ARCHIMEDES_LOG` names a file, so a default install would be
|
|
821
|
+
* silent while pi's paste stayed dead. No notify at all when `ctx.hasUI` is
|
|
822
|
+
* false — on ANY outcome, including a successful restore, whose info and
|
|
823
|
+
* `/reload`-hint texts are UI messages too — and a headless run does not spend
|
|
824
|
+
* the guard. Nothing here uses
|
|
825
|
+
* `console.*` (`docs/features/ui.md`, guarded by
|
|
826
|
+
* `packages/core/src/log-discipline.test.ts`).
|
|
827
|
+
*
|
|
828
|
+
* A successful restore goes through the existing reload path so pi re-reads
|
|
829
|
+
* `keybindings.json`; on a ctx without `reload` the returned status says
|
|
830
|
+
* `/reload` is required. Never throws — a session start must not fail because a
|
|
831
|
+
* keybinding file is awkward.
|
|
832
|
+
*/
|
|
833
|
+
export async function runNativePasteRestore(
|
|
834
|
+
ctx: ExtensionContext,
|
|
835
|
+
): Promise<NativePasteRestoreOutcome> {
|
|
836
|
+
// Same predicate the shortcut gate reads, so the file restore and the
|
|
837
|
+
// registration can never disagree about what the user asked for.
|
|
838
|
+
//
|
|
839
|
+
// INVARIANT (mirrored at gate 1b of `offerKeybindingFix`): for a given
|
|
840
|
+
// configuration only one of the two paste-key flows acts — this one runs only
|
|
841
|
+
// when the suite will NOT claim the key, the offer only when it will. So no
|
|
842
|
+
// session creates `keybindings.json` and then empties it, and one run cannot
|
|
843
|
+
// say both "Created …" and "Restored …" about one file. A user who changes the
|
|
844
|
+
// setting between sessions legitimately gets *Created* first and *Restored*
|
|
845
|
+
// later: that is the reversible hatch, not the breach this forbids.
|
|
846
|
+
if (suiteMayClaimPasteKey()) {
|
|
847
|
+
return { kind: "suppressed" };
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const status = restoreNativePaste();
|
|
851
|
+
diag(
|
|
852
|
+
"image-paste",
|
|
853
|
+
`[archimedes:restore-native-paste] ${describeRestoreStatus(status)}`,
|
|
854
|
+
);
|
|
855
|
+
|
|
856
|
+
if (status.kind === "untouched" || status.kind === "failed") {
|
|
857
|
+
// "We did not touch it" — one actionable warning naming the path, spent at
|
|
858
|
+
// most once per process. What it asks of the user comes from the status
|
|
859
|
+
// itself (`manualEdit`, i.e. {@link guidanceLine}), because the refusals are
|
|
860
|
+
// not the same files: only a file that still holds an unbinding of the paste
|
|
861
|
+
// action has anything to delete, and issue #127 is the warning that used to
|
|
862
|
+
// order all of them to go look for one.
|
|
863
|
+
if (ctx.hasUI && claimRestoreNotice()) {
|
|
864
|
+
const failure =
|
|
865
|
+
status.kind === "failed" ? `The write failed (${status.error}). ` : "";
|
|
866
|
+
// `manualEdit` carries the second half of the promise too — that with the
|
|
867
|
+
// flag off image-paste registers NO paste shortcut — worded per refusal:
|
|
868
|
+
// "that key does nothing until you do" only where the file really does
|
|
869
|
+
// leave the key unbound (our `[]`, or pi's old name for it), "pi's own
|
|
870
|
+
// paste answers that key" where it does not, and "pi is running on its
|
|
871
|
+
// defaults, check the file" where it cannot be read at all. `describeRestoreStatus`
|
|
872
|
+
// feeds the SAME `manualEdit` to `diag`, so the log and the warning cannot
|
|
873
|
+
// tell two stories about one file — asserted, not merely intended, by the
|
|
874
|
+
// `keybinding-offer.test.ts` case that names the mutant it guards.
|
|
875
|
+
ctx.ui.notify(
|
|
876
|
+
`image-paste did not change ${status.path}. ${failure}${status.manualEdit}`,
|
|
877
|
+
"warning",
|
|
878
|
+
);
|
|
879
|
+
}
|
|
880
|
+
return status;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
if (status.kind === "restored") {
|
|
884
|
+
// Last use of ctx: reload() invalidates this extension instance.
|
|
885
|
+
// `notify: ctx.hasUI` keeps the silence promise for a UI-less ctx: the
|
|
886
|
+
// restore still runs and `diag` still records it, but no UI message is
|
|
887
|
+
// pushed at a host that said it has no UI.
|
|
888
|
+
const reloaded = await reloadAfterKeybindingChange(
|
|
889
|
+
ctx,
|
|
890
|
+
RESTORED_NOTIFY,
|
|
891
|
+
RESTORED_RELOAD_HINT,
|
|
892
|
+
{ notify: ctx.hasUI },
|
|
893
|
+
);
|
|
894
|
+
return { ...status, reloadRequired: !reloaded };
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
// "missing" / "already-restored": nothing happened and nothing is asked of
|
|
898
|
+
// the user, so no notify — the log line above is the record.
|
|
899
|
+
return status;
|
|
900
|
+
}
|
|
901
|
+
|