@pi-archimedes/image-paste 2.7.3 → 2.9.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 +3 -3
- package/package.json +6 -6
- package/src/clipboard.test.ts +311 -0
- package/src/clipboard.ts +50 -60
- package/src/keybinding-offer.test.ts +29 -13
- package/src/keybinding-offer.ts +29 -11
package/README.md
CHANGED
|
@@ -47,9 +47,9 @@ New to Pi? Pi itself is a one-time global install and needs Node.js ≥ 22.19.0.
|
|
|
47
47
|
|
|
48
48
|
## Per-platform requirements
|
|
49
49
|
|
|
50
|
-
- **Linux** — a graphical session (`DISPLAY` or `WAYLAND_DISPLAY`) and one of `wl-clipboard` (tried first on Wayland sessions), `xclip` (tried first on X11), or
|
|
51
|
-
- **macOS** —
|
|
52
|
-
- **Windows** —
|
|
50
|
+
- **Linux** — a graphical session (`DISPLAY` or `WAYLAND_DISPLAY`) and one of `wl-clipboard` (tried first on Wayland sessions), `xclip` (tried first on X11), or `@earendil-works/pi-tui` native clipboard bindings. Termux is not supported.
|
|
51
|
+
- **macOS** — `@earendil-works/pi-tui` bundled native clipboard helper.
|
|
52
|
+
- **Windows** — `@earendil-works/pi-tui` native clipboard helper first, with a PowerShell fallback.
|
|
53
53
|
|
|
54
54
|
## Settings
|
|
55
55
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pi-archimedes/image-paste",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.9.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/danielcherubini/pi-archimedes.git"
|
|
@@ -19,15 +19,15 @@
|
|
|
19
19
|
"./keybinding-offer": "./src/keybinding-offer.ts"
|
|
20
20
|
},
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@pi-archimedes/core": "2.
|
|
22
|
+
"@pi-archimedes/core": "2.9.0"
|
|
23
23
|
},
|
|
24
24
|
"peerDependencies": {
|
|
25
|
-
"@earendil-works/pi-coding-agent": ">=0.
|
|
26
|
-
"@earendil-works/pi-tui": ">=0.
|
|
25
|
+
"@earendil-works/pi-coding-agent": ">=0.86.0",
|
|
26
|
+
"@earendil-works/pi-tui": ">=0.86.0"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
|
-
"@earendil-works/pi-coding-agent": "^0.
|
|
30
|
-
"@earendil-works/pi-tui": "^0.
|
|
29
|
+
"@earendil-works/pi-coding-agent": "^0.87.0",
|
|
30
|
+
"@earendil-works/pi-tui": "^0.87.0",
|
|
31
31
|
"typescript": "^6.0.0"
|
|
32
32
|
},
|
|
33
33
|
"pi": {
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { EventEmitter } from "node:events";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
|
|
4
|
+
const { mockGetImage, mockGetNativeClipboard, mockConvertToPng, mockSpawn, mockSpawnSync } = vi.hoisted(() => {
|
|
5
|
+
return {
|
|
6
|
+
mockGetImage: vi.fn(),
|
|
7
|
+
mockGetNativeClipboard: vi.fn(),
|
|
8
|
+
mockConvertToPng: vi.fn(),
|
|
9
|
+
mockSpawn: vi.fn(),
|
|
10
|
+
mockSpawnSync: vi.fn(),
|
|
11
|
+
};
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
vi.mock("@earendil-works/pi-tui", () => ({
|
|
15
|
+
getNativeClipboard: mockGetNativeClipboard,
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
vi.mock("@earendil-works/pi-coding-agent", () => ({
|
|
19
|
+
convertToPng: mockConvertToPng,
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
vi.mock("node:child_process", async (importOriginal) => {
|
|
23
|
+
const actual = await importOriginal<typeof import("node:child_process")>();
|
|
24
|
+
return {
|
|
25
|
+
...actual,
|
|
26
|
+
spawn: mockSpawn,
|
|
27
|
+
spawnSync: mockSpawnSync,
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
import { readClipboardImage, readClipboardImageViaNativeModule } from "./clipboard.js";
|
|
32
|
+
|
|
33
|
+
function createMockChildProcess(stdoutData = "", exitCode = 0) {
|
|
34
|
+
const child = new EventEmitter() as any;
|
|
35
|
+
child.stdout = new EventEmitter();
|
|
36
|
+
child.kill = vi.fn();
|
|
37
|
+
setTimeout(() => {
|
|
38
|
+
if (stdoutData) {
|
|
39
|
+
child.stdout.emit("data", Buffer.from(stdoutData));
|
|
40
|
+
}
|
|
41
|
+
child.emit("close", exitCode);
|
|
42
|
+
}, 5);
|
|
43
|
+
return child;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("readClipboardImage", () => {
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
vi.clearAllMocks();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("macOS path", () => {
|
|
52
|
+
it("returns PNG image when getNativeClipboard() returns PNG bytes", async () => {
|
|
53
|
+
const pngBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
54
|
+
mockGetImage.mockResolvedValue(pngBytes);
|
|
55
|
+
mockGetNativeClipboard.mockReturnValue({ getImage: mockGetImage });
|
|
56
|
+
|
|
57
|
+
const result = await readClipboardImage({ platform: "darwin", environment: {} });
|
|
58
|
+
|
|
59
|
+
expect(mockGetNativeClipboard).toHaveBeenCalled();
|
|
60
|
+
expect(mockGetImage).toHaveBeenCalled();
|
|
61
|
+
expect(result).toEqual({
|
|
62
|
+
bytes: pngBytes,
|
|
63
|
+
mimeType: "image/png",
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("returns null when getNativeClipboard() returns empty (null)", async () => {
|
|
68
|
+
mockGetImage.mockResolvedValue(null);
|
|
69
|
+
mockGetNativeClipboard.mockReturnValue({ getImage: mockGetImage });
|
|
70
|
+
|
|
71
|
+
const result = await readClipboardImage({ platform: "darwin", environment: {} });
|
|
72
|
+
|
|
73
|
+
expect(mockGetNativeClipboard).toHaveBeenCalled();
|
|
74
|
+
expect(mockGetImage).toHaveBeenCalled();
|
|
75
|
+
expect(result).toBeNull();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("returns null when getNativeClipboard() returns empty Uint8Array", async () => {
|
|
79
|
+
mockGetImage.mockResolvedValue(new Uint8Array(0));
|
|
80
|
+
mockGetNativeClipboard.mockReturnValue({ getImage: mockGetImage });
|
|
81
|
+
|
|
82
|
+
const result = await readClipboardImage({ platform: "darwin", environment: {} });
|
|
83
|
+
|
|
84
|
+
expect(mockGetNativeClipboard).toHaveBeenCalled();
|
|
85
|
+
expect(mockGetImage).toHaveBeenCalled();
|
|
86
|
+
expect(result).toBeNull();
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("Windows path", () => {
|
|
91
|
+
it("converts BMP bytes to PNG via convertToPng and returns { bytes, mimeType }", async () => {
|
|
92
|
+
const bmpBytes = new Uint8Array([0x42, 0x4d, 0x10, 0x20, 0x30]);
|
|
93
|
+
mockGetImage.mockResolvedValue(bmpBytes);
|
|
94
|
+
mockGetNativeClipboard.mockReturnValue({ getImage: mockGetImage });
|
|
95
|
+
|
|
96
|
+
const pngBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 1, 2, 3]);
|
|
97
|
+
const base64Png = Buffer.from(pngBytes).toString("base64");
|
|
98
|
+
mockConvertToPng.mockResolvedValue({ data: base64Png, mimeType: "image/png" });
|
|
99
|
+
|
|
100
|
+
const result = await readClipboardImage({ platform: "win32", environment: {} });
|
|
101
|
+
|
|
102
|
+
expect(mockGetNativeClipboard).toHaveBeenCalled();
|
|
103
|
+
expect(mockGetImage).toHaveBeenCalled();
|
|
104
|
+
expect(mockConvertToPng).toHaveBeenCalledWith(
|
|
105
|
+
Buffer.from(bmpBytes).toString("base64"),
|
|
106
|
+
"image/bmp",
|
|
107
|
+
);
|
|
108
|
+
expect(result).toEqual({
|
|
109
|
+
bytes: pngBytes,
|
|
110
|
+
mimeType: "image/png",
|
|
111
|
+
});
|
|
112
|
+
expect(mockSpawn).not.toHaveBeenCalled();
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("returns non-BMP bytes directly as image/png without convertToPng", async () => {
|
|
116
|
+
const pngBytes = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 1, 2, 3]);
|
|
117
|
+
mockGetImage.mockResolvedValue(pngBytes);
|
|
118
|
+
mockGetNativeClipboard.mockReturnValue({ getImage: mockGetImage });
|
|
119
|
+
|
|
120
|
+
const result = await readClipboardImage({ platform: "win32", environment: {} });
|
|
121
|
+
|
|
122
|
+
expect(mockConvertToPng).not.toHaveBeenCalled();
|
|
123
|
+
expect(result).toEqual({
|
|
124
|
+
bytes: pngBytes,
|
|
125
|
+
mimeType: "image/png",
|
|
126
|
+
});
|
|
127
|
+
expect(mockSpawn).not.toHaveBeenCalled();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("falls back to PowerShell when convertToPng returns null", async () => {
|
|
131
|
+
const bmpBytes = new Uint8Array([0x42, 0x4d, 0x10, 0x20]);
|
|
132
|
+
mockGetImage.mockResolvedValue(bmpBytes);
|
|
133
|
+
mockGetNativeClipboard.mockReturnValue({ getImage: mockGetImage });
|
|
134
|
+
mockConvertToPng.mockResolvedValue(null);
|
|
135
|
+
|
|
136
|
+
const fallbackBytes = new Uint8Array([1, 2, 3, 4]);
|
|
137
|
+
mockSpawn.mockImplementation(() =>
|
|
138
|
+
createMockChildProcess(Buffer.from(fallbackBytes).toString("base64"), 0),
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const result = await readClipboardImage({ platform: "win32", environment: {} });
|
|
142
|
+
|
|
143
|
+
expect(mockConvertToPng).toHaveBeenCalled();
|
|
144
|
+
expect(mockSpawn).toHaveBeenCalled();
|
|
145
|
+
expect(result).toEqual({
|
|
146
|
+
bytes: fallbackBytes,
|
|
147
|
+
mimeType: "image/png",
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("falls back to PowerShell when native.getImage() throws", async () => {
|
|
152
|
+
mockGetImage.mockRejectedValue(new Error("Native failure"));
|
|
153
|
+
mockGetNativeClipboard.mockReturnValue({ getImage: mockGetImage });
|
|
154
|
+
|
|
155
|
+
const fallbackBytes = new Uint8Array([5, 6, 7, 8]);
|
|
156
|
+
mockSpawn.mockImplementation(() =>
|
|
157
|
+
createMockChildProcess(Buffer.from(fallbackBytes).toString("base64"), 0),
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
const result = await readClipboardImage({ platform: "win32", environment: {} });
|
|
161
|
+
|
|
162
|
+
expect(mockSpawn).toHaveBeenCalled();
|
|
163
|
+
expect(result).toEqual({
|
|
164
|
+
bytes: fallbackBytes,
|
|
165
|
+
mimeType: "image/png",
|
|
166
|
+
});
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
it("falls back to PowerShell when getNativeClipboard() returns undefined", async () => {
|
|
170
|
+
mockGetNativeClipboard.mockReturnValue(undefined);
|
|
171
|
+
|
|
172
|
+
const fallbackBytes = new Uint8Array([9, 10, 11]);
|
|
173
|
+
mockSpawn.mockImplementation(() =>
|
|
174
|
+
createMockChildProcess(Buffer.from(fallbackBytes).toString("base64"), 0),
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
const result = await readClipboardImage({ platform: "win32", environment: {} });
|
|
178
|
+
|
|
179
|
+
expect(mockSpawn).toHaveBeenCalled();
|
|
180
|
+
expect(result).toEqual({
|
|
181
|
+
bytes: fallbackBytes,
|
|
182
|
+
mimeType: "image/png",
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it("logs a console.warn when PowerShell stdout exceeds MAX_BUFFER_BYTES", async () => {
|
|
187
|
+
mockGetNativeClipboard.mockReturnValue(undefined);
|
|
188
|
+
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
189
|
+
|
|
190
|
+
const child = new EventEmitter() as any;
|
|
191
|
+
child.stdout = new EventEmitter();
|
|
192
|
+
child.kill = vi.fn();
|
|
193
|
+
mockSpawn.mockImplementation(() => {
|
|
194
|
+
setTimeout(() => {
|
|
195
|
+
child.stdout.emit("data", Buffer.alloc(50 * 1024 * 1024 + 1));
|
|
196
|
+
child.emit("close", 0);
|
|
197
|
+
}, 5);
|
|
198
|
+
return child;
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const result = await readClipboardImage({ platform: "win32", environment: {} });
|
|
202
|
+
|
|
203
|
+
expect(warnSpy).toHaveBeenCalledWith(
|
|
204
|
+
"[archimedes] Clipboard image exceeded maximum buffer size (50MB)",
|
|
205
|
+
);
|
|
206
|
+
expect(result).toBeNull();
|
|
207
|
+
warnSpy.mockRestore();
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe("Unavailable reader error messages", () => {
|
|
212
|
+
it("reports pi-tui native clipboard or CLI tools instead of @mariozechner/clipboard on darwin", async () => {
|
|
213
|
+
mockGetNativeClipboard.mockReturnValue(undefined);
|
|
214
|
+
|
|
215
|
+
await expect(
|
|
216
|
+
readClipboardImage({ platform: "darwin", environment: {} }),
|
|
217
|
+
).rejects.toThrow(/@earendil-works\/pi-tui native clipboard or CLI tools/);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("reports pi-tui native clipboard or CLI tools instead of @mariozechner/clipboard on win32", async () => {
|
|
221
|
+
mockGetNativeClipboard.mockReturnValue(undefined);
|
|
222
|
+
const child = new EventEmitter() as any;
|
|
223
|
+
child.stdout = new EventEmitter();
|
|
224
|
+
child.kill = vi.fn();
|
|
225
|
+
mockSpawn.mockImplementation(() => {
|
|
226
|
+
setTimeout(() => {
|
|
227
|
+
const err = new Error("Command not found") as NodeJS.ErrnoException;
|
|
228
|
+
err.code = "ENOENT";
|
|
229
|
+
child.emit("error", err);
|
|
230
|
+
}, 5);
|
|
231
|
+
return child;
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
await expect(
|
|
235
|
+
readClipboardImage({ platform: "win32", environment: {} }),
|
|
236
|
+
).rejects.toThrow(/@earendil-works\/pi-tui native clipboard or CLI tools/);
|
|
237
|
+
});
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
describe("Linux paths", () => {
|
|
241
|
+
it("prefers Wayland (wl-paste attempted first) when WAYLAND_DISPLAY is set", async () => {
|
|
242
|
+
mockSpawnSync.mockImplementation((command: string, args: string[]) => {
|
|
243
|
+
if (command === "wl-paste" && args[0] === "--list-types") {
|
|
244
|
+
return { status: 0, stdout: Buffer.from("image/png\n"), error: undefined };
|
|
245
|
+
}
|
|
246
|
+
if (command === "wl-paste" && args[0] === "--type") {
|
|
247
|
+
return { status: 0, stdout: Buffer.from([1, 2, 3, 4]), error: undefined };
|
|
248
|
+
}
|
|
249
|
+
return { status: 1, stdout: Buffer.alloc(0), error: undefined };
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const result = await readClipboardImage({
|
|
253
|
+
platform: "linux",
|
|
254
|
+
environment: { WAYLAND_DISPLAY: "wayland-0" },
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
expect(mockSpawnSync).toHaveBeenCalled();
|
|
258
|
+
expect(mockSpawnSync.mock.calls[0]![0]).toBe("wl-paste");
|
|
259
|
+
expect(result).toEqual({
|
|
260
|
+
bytes: new Uint8Array([1, 2, 3, 4]),
|
|
261
|
+
mimeType: "image/png",
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("prefers X11 (xclip attempted first) when DISPLAY is set without Wayland", async () => {
|
|
266
|
+
mockSpawnSync.mockImplementation((command: string, args: string[]) => {
|
|
267
|
+
if (command === "xclip" && args.includes("TARGETS")) {
|
|
268
|
+
return { status: 0, stdout: Buffer.from("image/png\n"), error: undefined };
|
|
269
|
+
}
|
|
270
|
+
if (command === "xclip" && args.includes("image/png")) {
|
|
271
|
+
return { status: 0, stdout: Buffer.from([5, 6, 7]), error: undefined };
|
|
272
|
+
}
|
|
273
|
+
return { status: 1, stdout: Buffer.alloc(0), error: undefined };
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
const result = await readClipboardImage({
|
|
277
|
+
platform: "linux",
|
|
278
|
+
environment: { DISPLAY: ":0" },
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
expect(mockSpawnSync).toHaveBeenCalled();
|
|
282
|
+
expect(mockSpawnSync.mock.calls[0]![0]).toBe("xclip");
|
|
283
|
+
expect(result).toEqual({
|
|
284
|
+
bytes: new Uint8Array([5, 6, 7]),
|
|
285
|
+
mimeType: "image/png",
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it("throws appropriate graphical session error when DISPLAY and WAYLAND_DISPLAY are missing", async () => {
|
|
290
|
+
await expect(
|
|
291
|
+
readClipboardImage({ platform: "linux", environment: {} }),
|
|
292
|
+
).rejects.toThrow(
|
|
293
|
+
"Clipboard image paste requires a graphical desktop session with DISPLAY or WAYLAND_DISPLAY.",
|
|
294
|
+
);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
it("TERMUX_VERSION returns { available: false, image: null } from native module and null from readClipboardImage", async () => {
|
|
298
|
+
const nativeResult = await readClipboardImageViaNativeModule("linux", {
|
|
299
|
+
TERMUX_VERSION: "0.118.0",
|
|
300
|
+
DISPLAY: ":0",
|
|
301
|
+
});
|
|
302
|
+
expect(nativeResult).toEqual({ available: false, image: null });
|
|
303
|
+
|
|
304
|
+
const result = await readClipboardImage({
|
|
305
|
+
platform: "linux",
|
|
306
|
+
environment: { TERMUX_VERSION: "0.118.0", DISPLAY: ":0" },
|
|
307
|
+
});
|
|
308
|
+
expect(result).toBeNull();
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
});
|
package/src/clipboard.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
-
import { createRequire } from "node:module";
|
|
3
2
|
import { existsSync } from "node:fs";
|
|
4
3
|
import { readFile } from "node:fs/promises";
|
|
5
4
|
|
|
6
|
-
import
|
|
5
|
+
import { convertToPng } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { getNativeClipboard } from "@earendil-works/pi-tui";
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
import type { ClipboardImage } from "./types.js";
|
|
9
9
|
|
|
10
10
|
const LIST_TYPES_TIMEOUT_MS = 1000;
|
|
11
11
|
const READ_TIMEOUT_MS = 5000;
|
|
@@ -18,25 +18,13 @@ const SUPPORTED_IMAGE_MIME_TYPES = [
|
|
|
18
18
|
"image/bmp",
|
|
19
19
|
] as const;
|
|
20
20
|
|
|
21
|
-
let cachedClipboardModule: ClipboardModule | null | undefined;
|
|
22
|
-
|
|
23
|
-
/** Reset the clipboard module cache — allows retry after install. */
|
|
24
|
-
export function resetClipboardModuleCache(): void {
|
|
25
|
-
cachedClipboardModule = undefined;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
interface ClipboardModule {
|
|
29
|
-
hasImage: () => boolean;
|
|
30
|
-
getImageBinary: () => Promise<Array<number> | Uint8Array>;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
21
|
interface CommandResult {
|
|
34
22
|
ok: boolean;
|
|
35
23
|
stdout: Buffer;
|
|
36
24
|
missingCommand: boolean;
|
|
37
25
|
}
|
|
38
26
|
|
|
39
|
-
interface ClipboardReadResult {
|
|
27
|
+
export interface ClipboardReadResult {
|
|
40
28
|
available: boolean;
|
|
41
29
|
image: ClipboardImage | null;
|
|
42
30
|
}
|
|
@@ -74,54 +62,53 @@ function selectPreferredImageMimeType(mimeTypes: readonly string[]): string | nu
|
|
|
74
62
|
return firstImage?.raw ?? null;
|
|
75
63
|
}
|
|
76
64
|
|
|
77
|
-
function
|
|
78
|
-
platform: NodeJS.Platform = process.platform,
|
|
79
|
-
environment: NodeJS.ProcessEnv = process.env,
|
|
80
|
-
): ClipboardModule | null {
|
|
81
|
-
if (cachedClipboardModule !== undefined) {
|
|
82
|
-
return cachedClipboardModule;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
if (environment.TERMUX_VERSION || !hasGraphicalSession(platform, environment)) {
|
|
86
|
-
cachedClipboardModule = null;
|
|
87
|
-
return cachedClipboardModule;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
try {
|
|
91
|
-
cachedClipboardModule = require("@mariozechner/clipboard") as ClipboardModule;
|
|
92
|
-
} catch {
|
|
93
|
-
cachedClipboardModule = null;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
return cachedClipboardModule;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
async function readClipboardImageViaNativeModule(
|
|
65
|
+
export async function readClipboardImageViaNativeModule(
|
|
100
66
|
platform: NodeJS.Platform,
|
|
101
67
|
environment: NodeJS.ProcessEnv,
|
|
102
68
|
): Promise<ClipboardReadResult> {
|
|
103
|
-
|
|
104
|
-
if (!clipboard) {
|
|
69
|
+
if (environment.TERMUX_VERSION || !hasGraphicalSession(platform, environment)) {
|
|
105
70
|
return { available: false, image: null };
|
|
106
71
|
}
|
|
107
72
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
73
|
+
try {
|
|
74
|
+
const native = getNativeClipboard();
|
|
75
|
+
if (!native) {
|
|
76
|
+
return { available: false, image: null };
|
|
77
|
+
}
|
|
111
78
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
79
|
+
const bytes = await native.getImage();
|
|
80
|
+
if (!bytes || bytes.length === 0) {
|
|
81
|
+
return { available: true, image: null };
|
|
82
|
+
}
|
|
116
83
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
84
|
+
const rawBytes = bytes instanceof Uint8Array ? bytes : Uint8Array.from(bytes);
|
|
85
|
+
|
|
86
|
+
if (platform === "win32") {
|
|
87
|
+
if (rawBytes.length >= 2 && rawBytes[0] === 0x42 && rawBytes[1] === 0x4d) {
|
|
88
|
+
const png = await convertToPng(Buffer.from(rawBytes).toString("base64"), "image/bmp");
|
|
89
|
+
if (png) {
|
|
90
|
+
return {
|
|
91
|
+
available: true,
|
|
92
|
+
image: {
|
|
93
|
+
bytes: Uint8Array.from(Buffer.from(png.data, "base64")),
|
|
94
|
+
mimeType: png.mimeType,
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
return { available: false, image: null };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
available: true,
|
|
104
|
+
image: {
|
|
105
|
+
bytes: rawBytes,
|
|
106
|
+
mimeType: "image/png",
|
|
107
|
+
},
|
|
108
|
+
};
|
|
109
|
+
} catch {
|
|
110
|
+
return { available: false, image: null };
|
|
111
|
+
}
|
|
125
112
|
}
|
|
126
113
|
|
|
127
114
|
function runCommand(
|
|
@@ -209,7 +196,10 @@ try {
|
|
|
209
196
|
let exceededMaxBuffer = false;
|
|
210
197
|
child.stdout?.on("data", (data: Buffer) => {
|
|
211
198
|
if (stdout.length + data.length > MAX_BUFFER_BYTES) {
|
|
212
|
-
exceededMaxBuffer
|
|
199
|
+
if (!exceededMaxBuffer) {
|
|
200
|
+
exceededMaxBuffer = true;
|
|
201
|
+
console.warn("[archimedes] Clipboard image exceeded maximum buffer size (50MB)");
|
|
202
|
+
}
|
|
213
203
|
return;
|
|
214
204
|
}
|
|
215
205
|
stdout += data.toString("utf8");
|
|
@@ -345,11 +335,11 @@ function readClipboardImageViaXclip(): ClipboardReadResult {
|
|
|
345
335
|
function getUnavailableReaderMessage(platform: NodeJS.Platform): string {
|
|
346
336
|
switch (platform) {
|
|
347
337
|
case "linux":
|
|
348
|
-
return "No Linux clipboard image reader is available. Install wl-clipboard or xclip, or ensure @
|
|
338
|
+
return "No Linux clipboard image reader is available. Install wl-clipboard or xclip, or ensure @earendil-works/pi-tui native clipboard or CLI tools are available.";
|
|
349
339
|
case "darwin":
|
|
350
|
-
return "No macOS clipboard image reader is available. Ensure @
|
|
340
|
+
return "No macOS clipboard image reader is available. Ensure @earendil-works/pi-tui native clipboard or CLI tools are available.";
|
|
351
341
|
case "win32":
|
|
352
|
-
return "No Windows clipboard image reader is available. Ensure PowerShell is available or @
|
|
342
|
+
return "No Windows clipboard image reader is available. Ensure PowerShell is available or @earendil-works/pi-tui native clipboard or CLI tools are available.";
|
|
353
343
|
default:
|
|
354
344
|
return `Clipboard image paste is not supported on platform: ${platform}`;
|
|
355
345
|
}
|
|
@@ -174,7 +174,11 @@ describe("offerKeybindingFix — gate matrix", () => {
|
|
|
174
174
|
if (outcome === "yes") confirm.mockResolvedValue(true);
|
|
175
175
|
else confirm.mockResolvedValue(false); // no + cancel: confirm resolves false
|
|
176
176
|
|
|
177
|
-
|
|
177
|
+
// All matrix rows use a base-shape ctx (no reload), so the offer
|
|
178
|
+
// resolves `false` — the `true` contract is covered by the
|
|
179
|
+
// auto-reload branch tests below.
|
|
180
|
+
const result = await offerKeybindingFix(ctx);
|
|
181
|
+
expect(result).toBe(false);
|
|
178
182
|
|
|
179
183
|
const shouldAsk =
|
|
180
184
|
enabled === true &&
|
|
@@ -251,7 +255,8 @@ describe("flag write preserves existing settings", () => {
|
|
|
251
255
|
});
|
|
252
256
|
const { ctx, confirm } = makeCtx("tui");
|
|
253
257
|
confirm.mockResolvedValue(outcome === "yes");
|
|
254
|
-
|
|
258
|
+
// Base-shape ctx (no reload) → resolves `false` on both outcomes.
|
|
259
|
+
expect(await offerKeybindingFix(ctx)).toBe(false);
|
|
255
260
|
}
|
|
256
261
|
|
|
257
262
|
it("yes: snippet file written; other archimedes.imagePaste keys survive the flag set", async () => {
|
|
@@ -291,7 +296,8 @@ describe("file write throws → flag stays false (self-heals next session)", ()
|
|
|
291
296
|
const { ctx, confirm, notify } = makeCtx("tui");
|
|
292
297
|
confirm.mockResolvedValue(true);
|
|
293
298
|
|
|
294
|
-
|
|
299
|
+
// Write failed → the offer never reloaded → `false`.
|
|
300
|
+
await expect(offerKeybindingFix(ctx)).resolves.toBe(false);
|
|
295
301
|
|
|
296
302
|
expect(confirm).toHaveBeenCalledTimes(1);
|
|
297
303
|
expect(fs.existsSync(keybindingsPath())).toBe(false);
|
|
@@ -316,7 +322,8 @@ describe("flag write throws → notify, no crash (file write already succeeded)"
|
|
|
316
322
|
const { ctx, confirm, notify } = makeCtx("tui");
|
|
317
323
|
confirm.mockResolvedValue(true);
|
|
318
324
|
|
|
319
|
-
|
|
325
|
+
// Base-shape ctx (no reload) → `false`, even though the file was written.
|
|
326
|
+
await expect(offerKeybindingFix(ctx)).resolves.toBe(false);
|
|
320
327
|
|
|
321
328
|
// The file write strictly precedes the flag set and survived:
|
|
322
329
|
expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(SNIPPET);
|
|
@@ -343,7 +350,9 @@ describe("accept with base-shape ctx (no reload)", () => {
|
|
|
343
350
|
const { ctx, confirm, notify } = makeCtx("tui");
|
|
344
351
|
confirm.mockResolvedValue(true);
|
|
345
352
|
|
|
346
|
-
|
|
353
|
+
// No reload on this ctx → the user is told to run /reload manually,
|
|
354
|
+
// so the offer did NOT trigger a reload → `false`.
|
|
355
|
+
await expect(offerKeybindingFix(ctx)).resolves.toBe(false);
|
|
347
356
|
|
|
348
357
|
// File written with the exact snippet
|
|
349
358
|
expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(SNIPPET);
|
|
@@ -390,8 +399,10 @@ describe("auto-reload branch (future Pi with reload on the event ctx)", () => {
|
|
|
390
399
|
if ((args[0] as string) === CREATED_NOTIFY) order.push("notify");
|
|
391
400
|
});
|
|
392
401
|
|
|
393
|
-
await offerKeybindingFix(ctx);
|
|
402
|
+
const result = await offerKeybindingFix(ctx);
|
|
394
403
|
|
|
404
|
+
// The successful accept + reload() path resolves `true`
|
|
405
|
+
expect(result).toBe(true);
|
|
395
406
|
// reload called exactly once
|
|
396
407
|
expect(reload).toHaveBeenCalledTimes(1);
|
|
397
408
|
// flag was already set when reload ran
|
|
@@ -411,14 +422,15 @@ describe("auto-reload branch (future Pi with reload on the event ctx)", () => {
|
|
|
411
422
|
it("no path: reload NOT called", async () => {
|
|
412
423
|
const { ctx, confirm, reload } = makeCtx("tui", { withReload: true });
|
|
413
424
|
confirm.mockResolvedValue(false);
|
|
414
|
-
|
|
425
|
+
// Decline: no reload → `false`.
|
|
426
|
+
expect(await offerKeybindingFix(ctx)).toBe(false);
|
|
415
427
|
expect(reload).not.toHaveBeenCalled();
|
|
416
428
|
});
|
|
417
429
|
|
|
418
430
|
it("cancel (resolves false) path: reload NOT called", async () => {
|
|
419
431
|
const { ctx, confirm, reload } = makeCtx("tui", { withReload: true });
|
|
420
432
|
confirm.mockResolvedValue(false); // Esc/timeout: same as no
|
|
421
|
-
await offerKeybindingFix(ctx);
|
|
433
|
+
expect(await offerKeybindingFix(ctx)).toBe(false);
|
|
422
434
|
expect(reload).not.toHaveBeenCalled();
|
|
423
435
|
});
|
|
424
436
|
|
|
@@ -427,7 +439,7 @@ describe("auto-reload branch (future Pi with reload on the event ctx)", () => {
|
|
|
427
439
|
fs.mkdirSync(tmpPath(), { recursive: true });
|
|
428
440
|
const { ctx, confirm, reload } = makeCtx("tui", { withReload: true });
|
|
429
441
|
confirm.mockResolvedValue(true);
|
|
430
|
-
await offerKeybindingFix(ctx);
|
|
442
|
+
expect(await offerKeybindingFix(ctx)).toBe(false);
|
|
431
443
|
expect(reload).not.toHaveBeenCalled();
|
|
432
444
|
});
|
|
433
445
|
|
|
@@ -450,16 +462,19 @@ describe("auto-reload branch (future Pi with reload on the event ctx)", () => {
|
|
|
450
462
|
|
|
451
463
|
const { ctx, confirm, reload } = makeCtx("tui", { withReload: true });
|
|
452
464
|
confirm.mockResolvedValue(true);
|
|
453
|
-
await offerKeybindingFix(ctx);
|
|
465
|
+
expect(await offerKeybindingFix(ctx)).toBe(false);
|
|
454
466
|
expect(reload).not.toHaveBeenCalled();
|
|
455
467
|
});
|
|
456
468
|
|
|
457
|
-
it("reload rejects: offer still resolves, flag persisted, file present, CREATED_NOTIFY emitted", async () => {
|
|
469
|
+
it("reload rejects: offer still resolves (false), flag persisted, file present, CREATED_NOTIFY emitted", async () => {
|
|
458
470
|
const { ctx, confirm, notify, reload } = makeCtx("tui", { withReload: true });
|
|
459
471
|
confirm.mockResolvedValue(true);
|
|
460
472
|
reload!.mockRejectedValue(new Error("Reload failed"));
|
|
461
473
|
|
|
462
|
-
|
|
474
|
+
// The reload FAILED: the TUI shows its own "Reload failed" status and the
|
|
475
|
+
// session continues on the (still-valid) ctx, so the offer reports `false`
|
|
476
|
+
// — the onboarding still runs on this session.
|
|
477
|
+
await expect(offerKeybindingFix(ctx)).resolves.toBe(false);
|
|
463
478
|
|
|
464
479
|
// File must have been written
|
|
465
480
|
expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(SNIPPET);
|
|
@@ -502,7 +517,8 @@ describe("concurrent creation is never clobbered", () => {
|
|
|
502
517
|
const { ctx, confirm, notify } = makeCtx("tui");
|
|
503
518
|
confirm.mockResolvedValue(true);
|
|
504
519
|
|
|
505
|
-
|
|
520
|
+
// The offer ended before the reload step → `false`.
|
|
521
|
+
await expect(offerKeybindingFix(ctx)).resolves.toBe(false);
|
|
506
522
|
|
|
507
523
|
// The concurrently created file is never clobbered by the snippet
|
|
508
524
|
expect(fs.readFileSync(keybindingsPath(), "utf-8")).toBe(CONCURRENT_CONTENT);
|
package/src/keybinding-offer.ts
CHANGED
|
@@ -75,8 +75,19 @@ function markPromptDone(ctx: ExtensionContext): void {
|
|
|
75
75
|
* tears down). This counts as a decline — the flag is set and the offer will
|
|
76
76
|
* not appear again. To reset it, delete `archimedes.imagePaste.keybindingsPromptDone`
|
|
77
77
|
* from `~/.pi/agent/settings.json`.
|
|
78
|
+
*
|
|
79
|
+
* **Return contract:** resolves `true` iff the offer triggered a successful
|
|
80
|
+
* `ctx.reload()` (the successful-accept path on a ctx that exposes `reload()`
|
|
81
|
+
* and the reload completed). Every other outcome — the gates (1–4), decline,
|
|
82
|
+
* a failed file write, the file-exists race, a base-shape ctx without
|
|
83
|
+
* `reload` (manual /reload hint), and a reload that itself failed (the TUI
|
|
84
|
+
* shows its own "Reload failed" and the session continues on the still-valid
|
|
85
|
+
* ctx) — resolves `false`. The meta factory uses this to sequence the
|
|
86
|
+
* first-run flows: if `true`, the reloaded session runs the flow again (where
|
|
87
|
+
* the offer is a no-op — keybindings.json now exists), so it skips the
|
|
88
|
+
* onboarding on the now-stale ctx.
|
|
78
89
|
*/
|
|
79
|
-
export async function offerKeybindingFix(ctx: ExtensionContext): Promise<
|
|
90
|
+
export async function offerKeybindingFix(ctx: ExtensionContext): Promise<boolean> {
|
|
80
91
|
// Gate 1: extension on.
|
|
81
92
|
// NOTE: reading archimedes.imagePaste.enabled in-package is a sanctioned
|
|
82
93
|
// exception to the AGENTS.md "Plugin on/off" rule and ADR 0012 — this
|
|
@@ -84,24 +95,24 @@ export async function offerKeybindingFix(ctx: ExtensionContext): Promise<void> {
|
|
|
84
95
|
// registration, so there is no registration gate to catch it here.
|
|
85
96
|
// See docs/decisions/0012-plugin-gate-in-package-namespace.md § Exception.
|
|
86
97
|
if (!isConfigEnabled(NAMESPACE)) {
|
|
87
|
-
return;
|
|
98
|
+
return false;
|
|
88
99
|
}
|
|
89
100
|
|
|
90
101
|
// Gate 2: interactive TUI only. Deliberate: the flag is NOT consumed in
|
|
91
102
|
// non-TUI modes, so a later TUI session still gets the offer.
|
|
92
103
|
if (ctx.mode !== "tui") {
|
|
93
|
-
return;
|
|
104
|
+
return false;
|
|
94
105
|
}
|
|
95
106
|
|
|
96
107
|
// Gate 3: not yet consumed
|
|
97
108
|
if (loadConfig<PromptConfig>(NAMESPACE, { ...PROMPT_DEFAULTS }).keybindingsPromptDone === true) {
|
|
98
|
-
return;
|
|
109
|
+
return false;
|
|
99
110
|
}
|
|
100
111
|
|
|
101
112
|
// Gate 4: file absent — never merge into or rewrite an existing user file
|
|
102
113
|
const keybindingsPath = join(getAgentDir(), "keybindings.json");
|
|
103
114
|
if (existsSync(keybindingsPath)) {
|
|
104
|
-
return;
|
|
115
|
+
return false;
|
|
105
116
|
}
|
|
106
117
|
|
|
107
118
|
// Gate 5: ask (confirm signature is TITLE first — docs/extensions.md:165)
|
|
@@ -111,7 +122,7 @@ export async function offerKeybindingFix(ctx: ExtensionContext): Promise<void> {
|
|
|
111
122
|
// as decline): set the flag and do nothing else.
|
|
112
123
|
if (!confirmed) {
|
|
113
124
|
markPromptDone(ctx);
|
|
114
|
-
return;
|
|
125
|
+
return false;
|
|
115
126
|
}
|
|
116
127
|
|
|
117
128
|
// Yes: write the file atomically (tmp + rename) — strictly BEFORE the flag
|
|
@@ -130,7 +141,7 @@ export async function offerKeybindingFix(ctx: ExtensionContext): Promise<void> {
|
|
|
130
141
|
// ignore
|
|
131
142
|
}
|
|
132
143
|
markPromptDone(ctx);
|
|
133
|
-
return;
|
|
144
|
+
return false;
|
|
134
145
|
}
|
|
135
146
|
renameSync(tmpPath, keybindingsPath);
|
|
136
147
|
} catch (error) {
|
|
@@ -145,7 +156,7 @@ export async function offerKeybindingFix(ctx: ExtensionContext): Promise<void> {
|
|
|
145
156
|
`Could not create keybindings.json: ${messageOf(error)}`,
|
|
146
157
|
"warning",
|
|
147
158
|
);
|
|
148
|
-
return;
|
|
159
|
+
return false;
|
|
149
160
|
}
|
|
150
161
|
|
|
151
162
|
// File written successfully → THEN set the flag (deliberately after the
|
|
@@ -170,11 +181,18 @@ export async function offerKeybindingFix(ctx: ExtensionContext): Promise<void> {
|
|
|
170
181
|
ctx.ui.notify(CREATED_NOTIFY, "info");
|
|
171
182
|
try {
|
|
172
183
|
await reload();
|
|
184
|
+
// The reload completed: the reloaded session runs the flow again (where
|
|
185
|
+
// the offer is a no-op — keybindings.json now exists), so report that a
|
|
186
|
+
// reload was triggered and the caller can skip the onboarding on this
|
|
187
|
+
// now-stale ctx.
|
|
188
|
+
return true;
|
|
173
189
|
} catch {
|
|
174
190
|
// Swallowed on purpose: the TUI shows its own "Reload failed" status;
|
|
175
|
-
// the offer never throws.
|
|
191
|
+
// the offer never throws. The reload FAILED, so the session continues on
|
|
192
|
+
// the still-valid ctx → report `false` (the onboarding still runs).
|
|
193
|
+
return false;
|
|
176
194
|
}
|
|
177
|
-
} else {
|
|
178
|
-
ctx.ui.notify(CREATED_RELOAD_HINT, "info");
|
|
179
195
|
}
|
|
196
|
+
ctx.ui.notify(CREATED_RELOAD_HINT, "info");
|
|
197
|
+
return false;
|
|
180
198
|
}
|