@pi-archimedes/image-paste 2.12.0 → 2.14.0
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 +5 -4
- package/package.json +5 -4
- package/src/default-export.test.ts +110 -13
- package/src/index.ts +67 -41
- package/src/keybinding-offer.test.ts +1 -1
- package/src/keybinding-offer.ts +1 -1
- package/src/paste-mutex.test.ts +9 -8
- package/src/preview-setting.test.ts +174 -0
- package/src/session-isolation.test.ts +154 -0
- package/src/settings.ts +33 -0
package/README.md
CHANGED
|
@@ -18,13 +18,13 @@ Or the full suite instead:
|
|
|
18
18
|
pi install npm:pi-archimedes
|
|
19
19
|
```
|
|
20
20
|
|
|
21
|
-
New to Pi? Pi itself is a one-time global install and needs Node.js ≥ 22.19.0. If you need Pi, [its quickstart](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/quickstart.md) starts with `npm install -g --ignore-scripts @earendil-works/pi-coding-agent`. After installing Pi, choose one installation command above, then `cd` into your project, run `pi`, and use `/login` and `/model` inside the session. The [setup section](https://github.com/
|
|
21
|
+
New to Pi? Pi itself is a one-time global install and needs Node.js ≥ 22.19.0. If you need Pi, [its quickstart](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/quickstart.md) starts with `npm install -g --ignore-scripts @earendil-works/pi-coding-agent`. After installing Pi, choose one installation command above, then `cd` into your project, run `pi`, and use `/login` and `/model` inside the session. The [setup section](https://github.com/archimedes-codes/pi-archimedes#setup) covers it end to end. `/reload` picks the extension up in a running session.
|
|
22
22
|
|
|
23
23
|
## How it works
|
|
24
24
|
|
|
25
25
|
- **Markers on paste** — pasting inserts a visible `[Image #1]` placeholder at the cursor; each paste gets the next number.
|
|
26
26
|
- **Attachment on submit** — when you send the message, your text is scanned for markers and the matching queued images are attached to it. Delete a marker and its image is not attached — the text is the source of truth.
|
|
27
|
-
- **Inline preview** — a preview of the attached images is sent and rendered in the TUI. Previews appear where your terminal renders inline images; on a terminal that can't show them, the image is still attached — only the preview falls back.
|
|
27
|
+
- **Inline preview** — a preview of the attached images is sent and rendered in the TUI. Previews appear where your terminal renders inline images; on a terminal that can't show them, the image is still attached — only the preview falls back. Turn it off with `/archimedes` → Image Paste Preview, or `archimedes.imagePaste.preview: false`; images still attach.
|
|
28
28
|
- **Same key, wider scope** — the paste key is Pi's own (`app.clipboard.pasteImage`), and taking it means covering everything it did, so with no image on the clipboard the handler falls back to what Pi does: copied **file paths** are inserted at the cursor with Pi's own spacing, and otherwise the clipboard **text** is inserted. One keypress produces one outcome, in that order: image, then paths, then text. Nothing on the clipboard leaves the draft untouched and says nothing, exactly as Pi does.
|
|
29
29
|
- **Size guard** — 20 MiB **per image**, rejected with a clear message before it wastes context or hangs the submit.
|
|
30
30
|
- **Bounded reads** — every clipboard read has a deadline (the same budget Pi gives its own clipboard commands), so a helper that stops answering — an X11/Wayland session that went unresponsive, say — reports `Clipboard paste failed: … timed out` instead of silently taking the paste key out of service for the rest of the session.
|
|
@@ -66,12 +66,13 @@ New to Pi? Pi itself is a one-time global install and needs Node.js ≥ 22.19.0.
|
|
|
66
66
|
|---------|------|---------|-------------|
|
|
67
67
|
| `enabled` | bool | `true` | On/off for the extension, via `/plugins` (suite-managed) |
|
|
68
68
|
| `suppressNativePaste` | bool | `true` | `false` gives the paste key back to Pi: the suite's `app.clipboard.pasteImage` entry is removed from `~/.pi/agent/keybindings.json` (only that entry, only if the file holds nothing else but the suite's own `"$schema"` line) **and** no paste shortcut is registered. It also keeps the first-run offer silent — there is nothing to unbind — without spending `keybindingsPromptDone`, so turning it back on still brings the offer. You get Pi's paste and lose image attach / queue / preview on those keys. Applies at the next `/new` or `/reload`. Anything that is not the strict boolean `false` — `"false"`, `0` — keeps the default. |
|
|
69
|
+
| `preview` | bool | `true` | `false` stops the inline preview of attached images on submit. Images still attach. Also in `/archimedes` → Image Paste Preview. Read on every submit, so no `/reload` needed. Anything that is not the strict boolean `false` keeps the default. |
|
|
69
70
|
| `keybindingsPromptDone` | bool | `false` | Set true once the first-run keybindings.json offer has been answered (accept or decline); delete to re-open the offer |
|
|
70
71
|
|
|
71
72
|
In the suite on/off is managed by the suite: toggle via `/plugins` (`archimedes.imagePaste.enabled`, default on).
|
|
72
73
|
|
|
73
74
|
## Part of the suite
|
|
74
75
|
|
|
75
|
-
In [pi-archimedes](https://github.com/
|
|
76
|
+
In [pi-archimedes](https://github.com/archimedes-codes/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).
|
|
76
77
|
|
|
77
|
-
← [Back to pi-archimedes](https://github.com/
|
|
78
|
+
← [Back to pi-archimedes](https://github.com/archimedes-codes/pi-archimedes)
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-archimedes/image-paste",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.14.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
|
-
"url": "https://github.com/
|
|
6
|
+
"url": "https://github.com/archimedes-codes/pi-archimedes.git"
|
|
7
7
|
},
|
|
8
8
|
"type": "module",
|
|
9
9
|
"keywords": [
|
|
@@ -16,10 +16,11 @@
|
|
|
16
16
|
"main": "./src/index.ts",
|
|
17
17
|
"exports": {
|
|
18
18
|
".": "./src/index.ts",
|
|
19
|
-
"./keybinding-offer": "./src/keybinding-offer.ts"
|
|
19
|
+
"./keybinding-offer": "./src/keybinding-offer.ts",
|
|
20
|
+
"./settings": "./src/settings.ts"
|
|
20
21
|
},
|
|
21
22
|
"dependencies": {
|
|
22
|
-
"@pi-archimedes/core": "2.
|
|
23
|
+
"@pi-archimedes/core": "2.14.0"
|
|
23
24
|
},
|
|
24
25
|
"peerDependencies": {
|
|
25
26
|
"@earendil-works/pi-coding-agent": ">=0.86.0",
|
|
@@ -11,10 +11,92 @@ vi.mock("./keybinding-offer.js", () => ({
|
|
|
11
11
|
suiteMayClaimPasteKey: vi.fn(() => true),
|
|
12
12
|
}));
|
|
13
13
|
|
|
14
|
+
// The isolation case below drives a real paste through the standalone handlers,
|
|
15
|
+
// so the clipboard readers are mocked (no shelling out) and the settings read is
|
|
16
|
+
// pointed at a scratch dir — `core/settings-io` captures
|
|
17
|
+
// `join(getAgentDir(), "settings.json")` at module load.
|
|
18
|
+
const { tempDir, fs, join, mockReadClipboardImage } = vi.hoisted(() => {
|
|
19
|
+
const fs = require("node:fs");
|
|
20
|
+
const { join } = require("node:path");
|
|
21
|
+
const { tmpdir } = require("node:os");
|
|
22
|
+
const { randomUUID } = require("node:crypto");
|
|
23
|
+
const dir = join(tmpdir(), `image-paste-default-export-${randomUUID()}`);
|
|
24
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
25
|
+
return { tempDir: dir, fs, join, mockReadClipboardImage: vi.fn() };
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
vi.mock("@earendil-works/pi-coding-agent", () => ({
|
|
29
|
+
getAgentDir: () => tempDir,
|
|
30
|
+
convertToPng: () => undefined,
|
|
31
|
+
}));
|
|
32
|
+
|
|
33
|
+
vi.mock("./clipboard.js", () => ({
|
|
34
|
+
readClipboardImage: mockReadClipboardImage,
|
|
35
|
+
readClipboardFilePaths: vi.fn(async () => null),
|
|
36
|
+
readClipboardText: vi.fn(async () => null),
|
|
37
|
+
buildPathInsertion: vi.fn(),
|
|
38
|
+
clipboardTextForEditor: vi.fn(),
|
|
39
|
+
}));
|
|
40
|
+
|
|
14
41
|
const { runNativePasteRestore } = await import("./keybinding-offer.js");
|
|
15
42
|
const defaultExport = (await import("./index.js")).default;
|
|
16
43
|
|
|
44
|
+
type Handler = (...args: unknown[]) => unknown;
|
|
45
|
+
|
|
46
|
+
interface SubmitResult {
|
|
47
|
+
action: string;
|
|
48
|
+
images?: unknown[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* One standalone session: a factory run of its own, with the handlers that run
|
|
53
|
+
* handed to pi. pi caches the extension MODULE and re-invokes the factory per
|
|
54
|
+
* session, so two of these are two sessions in one process (#156).
|
|
55
|
+
*/
|
|
56
|
+
function standaloneSession() {
|
|
57
|
+
const inserted: string[] = [];
|
|
58
|
+
const on = vi.fn();
|
|
59
|
+
const registerShortcut = vi.fn();
|
|
60
|
+
const pi = {
|
|
61
|
+
on,
|
|
62
|
+
registerShortcut,
|
|
63
|
+
registerMessageRenderer: vi.fn(),
|
|
64
|
+
sendMessage: vi.fn(),
|
|
65
|
+
} as any;
|
|
66
|
+
defaultExport(pi);
|
|
67
|
+
|
|
68
|
+
const ctx = {
|
|
69
|
+
hasUI: true,
|
|
70
|
+
ui: { notify: vi.fn(), pasteToEditor: (text: string) => inserted.push(text), getEditorText: () => "" },
|
|
71
|
+
};
|
|
72
|
+
const start = on.mock.calls.find((c: Array<unknown>) => c[0] === "session_start")?.[1] as Handler;
|
|
73
|
+
const shutdown = on.mock.calls.find((c: Array<unknown>) => c[0] === "session_shutdown")?.[1] as Handler;
|
|
74
|
+
const input = on.mock.calls.find((c: Array<unknown>) => c[0] === "input")?.[1] as Handler;
|
|
75
|
+
const press = registerShortcut.mock.calls[0]![1].handler as Handler;
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
inserted,
|
|
79
|
+
/** Fire this session's `session_start`. */
|
|
80
|
+
start: () => start({}, ctx),
|
|
81
|
+
/** Fire this session's `session_shutdown`. */
|
|
82
|
+
shutdown: () => shutdown({}, ctx),
|
|
83
|
+
/** Press this session's paste shortcut. */
|
|
84
|
+
press: () => press(ctx),
|
|
85
|
+
/** Submit `text` through this session's `input` handler. */
|
|
86
|
+
submit: (text: string) => input({ text, source: "interactive" }) as Promise<SubmitResult>,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
17
90
|
describe("image-paste default export (standalone factory)", () => {
|
|
91
|
+
beforeEach(() => {
|
|
92
|
+
fs.rmSync(join(tempDir, "settings.json"), { force: true });
|
|
93
|
+
mockReadClipboardImage.mockReset();
|
|
94
|
+
mockReadClipboardImage.mockResolvedValue({
|
|
95
|
+
bytes: new Uint8Array([1, 2, 3]),
|
|
96
|
+
mimeType: "image/png",
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
18
100
|
it("is a function that registers paste + per-session lifecycle on the pi extension", () => {
|
|
19
101
|
expect(typeof defaultExport).toBe("function");
|
|
20
102
|
|
|
@@ -37,19 +119,34 @@ describe("image-paste default export (standalone factory)", () => {
|
|
|
37
119
|
);
|
|
38
120
|
});
|
|
39
121
|
|
|
40
|
-
it("
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
expect(
|
|
52
|
-
|
|
122
|
+
it("a session's shutdown clears its OWN queue and leaves another session's intact", async () => {
|
|
123
|
+
// #156: pi caches the module and re-runs the factory per session, so the
|
|
124
|
+
// lifecycle handlers of two sessions coexist in one process. The old
|
|
125
|
+
// module-level state meant a child's `session_shutdown` cleared the MAIN
|
|
126
|
+
// session's queue (dead paste key, silently) and a child's
|
|
127
|
+
// `session_start` replaced it (the queued image never attached). Both
|
|
128
|
+
// halves are asserted here through the standalone wiring; the same
|
|
129
|
+
// guarantee through the named export is in session-isolation.test.ts.
|
|
130
|
+
const main = standaloneSession();
|
|
131
|
+
await main.start();
|
|
132
|
+
await main.press(); // main's queue now holds "[Image #1]"
|
|
133
|
+
expect(main.inserted).toEqual(["[Image #1] "]);
|
|
134
|
+
|
|
135
|
+
const child = standaloneSession();
|
|
136
|
+
await child.start();
|
|
137
|
+
await child.shutdown();
|
|
138
|
+
|
|
139
|
+
// The main session's queue survived the child's teardown: its shortcut
|
|
140
|
+
// reaches the clipboard again rather than returning at the `!queue` guard.
|
|
141
|
+
await main.press();
|
|
142
|
+
expect(mockReadClipboardImage).toHaveBeenCalledTimes(2);
|
|
143
|
+
expect(main.inserted).toEqual(["[Image #1] ", "[Image #2] "]);
|
|
144
|
+
|
|
145
|
+
// …and both queued images still attach on the main session's submit — the
|
|
146
|
+
// child's session_start did not orphan the first one.
|
|
147
|
+
const result = await main.submit("[Image #1] and [Image #2] look");
|
|
148
|
+
expect(result.action).toBe("transform");
|
|
149
|
+
expect(result.images).toHaveLength(2);
|
|
53
150
|
});
|
|
54
151
|
});
|
|
55
152
|
|
package/src/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from "./clipboard.js";
|
|
16
16
|
import { runNativePasteRestore, suiteMayClaimPasteKey } from "./keybinding-offer.js";
|
|
17
17
|
import { registerImagePreview, sendPreviewMessage } from "./preview.js";
|
|
18
|
+
import { previewEnabled } from "./settings.js";
|
|
18
19
|
import type { ClipboardImage, PendingImage, ImageMarker } from "./types.js";
|
|
19
20
|
|
|
20
21
|
const MAX_FILE_SIZE_BYTES = 20 * 1024 * 1024; // 20MB
|
|
@@ -171,12 +172,32 @@ function getImagePasteShortcuts(): KeyId[] {
|
|
|
171
172
|
return ["ctrl+v", "alt+v", "ctrl+alt+v"] as KeyId[];
|
|
172
173
|
}
|
|
173
174
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
175
|
+
/**
|
|
176
|
+
* The per-session handle `registerImagePaste` hands back (#156).
|
|
177
|
+
*
|
|
178
|
+
* pi caches an extension MODULE per process but re-invokes the factory with a
|
|
179
|
+
* fresh `pi` for every session, so state that lives at module scope belongs to
|
|
180
|
+
* the process, not to a session: a subordinate in-process session (a third-party
|
|
181
|
+
* subagent child) running its `session_shutdown` used to null the MAIN session's
|
|
182
|
+
* queue through the package's module-level teardown, and the paste handler
|
|
183
|
+
* then returned before reading the clipboard — silently, with no message. The
|
|
184
|
+
* handle is what makes "this session's state" expressible: whoever holds it can
|
|
185
|
+
* only ever touch the session that created it.
|
|
186
|
+
*/
|
|
187
|
+
export interface ImagePasteSession {
|
|
188
|
+
/** Adopt this session's ctx and start a fresh queue. Call from session_start. */
|
|
189
|
+
initSession(ctx: ExtensionContext): void;
|
|
190
|
+
/** Drop this session's state. Idempotent; touches nothing outside this session. */
|
|
191
|
+
shutdown(): void;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function registerImagePaste(pi: ExtensionAPI): ImagePasteSession {
|
|
195
|
+
// Per-session state — created by THIS factory run, reachable only through the
|
|
196
|
+
// handle returned below. Never module scope: see ImagePasteSession (#156).
|
|
197
|
+
let ctxRef: ExtensionContext | null = null;
|
|
198
|
+
let queue: ImageQueue | null = null;
|
|
199
|
+
let pasting = false;
|
|
178
200
|
|
|
179
|
-
export function registerImagePaste(pi: ExtensionAPI): void {
|
|
180
201
|
// Register preview renderer (once)
|
|
181
202
|
registerImagePreview(pi);
|
|
182
203
|
|
|
@@ -205,13 +226,13 @@ export function registerImagePaste(pi: ExtensionAPI): void {
|
|
|
205
226
|
// bounded (a `spawnSync` timeout per CLI candidate,
|
|
206
227
|
// `NATIVE_READ_TIMEOUT_MS` per native read) — because an unbounded read
|
|
207
228
|
// here would hold the mutex forever and take the key out of service.
|
|
208
|
-
if (
|
|
209
|
-
if (!
|
|
210
|
-
|
|
229
|
+
if (pasting) return;
|
|
230
|
+
if (!queue || !ctx.hasUI) return;
|
|
231
|
+
pasting = true;
|
|
211
232
|
try {
|
|
212
|
-
await dispatchClipboardPaste(ctx,
|
|
233
|
+
await dispatchClipboardPaste(ctx, queue);
|
|
213
234
|
} finally {
|
|
214
|
-
|
|
235
|
+
pasting = false;
|
|
215
236
|
}
|
|
216
237
|
};
|
|
217
238
|
|
|
@@ -234,13 +255,13 @@ export function registerImagePaste(pi: ExtensionAPI): void {
|
|
|
234
255
|
if (event.source === "extension") {
|
|
235
256
|
return { action: "continue" as const };
|
|
236
257
|
}
|
|
237
|
-
if (!
|
|
258
|
+
if (!queue || !queue.markers.length || !ctxRef) {
|
|
238
259
|
return { action: "continue" as const };
|
|
239
260
|
}
|
|
240
261
|
|
|
241
262
|
// ── Marker matching: use consistent key (trimmed text) ──
|
|
242
263
|
let hasMarkers = false;
|
|
243
|
-
for (const marker of
|
|
264
|
+
for (const marker of queue.markers) {
|
|
244
265
|
if (event.text.includes(markerKey(marker))) {
|
|
245
266
|
hasMarkers = true;
|
|
246
267
|
break;
|
|
@@ -249,19 +270,19 @@ export function registerImagePaste(pi: ExtensionAPI): void {
|
|
|
249
270
|
|
|
250
271
|
if (!hasMarkers) {
|
|
251
272
|
// No markers found — clear queue (user removed them)
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
273
|
+
queue.images.length = 0;
|
|
274
|
+
queue.markers.length = 0;
|
|
275
|
+
queue.nextIndex = 1;
|
|
255
276
|
return { action: "continue" as const };
|
|
256
277
|
}
|
|
257
278
|
|
|
258
279
|
// Match markers to images using trimmed key
|
|
259
280
|
const imagesToAttach: PendingImage[] = [];
|
|
260
281
|
|
|
261
|
-
for (const marker of
|
|
282
|
+
for (const marker of queue.markers) {
|
|
262
283
|
const key = markerKey(marker);
|
|
263
284
|
if (event.text.includes(key)) {
|
|
264
|
-
const pending =
|
|
285
|
+
const pending = queue.images.find((img) => img.id === marker.id);
|
|
265
286
|
if (pending) {
|
|
266
287
|
imagesToAttach.push(pending);
|
|
267
288
|
}
|
|
@@ -269,19 +290,21 @@ export function registerImagePaste(pi: ExtensionAPI): void {
|
|
|
269
290
|
}
|
|
270
291
|
|
|
271
292
|
// Clear queue
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
293
|
+
queue.images.length = 0;
|
|
294
|
+
queue.markers.length = 0;
|
|
295
|
+
queue.nextIndex = 1;
|
|
275
296
|
|
|
276
297
|
if (imagesToAttach.length === 0) {
|
|
277
298
|
return { action: "continue" as const };
|
|
278
299
|
}
|
|
279
300
|
|
|
280
301
|
// Send preview message for TUI display (images are invisible in user messages)
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
302
|
+
if (previewEnabled()) {
|
|
303
|
+
try {
|
|
304
|
+
sendPreviewMessage(pi, imagesToAttach);
|
|
305
|
+
} catch {
|
|
306
|
+
// Preview is optional — don't fail the submit
|
|
307
|
+
}
|
|
285
308
|
}
|
|
286
309
|
|
|
287
310
|
return {
|
|
@@ -294,29 +317,30 @@ export function registerImagePaste(pi: ExtensionAPI): void {
|
|
|
294
317
|
})),
|
|
295
318
|
};
|
|
296
319
|
});
|
|
297
|
-
}
|
|
298
320
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
321
|
+
return {
|
|
322
|
+
initSession(ctx: ExtensionContext): void {
|
|
323
|
+
ctxRef = ctx;
|
|
324
|
+
queue = createImageQueue();
|
|
325
|
+
},
|
|
326
|
+
shutdown(): void {
|
|
327
|
+
ctxRef = null;
|
|
328
|
+
queue = null;
|
|
329
|
+
pasting = false;
|
|
330
|
+
},
|
|
331
|
+
};
|
|
310
332
|
}
|
|
311
333
|
|
|
312
334
|
// ── Default export (for standalone pi.extensions loading) ─────────────────
|
|
313
335
|
|
|
314
336
|
// Pi's standalone loader requires a default export functioning as the
|
|
315
337
|
// extension factory. Meta imports the named exports directly (register
|
|
316
|
-
// +
|
|
338
|
+
// + the handle it returns), so this default export is standalone-only.
|
|
317
339
|
export default function (pi: ExtensionAPI): void {
|
|
318
|
-
registerImagePaste(pi);
|
|
319
|
-
// Session lifecycle: pi requires per-session ctx for image pasting
|
|
340
|
+
const session = registerImagePaste(pi);
|
|
341
|
+
// Session lifecycle: pi requires per-session ctx for image pasting, and the
|
|
342
|
+
// state now lives in the handle above, so this session can only ever reset
|
|
343
|
+
// its own queue (#156 — a foreign session's shutdown used to clear this one).
|
|
320
344
|
//
|
|
321
345
|
// Registered at the TOP LEVEL of this factory — never inside another
|
|
322
346
|
// session_start handler, which would accumulate handlers on /reload
|
|
@@ -329,7 +353,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
329
353
|
// from keybindings.json, and the gate in `registerImagePaste` above stops us
|
|
330
354
|
// claiming the key. Both read the same setting per session.
|
|
331
355
|
pi.on("session_start", async (_event, ctx) => {
|
|
332
|
-
|
|
356
|
+
// The queue must be live BEFORE the restore: the restore can reload, and a
|
|
357
|
+
// session that reloaded with no queue would have a dead paste key.
|
|
358
|
+
session.initSession(ctx);
|
|
333
359
|
// A broken keybindings.json must never fail a session start; the detail
|
|
334
360
|
// goes to the log sink, never the console.
|
|
335
361
|
try {
|
|
@@ -342,6 +368,6 @@ export default function (pi: ExtensionAPI): void {
|
|
|
342
368
|
}
|
|
343
369
|
});
|
|
344
370
|
pi.on("session_shutdown", (_event, _ctx) => {
|
|
345
|
-
|
|
371
|
+
session.shutdown();
|
|
346
372
|
});
|
|
347
373
|
}
|
|
@@ -223,7 +223,7 @@ describe("offerKeybindingFix — gate matrix", () => {
|
|
|
223
223
|
if (mode !== "tui") expect(confirm).not.toHaveBeenCalled();
|
|
224
224
|
|
|
225
225
|
if (shouldAsk) {
|
|
226
|
-
// confirm is called with the TITLE FIRST (
|
|
226
|
+
// confirm is called with the TITLE FIRST (confirm(title, message))
|
|
227
227
|
const [title, message] = callArgs(confirm)[0] as [string, string];
|
|
228
228
|
expect(title).toBe("First run");
|
|
229
229
|
expect(typeof message).toBe("string");
|
package/src/keybinding-offer.ts
CHANGED
|
@@ -308,7 +308,7 @@ export async function offerKeybindingFix(ctx: ExtensionContext): Promise<boolean
|
|
|
308
308
|
return false;
|
|
309
309
|
}
|
|
310
310
|
|
|
311
|
-
// Gate 5: ask (confirm
|
|
311
|
+
// Gate 5: ask (confirm(title, message): TITLE first — pi's types.d.ts)
|
|
312
312
|
const confirmed = await ctx.ui.confirm(CONFIRM_TITLE, CONFIRM_MESSAGE);
|
|
313
313
|
|
|
314
314
|
// No, or cancel (Esc/timeout — `confirm` resolves `false` either way counts
|
package/src/paste-mutex.test.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
|
|
3
|
-
// The mutex
|
|
4
|
-
// `clipboard.ts`, so this file deliberately mocks
|
|
5
|
-
// runs against a faked clipboard helper and a faked
|
|
6
|
-
// only way to ask the question that matters to a
|
|
3
|
+
// The mutex is the `pasting` flag in `registerImagePaste` (per session since
|
|
4
|
+
// #156) and the readers live in `clipboard.ts`, so this file deliberately mocks
|
|
5
|
+
// *neither*: the real dispatch runs against a faked clipboard helper and a faked
|
|
6
|
+
// command runner, which is the only way to ask the question that matters to a
|
|
7
|
+
// user — does `ctrl+v` come back?
|
|
7
8
|
const { mockGetNativeClipboard, mockSpawnSync } = vi.hoisted(() => ({
|
|
8
9
|
mockGetNativeClipboard: vi.fn(),
|
|
9
10
|
mockSpawnSync: vi.fn(),
|
|
@@ -21,7 +22,7 @@ vi.mock("node:child_process", async (importOriginal) => {
|
|
|
21
22
|
|
|
22
23
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
23
24
|
|
|
24
|
-
import {
|
|
25
|
+
import { registerImagePaste } from "./index.js";
|
|
25
26
|
import { NATIVE_READ_TIMEOUT_MS } from "./clipboard.js";
|
|
26
27
|
|
|
27
28
|
/** Every CLI candidate is missing, so the native helper is the only reader. */
|
|
@@ -64,8 +65,8 @@ function createHarness(): Harness {
|
|
|
64
65
|
on: () => {},
|
|
65
66
|
} as unknown as ExtensionAPI;
|
|
66
67
|
|
|
67
|
-
registerImagePaste(pi);
|
|
68
|
-
|
|
68
|
+
const session = registerImagePaste(pi);
|
|
69
|
+
session.initSession(ctx);
|
|
69
70
|
const pressKey = handlers.get("ctrl+v")!;
|
|
70
71
|
return { ctx, press: () => pressKey(ctx), inserted, notifications };
|
|
71
72
|
}
|
|
@@ -121,7 +122,7 @@ describe("the ctrl+v mutex against a clipboard that never answers", () => {
|
|
|
121
122
|
await vi.advanceTimersByTimeAsync(NATIVE_READ_TIMEOUT_MS * 3);
|
|
122
123
|
// RED without a deadline on the native read: this await never finishes,
|
|
123
124
|
// the promise stays pending, and every later press is dropped by the
|
|
124
|
-
// `
|
|
125
|
+
// `pasting` mutex — `ctrl+v` is dead until the session restarts, with no
|
|
125
126
|
// message saying so (measured live: first press still pending after
|
|
126
127
|
// 7.1 s, both later presses silently swallowed, nothing notified).
|
|
127
128
|
await expect(press).resolves.toBeUndefined();
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
// `archimedes.imagePaste.preview: false` skips the inline preview message on
|
|
4
|
+
// submit; the image still attaches and the paste-time notify still fires.
|
|
5
|
+
//
|
|
6
|
+
// Settings: `core/settings-io` captures `join(getAgentDir(), "settings.json")`
|
|
7
|
+
// at module load, hence one scratch dir for the whole file, rewritten per case.
|
|
8
|
+
// Clipboard: the readers are mocked so a paste yields image bytes without
|
|
9
|
+
// shelling out.
|
|
10
|
+
|
|
11
|
+
const { tempDir, fs, join, mockReadClipboardImage } = vi.hoisted(() => {
|
|
12
|
+
const fs = require("node:fs");
|
|
13
|
+
const { join } = require("node:path");
|
|
14
|
+
const { tmpdir } = require("node:os");
|
|
15
|
+
const { randomUUID } = require("node:crypto");
|
|
16
|
+
const dir = join(tmpdir(), `image-paste-preview-setting-${randomUUID()}`);
|
|
17
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
18
|
+
return { tempDir: dir, fs, join, mockReadClipboardImage: vi.fn() };
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
vi.mock("@earendil-works/pi-coding-agent", () => ({
|
|
22
|
+
getAgentDir: () => tempDir,
|
|
23
|
+
convertToPng: () => undefined,
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
vi.mock("./clipboard.js", () => ({
|
|
27
|
+
readClipboardImage: mockReadClipboardImage,
|
|
28
|
+
readClipboardFilePaths: vi.fn(async () => null),
|
|
29
|
+
readClipboardText: vi.fn(async () => null),
|
|
30
|
+
buildPathInsertion: vi.fn(),
|
|
31
|
+
clipboardTextForEditor: vi.fn(),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
const { registerImagePaste } = await import("./index.js");
|
|
35
|
+
type ImagePasteSession = ReturnType<typeof registerImagePaste>;
|
|
36
|
+
const { saveImagePastePreview } = await import("./settings.js");
|
|
37
|
+
|
|
38
|
+
const NAMESPACE = "archimedes.imagePaste";
|
|
39
|
+
const settingsPath = (): string => join(tempDir, "settings.json");
|
|
40
|
+
|
|
41
|
+
function writeSettings(imagePaste: Record<string, unknown> | undefined): void {
|
|
42
|
+
const settings: Record<string, unknown> = {};
|
|
43
|
+
if (imagePaste !== undefined) settings[NAMESPACE] = imagePaste;
|
|
44
|
+
fs.writeFileSync(settingsPath(), JSON.stringify(settings), "utf-8");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type Handler = (...args: unknown[]) => unknown;
|
|
48
|
+
|
|
49
|
+
interface SubmitResult {
|
|
50
|
+
action: string;
|
|
51
|
+
images?: unknown[];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The handle of the most recently registered session. State is per session now
|
|
55
|
+
// (#156), so the resets below drop exactly the session the previous case left
|
|
56
|
+
// behind — the module-level `shutdownImagePaste()` they replace dropped every
|
|
57
|
+
// session's at once, which was the bug.
|
|
58
|
+
let activeSession: ImagePasteSession | null = null;
|
|
59
|
+
|
|
60
|
+
/** Register once; `pasteAndSubmit()` then drives the same handlers each time. */
|
|
61
|
+
function registerSession() {
|
|
62
|
+
const pi = {
|
|
63
|
+
on: vi.fn(),
|
|
64
|
+
registerShortcut: vi.fn(),
|
|
65
|
+
registerMessageRenderer: vi.fn(),
|
|
66
|
+
registerCommand: vi.fn(),
|
|
67
|
+
sendMessage: vi.fn(),
|
|
68
|
+
};
|
|
69
|
+
const notify = vi.fn();
|
|
70
|
+
const ctx = { hasUI: true, ui: { notify, pasteToEditor: vi.fn() } };
|
|
71
|
+
|
|
72
|
+
const session = registerImagePaste(pi as never);
|
|
73
|
+
session.initSession(ctx as never);
|
|
74
|
+
activeSession = session;
|
|
75
|
+
|
|
76
|
+
const paste = pi.registerShortcut.mock.calls[0]![1].handler as Handler;
|
|
77
|
+
const input = pi.on.mock.calls.find((call) => call[0] === "input")![1] as Handler;
|
|
78
|
+
|
|
79
|
+
/** Paste one image through the shortcut and submit it. */
|
|
80
|
+
async function pasteAndSubmit(): Promise<SubmitResult> {
|
|
81
|
+
await paste(ctx);
|
|
82
|
+
return (await input({ text: "[Image #1] look", source: "interactive" })) as SubmitResult;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { pi, notify, pasteAndSubmit, session };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Register, paste one image through the shortcut, submit it; report what happened. */
|
|
89
|
+
async function pasteAndSubmit() {
|
|
90
|
+
const session = registerSession();
|
|
91
|
+
const result = await session.pasteAndSubmit();
|
|
92
|
+
return { pi: session.pi, notify: session.notify, result };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
beforeEach(() => {
|
|
96
|
+
fs.rmSync(settingsPath(), { force: true });
|
|
97
|
+
activeSession?.shutdown();
|
|
98
|
+
activeSession = null;
|
|
99
|
+
mockReadClipboardImage.mockReset();
|
|
100
|
+
mockReadClipboardImage.mockResolvedValue({ bytes: new Uint8Array([1, 2, 3]), mimeType: "image/png" });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe("archimedes.imagePaste.preview", () => {
|
|
104
|
+
it("sends the preview when the setting is absent", async () => {
|
|
105
|
+
const { pi, result } = await pasteAndSubmit();
|
|
106
|
+
expect(result.action).toBe("transform");
|
|
107
|
+
expect(pi.sendMessage).toHaveBeenCalledTimes(1);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("sends the preview when preview is true", async () => {
|
|
111
|
+
writeSettings({ preview: true });
|
|
112
|
+
const { pi } = await pasteAndSubmit();
|
|
113
|
+
expect(pi.sendMessage).toHaveBeenCalledTimes(1);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("skips the preview when preview is false, but still notifies and attaches", async () => {
|
|
117
|
+
writeSettings({ preview: false });
|
|
118
|
+
const { pi, notify, result } = await pasteAndSubmit();
|
|
119
|
+
|
|
120
|
+
expect(pi.sendMessage).not.toHaveBeenCalled();
|
|
121
|
+
expect(notify).toHaveBeenCalledWith("Image attached from clipboard.", "info");
|
|
122
|
+
expect(result.action).toBe("transform");
|
|
123
|
+
expect(result.images).toHaveLength(1);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("re-reads the setting on each submit through the same registered handler (no /reload)", async () => {
|
|
127
|
+
const session = registerSession();
|
|
128
|
+
|
|
129
|
+
const first = await session.pasteAndSubmit();
|
|
130
|
+
expect(session.pi.sendMessage).toHaveBeenCalledTimes(1);
|
|
131
|
+
expect(first.action).toBe("transform");
|
|
132
|
+
expect(first.images).toHaveLength(1);
|
|
133
|
+
|
|
134
|
+
writeSettings({ preview: false });
|
|
135
|
+
const second = await session.pasteAndSubmit();
|
|
136
|
+
expect(session.pi.sendMessage).toHaveBeenCalledTimes(1);
|
|
137
|
+
expect(second.action).toBe("transform");
|
|
138
|
+
expect(second.images).toHaveLength(1);
|
|
139
|
+
|
|
140
|
+
writeSettings({ preview: true });
|
|
141
|
+
const third = await session.pasteAndSubmit();
|
|
142
|
+
expect(session.pi.sendMessage).toHaveBeenCalledTimes(2);
|
|
143
|
+
expect(third.images).toHaveLength(1);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("treats a value that is not the boolean false as on", async () => {
|
|
147
|
+
for (const value of ["false", 0]) {
|
|
148
|
+
writeSettings({ preview: value });
|
|
149
|
+
// Drop the previous iteration's session (its own handle — #156).
|
|
150
|
+
activeSession?.shutdown();
|
|
151
|
+
const { pi } = await pasteAndSubmit();
|
|
152
|
+
expect(pi.sendMessage, `preview=${JSON.stringify(value)}`).toHaveBeenCalledTimes(1);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe("saveImagePastePreview (the /archimedes row)", () => {
|
|
158
|
+
it("merges preview into the namespace, keeps its other keys, and the next submit obeys it", async () => {
|
|
159
|
+
writeSettings({ enabled: true, suppressNativePaste: true, keybindingsPromptDone: true });
|
|
160
|
+
const session = registerSession();
|
|
161
|
+
|
|
162
|
+
saveImagePastePreview(false);
|
|
163
|
+
expect(JSON.parse(fs.readFileSync(settingsPath(), "utf-8"))[NAMESPACE]).toEqual({
|
|
164
|
+
enabled: true,
|
|
165
|
+
suppressNativePaste: true,
|
|
166
|
+
keybindingsPromptDone: true,
|
|
167
|
+
preview: false,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
const result = await session.pasteAndSubmit();
|
|
171
|
+
expect(session.pi.sendMessage).not.toHaveBeenCalled();
|
|
172
|
+
expect(result.images).toHaveLength(1);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
|
|
5
|
+
// ── #156: one module instance, two sessions, no shared queue ────────────────
|
|
6
|
+
//
|
|
7
|
+
// pi caches an extension MODULE per process but re-invokes its factory with a
|
|
8
|
+
// fresh `pi` per session, so a subordinate in-process session (a third-party
|
|
9
|
+
// subagent child) shares the module with the main session. When that child ran
|
|
10
|
+
// its `session_shutdown`, the package's module-level `shutdownImagePaste()`
|
|
11
|
+
// nulled the MAIN session's queue and the paste handler returned at
|
|
12
|
+
// `if (!queue || !ctx.hasUI) return;` before touching the clipboard — silently.
|
|
13
|
+
// Reproduced live against pi 1.5.0 with a control: `alt+v` attached `[Image #1]`
|
|
14
|
+
// before the child's shutdown and produced literally nothing after it.
|
|
15
|
+
//
|
|
16
|
+
// The fix is a handle: `registerImagePaste` creates its state per factory run
|
|
17
|
+
// and hands back `{ initSession, shutdown }`, so a session can only ever drop
|
|
18
|
+
// its own state. This file drives TWO sessions through ONE module instance —
|
|
19
|
+
// which is exactly how pi runs it — and asserts the main session survives both
|
|
20
|
+
// foreign lifecycle events. Assertions come from the `input` handler's return
|
|
21
|
+
// value, so no pi internals are involved.
|
|
22
|
+
//
|
|
23
|
+
// Settings: `core/settings-io` captures `join(getAgentDir(), "settings.json")`
|
|
24
|
+
// at module load, hence one scratch dir for the whole file (absent settings =
|
|
25
|
+
// the defaults: shortcuts claimed, preview on). Clipboard: the readers are
|
|
26
|
+
// mocked so a paste yields image bytes without shelling out.
|
|
27
|
+
|
|
28
|
+
const { tempDir, fs, join, mockReadClipboardImage } = vi.hoisted(() => {
|
|
29
|
+
const fs = require("node:fs");
|
|
30
|
+
const { join } = require("node:path");
|
|
31
|
+
const { tmpdir } = require("node:os");
|
|
32
|
+
const { randomUUID } = require("node:crypto");
|
|
33
|
+
const dir = join(tmpdir(), `image-paste-session-isolation-${randomUUID()}`);
|
|
34
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
35
|
+
return { tempDir: dir, fs, join, mockReadClipboardImage: vi.fn() };
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
vi.mock("@earendil-works/pi-coding-agent", () => ({
|
|
39
|
+
getAgentDir: () => tempDir,
|
|
40
|
+
convertToPng: () => undefined,
|
|
41
|
+
}));
|
|
42
|
+
|
|
43
|
+
vi.mock("./clipboard.js", () => ({
|
|
44
|
+
readClipboardImage: mockReadClipboardImage,
|
|
45
|
+
readClipboardFilePaths: vi.fn(async () => null),
|
|
46
|
+
readClipboardText: vi.fn(async () => null),
|
|
47
|
+
buildPathInsertion: vi.fn(),
|
|
48
|
+
clipboardTextForEditor: vi.fn(),
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
const { registerImagePaste } = await import("./index.js");
|
|
52
|
+
|
|
53
|
+
type Handler = (...args: unknown[]) => unknown;
|
|
54
|
+
|
|
55
|
+
interface SubmitResult {
|
|
56
|
+
action: string;
|
|
57
|
+
images?: unknown[];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* One session, exactly as pi builds one: a fresh `pi` handle through the same
|
|
62
|
+
* module instance. `initSession()` / `shutdown()` are the handle's lifecycle
|
|
63
|
+
* methods (the session's `session_start` / `session_shutdown`); `paste` and
|
|
64
|
+
* `input` drive the handlers registration handed to pi.
|
|
65
|
+
*/
|
|
66
|
+
function makeSession() {
|
|
67
|
+
const pi = {
|
|
68
|
+
on: vi.fn(),
|
|
69
|
+
registerShortcut: vi.fn(),
|
|
70
|
+
registerMessageRenderer: vi.fn(),
|
|
71
|
+
registerCommand: vi.fn(),
|
|
72
|
+
sendMessage: vi.fn(),
|
|
73
|
+
};
|
|
74
|
+
const inserted: string[] = [];
|
|
75
|
+
const notifications: Array<[string, string | undefined]> = [];
|
|
76
|
+
// A partial ctx, cast the way paste-mutex.test.ts casts its harness ctx: the
|
|
77
|
+
// package only ever reads `hasUI` and `ui` from it.
|
|
78
|
+
const ctx = {
|
|
79
|
+
hasUI: true,
|
|
80
|
+
ui: {
|
|
81
|
+
notify: (message: string, type?: "info" | "warning" | "error") => {
|
|
82
|
+
notifications.push([message, type]);
|
|
83
|
+
},
|
|
84
|
+
pasteToEditor: (text: string) => {
|
|
85
|
+
inserted.push(text);
|
|
86
|
+
},
|
|
87
|
+
getEditorText: () => "",
|
|
88
|
+
},
|
|
89
|
+
} as unknown as ExtensionContext;
|
|
90
|
+
|
|
91
|
+
const session = registerImagePaste(pi as never);
|
|
92
|
+
|
|
93
|
+
const pasteHandler = pi.registerShortcut.mock.calls[0]![1].handler as Handler;
|
|
94
|
+
const inputHandler = pi.on.mock.calls.find((call) => call[0] === "input")![1] as Handler;
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
...session,
|
|
98
|
+
pi,
|
|
99
|
+
ctx,
|
|
100
|
+
inserted,
|
|
101
|
+
notifications,
|
|
102
|
+
/** Press the package's paste shortcut for this session. */
|
|
103
|
+
paste: async (context = ctx) => {
|
|
104
|
+
await pasteHandler(context);
|
|
105
|
+
},
|
|
106
|
+
/** Submit an event through this session's `input` handler. */
|
|
107
|
+
input: async (event: { text: string; source: string }): Promise<SubmitResult> =>
|
|
108
|
+
(await inputHandler(event)) as SubmitResult,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
beforeEach(() => {
|
|
113
|
+
fs.rmSync(join(tempDir, "settings.json"), { force: true });
|
|
114
|
+
mockReadClipboardImage.mockReset();
|
|
115
|
+
mockReadClipboardImage.mockResolvedValue({ bytes: new Uint8Array([1, 2, 3]), mimeType: "image/png" });
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
describe("#156 image-paste session isolation", () => {
|
|
119
|
+
it("a foreign session's shutdown does not stop the main session's paste", async () => {
|
|
120
|
+
const main = makeSession(); // registerImagePaste(mainPi) + main.initSession(mainCtx)
|
|
121
|
+
main.initSession(main.ctx); // main session_start
|
|
122
|
+
|
|
123
|
+
const child = makeSession(); // second factory run, same module — pi does this per session
|
|
124
|
+
child.initSession(child.ctx); // child session_start
|
|
125
|
+
child.shutdown(); // child session_shutdown (reason: "quit")
|
|
126
|
+
|
|
127
|
+
// 1. main's shortcut still reaches the clipboard. Pre-fix this returned at
|
|
128
|
+
// the `!queue` guard and the reader never ran — no notify, no marker, no
|
|
129
|
+
// message: a dead key.
|
|
130
|
+
await main.paste(main.ctx);
|
|
131
|
+
|
|
132
|
+
expect(mockReadClipboardImage, "the reader never ran for the main session").toHaveBeenCalledTimes(1);
|
|
133
|
+
expect(main.inserted).toEqual(["[Image #1] "]);
|
|
134
|
+
expect(main.notifications).toEqual([["Image attached from clipboard.", "info"]]);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it("a foreign session_start does not orphan the main session's queued image", async () => {
|
|
138
|
+
const main = makeSession();
|
|
139
|
+
main.initSession(main.ctx);
|
|
140
|
+
await main.paste(main.ctx); // the marker "[Image #1]" is in the editor
|
|
141
|
+
expect(main.inserted).toEqual(["[Image #1] "]);
|
|
142
|
+
|
|
143
|
+
const child = makeSession();
|
|
144
|
+
child.initSession(child.ctx); // child session_start only — no shutdown
|
|
145
|
+
|
|
146
|
+
// 2. main's queued image still attaches on submit. Pre-fix `_queue` was
|
|
147
|
+
// reassigned by the child's session_start, so main's marker matched
|
|
148
|
+
// nothing and the submit silently dropped the attached image.
|
|
149
|
+
const result = await main.input({ text: "[Image #1] look", source: "interactive" });
|
|
150
|
+
|
|
151
|
+
expect(result.action).toBe("transform");
|
|
152
|
+
expect(result.images).toHaveLength(1);
|
|
153
|
+
});
|
|
154
|
+
});
|
package/src/settings.ts
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { SettingItem } from "@earendil-works/pi-tui";
|
|
2
|
+
|
|
3
|
+
import { loadConfig, updateConfig } from "@pi-archimedes/core/settings-io";
|
|
4
|
+
|
|
5
|
+
// Kept free of the package's main entry (clipboard, renderer) so meta's
|
|
6
|
+
// /archimedes panel can import it without loading the lazily-loaded plugin.
|
|
7
|
+
|
|
8
|
+
const NAMESPACE = "archimedes.imagePaste";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `archimedes.imagePaste.preview`, default on. Only the strict boolean `false`
|
|
12
|
+
* disables it. Read per call so a settings change applies on the next submit.
|
|
13
|
+
*/
|
|
14
|
+
export function previewEnabled(): boolean {
|
|
15
|
+
return loadConfig<{ preview: boolean }>(NAMESPACE, { preview: true }).preview !== false;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function getImagePasteSettingsItems(): SettingItem[] {
|
|
19
|
+
return [
|
|
20
|
+
{
|
|
21
|
+
id: "imagePastePreview",
|
|
22
|
+
label: "Image Paste Preview",
|
|
23
|
+
description: "Show an inline preview of pasted images after you submit",
|
|
24
|
+
currentValue: previewEnabled() ? "On" : "Off",
|
|
25
|
+
values: ["On", "Off"],
|
|
26
|
+
},
|
|
27
|
+
];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Merges `preview` into the namespace; its other keys are kept. */
|
|
31
|
+
export function saveImagePastePreview(on: boolean): void {
|
|
32
|
+
updateConfig<Record<string, unknown>>(NAMESPACE, {}, (cfg) => ({ ...cfg, preview: on }));
|
|
33
|
+
}
|