pi-image-tools 1.2.0 → 1.3.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/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.3.0] - 2026-06-01
4
+
5
+ ### Added
6
+ - Added a provider registry for platform-specific clipboard readers.
7
+ - Added macOS `pngpaste` and `osascript` clipboard fallbacks.
8
+ - Added an inline preview queue and chat component for user image previews.
9
+
10
+ ### Changed
11
+ - Deferred preview, clipboard, and recent-image module loading from the extension entrypoint.
12
+ - Made shortcut conflict defaults platform-aware.
13
+ - Updated Pi peer dependency ranges and the optional clipboard dependency for 0.78-compatible runtimes.
14
+
15
+ ### Fixed
16
+ - Improved shell and session handling for clipboard command providers.
17
+
18
+ ## [1.2.1] - 2026-05-26
19
+
20
+ ### Changed
21
+ - Widened `@earendil-works/pi-coding-agent` and `@earendil-works/pi-tui` peer dependency ranges to `^0.74.0 || ^0.75.0`.
22
+
3
23
  ## [1.2.0] - 2026-05-22
4
24
 
5
25
  ### Added
package/README.md CHANGED
@@ -25,11 +25,9 @@ Image attachment and preview extension for the **Pi coding agent**.
25
25
  |----------|-----------------|---------------------|-------|
26
26
  | Windows | Yes | Yes | Uses native clipboard module first, then PowerShell fallback |
27
27
  | Linux | Yes | Yes | Requires a graphical session; uses `wl-paste` or `xclip`, then native module fallback |
28
- | macOS | Yes* | Yes | Clipboard paste depends on `@mariozechner/clipboard` being available |
28
+ | macOS | Yes | Yes | Uses `pngpaste` first, then `osascript` and native module fallbacks |
29
29
  | Termux / headless Linux | No | Limited | Clipboard image paste is disabled without a graphical session |
30
30
 
31
- \* macOS clipboard image support relies on the optional native clipboard module.
32
-
33
31
  ## Installation
34
32
 
35
33
  ### Extension folder
@@ -273,7 +271,9 @@ Preview behavior:
273
271
  - `xclip` in X11 sessions
274
272
  - `@mariozechner/clipboard` fallback
275
273
  - **macOS**
276
- - `@mariozechner/clipboard`
274
+ - `pngpaste`
275
+ - `osascript` PNG clipboard readers
276
+ - `@mariozechner/clipboard` fallback
277
277
 
278
278
  If a platform-specific reader exists but no image is currently on the clipboard, the command returns a normal “No image found in clipboard” message. If no usable reader exists at all, the extension surfaces a setup-oriented error.
279
279
 
package/package.json CHANGED
@@ -1,69 +1,69 @@
1
- {
2
- "name": "pi-image-tools",
3
- "version": "1.2.0",
4
- "description": "Image attachment and rendering extension for Pi TUI",
5
- "type": "module",
6
- "main": "./index.ts",
7
- "exports": {
8
- ".": "./index.ts"
9
- },
10
- "files": [
11
- "index.ts",
12
- "src",
13
- "config/config.example.json",
14
- "README.md",
15
- "CHANGELOG.md",
16
- "LICENSE"
17
- ],
18
- "scripts": {
19
- "typecheck": "npx --yes -p typescript@5.7.3 tsc -p tsconfig.json --noEmit",
20
- "build": "npm run typecheck",
21
- "lint": "npm run typecheck",
22
- "test": "node --test",
23
- "check": "npm run build && npm run test"
24
- },
25
- "keywords": [
26
- "pi-package",
27
- "pi",
28
- "pi-extension",
29
- "pi-coding-agent",
30
- "pi-tui",
31
- "coding-agent",
32
- "image",
33
- "image-attachment",
34
- "image-preview",
35
- "clipboard",
36
- "preview",
37
- "windows",
38
- "linux",
39
- "cross-platform"
40
- ],
41
- "author": "MasuRii",
42
- "license": "MIT",
43
- "repository": {
44
- "type": "git",
45
- "url": "git+https://github.com/MasuRii/pi-image-tools.git"
46
- },
47
- "homepage": "https://github.com/MasuRii/pi-image-tools#readme",
48
- "bugs": {
49
- "url": "https://github.com/MasuRii/pi-image-tools/issues"
50
- },
51
- "engines": {
52
- "node": ">=20"
53
- },
54
- "publishConfig": {
55
- "access": "public"
56
- },
57
- "pi": {
58
- "extensions": [
59
- "./index.ts"
60
- ]
61
- },
62
- "peerDependencies": {
63
- "@earendil-works/pi-coding-agent": "^0.75.4",
64
- "@earendil-works/pi-tui": "^0.75.4"
65
- },
66
- "optionalDependencies": {
67
- "@mariozechner/clipboard": "^0.3.6"
68
- }
69
- }
1
+ {
2
+ "name": "pi-image-tools",
3
+ "version": "1.3.0",
4
+ "description": "Image attachment and rendering extension for Pi TUI",
5
+ "type": "module",
6
+ "main": "./index.ts",
7
+ "exports": {
8
+ ".": "./index.ts"
9
+ },
10
+ "files": [
11
+ "index.ts",
12
+ "src",
13
+ "config/config.example.json",
14
+ "README.md",
15
+ "CHANGELOG.md",
16
+ "LICENSE"
17
+ ],
18
+ "scripts": {
19
+ "typecheck": "npx --yes -p typescript@5.7.3 tsc -p tsconfig.json --noEmit",
20
+ "build": "npm run typecheck",
21
+ "lint": "npm run typecheck",
22
+ "test": "node --test test/*.test.js test/providers/*.test.js",
23
+ "check": "npm run build && npm run test"
24
+ },
25
+ "keywords": [
26
+ "pi-package",
27
+ "pi",
28
+ "pi-extension",
29
+ "pi-coding-agent",
30
+ "pi-tui",
31
+ "coding-agent",
32
+ "image",
33
+ "image-attachment",
34
+ "image-preview",
35
+ "clipboard",
36
+ "preview",
37
+ "windows",
38
+ "linux",
39
+ "cross-platform"
40
+ ],
41
+ "author": "MasuRii",
42
+ "license": "MIT",
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "git+https://github.com/MasuRii/pi-image-tools.git"
46
+ },
47
+ "homepage": "https://github.com/MasuRii/pi-image-tools#readme",
48
+ "bugs": {
49
+ "url": "https://github.com/MasuRii/pi-image-tools/issues"
50
+ },
51
+ "engines": {
52
+ "node": ">=20"
53
+ },
54
+ "publishConfig": {
55
+ "access": "public"
56
+ },
57
+ "pi": {
58
+ "extensions": [
59
+ "./index.ts"
60
+ ]
61
+ },
62
+ "peerDependencies": {
63
+ "@earendil-works/pi-coding-agent": "^0.74.0 || ^0.75.0 || ^0.77.0 || ^0.78.0",
64
+ "@earendil-works/pi-tui": "^0.74.0 || ^0.75.0 || ^0.77.0 || ^0.78.0"
65
+ },
66
+ "optionalDependencies": {
67
+ "@mariozechner/clipboard": "^0.3.9"
68
+ }
69
+ }
package/src/clipboard.ts CHANGED
@@ -1,336 +1,89 @@
1
- import { spawnSync } from "node:child_process";
2
- import { createRequire } from "node:module";
3
-
4
- import { isErrnoException } from "./errors.js";
5
- import { normalizeMimeType, selectPreferredImageMimeType, SUPPORTED_IMAGE_MIME_TYPES } from "./image-mime.js";
6
- import { runPowerShellCommand } from "./powershell.js";
7
- import type { ClipboardImage, ClipboardModule } from "./types.js";
8
-
9
- const require = createRequire(import.meta.url);
10
-
11
- const LIST_TYPES_TIMEOUT_MS = 1000;
12
- const READ_TIMEOUT_MS = 5000;
13
- const MAX_BUFFER_BYTES = 50 * 1024 * 1024;
14
- let cachedClipboardModule: ClipboardModule | null | undefined;
15
-
16
- interface CommandResult {
17
- ok: boolean;
18
- stdout: Buffer;
19
- missingCommand: boolean;
20
- }
21
-
22
- interface ClipboardReadResult {
23
- available: boolean;
24
- image: ClipboardImage | null;
25
- }
26
-
27
- export function hasGraphicalSession(platform: NodeJS.Platform, environment: NodeJS.ProcessEnv): boolean {
28
- return platform !== "linux" || Boolean(environment.DISPLAY || environment.WAYLAND_DISPLAY);
29
- }
30
-
31
- function isWaylandSession(environment: NodeJS.ProcessEnv): boolean {
32
- return Boolean(environment.WAYLAND_DISPLAY) || environment.XDG_SESSION_TYPE === "wayland";
33
- }
34
-
35
- function loadClipboardModule(
36
- platform: NodeJS.Platform = process.platform,
37
- environment: NodeJS.ProcessEnv = process.env,
38
- ): ClipboardModule | null {
39
- if (cachedClipboardModule !== undefined) {
40
- return cachedClipboardModule;
41
- }
42
-
43
- if (environment.TERMUX_VERSION || !hasGraphicalSession(platform, environment)) {
44
- cachedClipboardModule = null;
45
- return cachedClipboardModule;
46
- }
47
-
48
- try {
49
- cachedClipboardModule = require("@mariozechner/clipboard") as ClipboardModule;
50
- } catch {
51
- cachedClipboardModule = null;
52
- }
53
-
54
- return cachedClipboardModule;
55
- }
56
-
57
- async function readClipboardImageViaNativeModule(
58
- platform: NodeJS.Platform,
59
- environment: NodeJS.ProcessEnv,
60
- ): Promise<ClipboardReadResult> {
61
- const clipboard = loadClipboardModule(platform, environment);
62
- if (!clipboard) {
63
- return { available: false, image: null };
64
- }
65
-
66
- if (!clipboard.hasImage()) {
67
- return { available: true, image: null };
68
- }
69
-
70
- const imageData = await clipboard.getImageBinary();
71
- if (!imageData || imageData.length === 0) {
72
- return { available: true, image: null };
73
- }
74
-
75
- const bytes = imageData instanceof Uint8Array ? imageData : Uint8Array.from(imageData);
76
- return {
77
- available: true,
78
- image: {
79
- bytes,
80
- mimeType: "image/png",
81
- },
82
- };
83
- }
84
-
85
- function runCommand(
86
- command: string,
87
- args: string[],
88
- timeout: number,
89
- ): CommandResult {
90
- const result = spawnSync(command, args, {
91
- timeout,
92
- maxBuffer: MAX_BUFFER_BYTES,
93
- });
94
-
95
- if (result.error) {
96
- return {
97
- ok: false,
98
- stdout: Buffer.alloc(0),
99
- missingCommand: isErrnoException(result.error) && result.error.code === "ENOENT",
100
- };
101
- }
102
-
103
- const stdout = Buffer.isBuffer(result.stdout)
104
- ? result.stdout
105
- : Buffer.from(result.stdout ?? "", typeof result.stdout === "string" ? "utf8" : undefined);
106
-
107
- return {
108
- ok: result.status === 0,
109
- stdout,
110
- missingCommand: false,
111
- };
112
- }
113
-
114
- function readClipboardImageViaPowerShell(): ClipboardReadResult {
115
- const script = `
116
- $ErrorActionPreference = 'Stop'
117
- Add-Type -AssemblyName System.Windows.Forms
118
- Add-Type -AssemblyName System.Drawing
119
-
120
- if (-not [System.Windows.Forms.Clipboard]::ContainsImage()) {
121
- return
122
- }
123
-
124
- $image = [System.Windows.Forms.Clipboard]::GetImage()
125
- if ($null -eq $image) {
126
- return
127
- }
128
-
129
- $stream = New-Object System.IO.MemoryStream
130
- try {
131
- $image.Save($stream, [System.Drawing.Imaging.ImageFormat]::Png)
132
- [System.Convert]::ToBase64String($stream.ToArray())
133
- } finally {
134
- $stream.Dispose()
135
- $image.Dispose()
136
- }
137
- `;
138
-
139
- const result = runPowerShellCommand(script, {
140
- encoded: true,
141
- sta: true,
142
- timeout: READ_TIMEOUT_MS,
143
- maxBuffer: MAX_BUFFER_BYTES,
144
- });
145
-
146
- if (result.missingCommand) {
147
- return { available: false, image: null };
148
- }
149
-
150
- if (!result.ok) {
151
- return { available: true, image: null };
152
- }
153
-
154
- const base64 = result.stdout.trim();
155
- if (!base64) {
156
- return { available: true, image: null };
157
- }
158
-
159
- try {
160
- const bytes = Buffer.from(base64, "base64");
161
- if (bytes.length === 0) {
162
- return { available: true, image: null };
163
- }
164
-
165
- return {
166
- available: true,
167
- image: {
168
- bytes: new Uint8Array(bytes),
169
- mimeType: "image/png",
170
- },
171
- };
172
- } catch {
173
- return { available: true, image: null };
174
- }
175
- }
176
-
177
- function readClipboardImageViaWlPaste(): ClipboardReadResult {
178
- const listTypes = runCommand("wl-paste", ["--list-types"], LIST_TYPES_TIMEOUT_MS);
179
- if (listTypes.missingCommand) {
180
- return { available: false, image: null };
181
- }
182
-
183
- if (!listTypes.ok) {
184
- return { available: true, image: null };
185
- }
186
-
187
- const mimeTypes = listTypes.stdout
188
- .toString("utf8")
189
- .split(/\r?\n/)
190
- .map((mimeType) => mimeType.trim())
191
- .filter((mimeType) => mimeType.length > 0);
192
-
193
- const selectedMimeType = selectPreferredImageMimeType(mimeTypes);
194
- if (!selectedMimeType) {
195
- return { available: true, image: null };
196
- }
197
-
198
- const imageData = runCommand(
199
- "wl-paste",
200
- ["--type", selectedMimeType, "--no-newline"],
201
- READ_TIMEOUT_MS,
202
- );
203
-
204
- if (!imageData.ok || imageData.stdout.length === 0) {
205
- return { available: true, image: null };
206
- }
207
-
208
- return {
209
- available: true,
210
- image: {
211
- bytes: new Uint8Array(imageData.stdout),
212
- mimeType: normalizeMimeType(selectedMimeType),
213
- },
214
- };
215
- }
216
-
217
- function readClipboardImageViaXclip(): ClipboardReadResult {
218
- const targets = runCommand(
219
- "xclip",
220
- ["-selection", "clipboard", "-t", "TARGETS", "-o"],
221
- LIST_TYPES_TIMEOUT_MS,
222
- );
223
-
224
- if (targets.missingCommand) {
225
- return { available: false, image: null };
226
- }
227
-
228
- const advertisedMimeTypes = targets.ok
229
- ? targets.stdout
230
- .toString("utf8")
231
- .split(/\r?\n/)
232
- .map((mimeType) => mimeType.trim())
233
- .filter((mimeType) => mimeType.length > 0)
234
- : [];
235
-
236
- const preferredMimeType =
237
- advertisedMimeTypes.length > 0 ? selectPreferredImageMimeType(advertisedMimeTypes) : null;
238
- const mimeTypesToTry = preferredMimeType
239
- ? [preferredMimeType, ...SUPPORTED_IMAGE_MIME_TYPES]
240
- : [...SUPPORTED_IMAGE_MIME_TYPES];
241
-
242
- for (const mimeType of mimeTypesToTry) {
243
- const imageData = runCommand(
244
- "xclip",
245
- ["-selection", "clipboard", "-t", mimeType, "-o"],
246
- READ_TIMEOUT_MS,
247
- );
248
-
249
- if (imageData.ok && imageData.stdout.length > 0) {
250
- return {
251
- available: true,
252
- image: {
253
- bytes: new Uint8Array(imageData.stdout),
254
- mimeType: normalizeMimeType(mimeType),
255
- },
256
- };
257
- }
258
- }
259
-
260
- return { available: true, image: null };
261
- }
262
-
263
- function getUnavailableReaderMessage(platform: NodeJS.Platform): string {
264
- switch (platform) {
265
- case "linux":
266
- return "No Linux clipboard image reader is available. Install wl-clipboard or xclip, or ensure @mariozechner/clipboard is installed.";
267
- case "darwin":
268
- return "No macOS clipboard image reader is available. Ensure @mariozechner/clipboard is installed.";
269
- case "win32":
270
- return "No Windows clipboard image reader is available. Ensure PowerShell is available or @mariozechner/clipboard is installed.";
271
- default:
272
- return `Clipboard image paste is not supported on platform: ${platform}`;
273
- }
274
- }
275
-
276
- export async function readClipboardImage(options?: {
277
- environment?: NodeJS.ProcessEnv;
278
- platform?: NodeJS.Platform;
279
- }): Promise<ClipboardImage | null> {
280
- const environment = options?.environment ?? process.env;
281
- const platform = options?.platform ?? process.platform;
282
-
283
- if (environment.TERMUX_VERSION) {
284
- return null;
285
- }
286
-
287
- if (!hasGraphicalSession(platform, environment)) {
288
- throw new Error("Clipboard image paste requires a graphical desktop session with DISPLAY or WAYLAND_DISPLAY.");
289
- }
290
-
291
- const readerResults: ClipboardReadResult[] = [];
292
-
293
- const recordResult = (result: ClipboardReadResult): ClipboardImage | null => {
294
- readerResults.push(result);
295
- return result.image;
296
- };
297
-
298
- if (platform === "win32") {
299
- const nativeImage = recordResult(await readClipboardImageViaNativeModule(platform, environment));
300
- if (nativeImage) {
301
- return nativeImage;
302
- }
303
-
304
- const powerShellImage = recordResult(readClipboardImageViaPowerShell());
305
- if (powerShellImage) {
306
- return powerShellImage;
307
- }
308
- } else if (platform === "linux") {
309
- const sessionReaders = isWaylandSession(environment)
310
- ? [readClipboardImageViaWlPaste, readClipboardImageViaXclip]
311
- : [readClipboardImageViaXclip, readClipboardImageViaWlPaste];
312
-
313
- for (const reader of sessionReaders) {
314
- const image = recordResult(reader());
315
- if (image) {
316
- return image;
317
- }
318
- }
319
-
320
- const nativeImage = recordResult(await readClipboardImageViaNativeModule(platform, environment));
321
- if (nativeImage) {
322
- return nativeImage;
323
- }
324
- } else {
325
- const nativeImage = recordResult(await readClipboardImageViaNativeModule(platform, environment));
326
- if (nativeImage) {
327
- return nativeImage;
328
- }
329
- }
330
-
331
- if (readerResults.some((result) => result.available)) {
332
- return null;
333
- }
334
-
335
- throw new Error(getUnavailableReaderMessage(platform));
336
- }
1
+ import { hasGraphicalSession, isWaylandSession } from "./shell-environment.js";
2
+ import { NativeModuleProvider } from "./providers/native-module.js";
3
+ import { OsascriptPngfProvider } from "./providers/mac-osascript-pngf.js";
4
+ import { OsascriptPublicPngProvider } from "./providers/mac-osascript-publicpng.js";
5
+ import { PngpasteProvider } from "./providers/mac-pngpaste.js";
6
+ import { PowerShellFormsProvider } from "./providers/powershell-forms.js";
7
+ import { ClipboardProviderRegistry } from "./providers/registry.js";
8
+ import { WlPasteProvider } from "./providers/wl-paste.js";
9
+ import { XclipProvider } from "./providers/xclip.js";
10
+ import type { ClipboardImage } from "./types.js";
11
+
12
+ export { hasGraphicalSession } from "./shell-environment.js";
13
+
14
+ export function buildDefaultClipboardProviderRegistry(
15
+ platform: NodeJS.Platform,
16
+ environment: NodeJS.ProcessEnv,
17
+ ): ClipboardProviderRegistry {
18
+ const registry = new ClipboardProviderRegistry();
19
+
20
+ if (platform === "win32") {
21
+ registry
22
+ .register(new NativeModuleProvider({ priority: 10 }))
23
+ .register(new PowerShellFormsProvider({ priority: 20 }));
24
+ return registry;
25
+ }
26
+
27
+ if (platform === "linux") {
28
+ const waylandFirst = isWaylandSession(environment);
29
+ registry
30
+ .register(new WlPasteProvider({ priority: waylandFirst ? 10 : 20 }))
31
+ .register(new XclipProvider({ priority: waylandFirst ? 20 : 10 }))
32
+ .register(new NativeModuleProvider({ priority: 30 }));
33
+ return registry;
34
+ }
35
+
36
+ if (platform === "darwin") {
37
+ registry
38
+ .register(new PngpasteProvider({ priority: 10 }))
39
+ .register(new OsascriptPublicPngProvider({ priority: 20 }))
40
+ .register(new OsascriptPngfProvider({ priority: 30 }))
41
+ .register(new NativeModuleProvider({ priority: 40 }));
42
+ return registry;
43
+ }
44
+
45
+ registry.register(new NativeModuleProvider({ priority: 100 }));
46
+ return registry;
47
+ }
48
+
49
+ function getUnavailableReaderMessage(platform: NodeJS.Platform): string {
50
+ switch (platform) {
51
+ case "linux":
52
+ return "No Linux clipboard image reader is available. Install wl-clipboard or xclip, or ensure @mariozechner/clipboard is installed.";
53
+ case "darwin":
54
+ return "No macOS clipboard image reader is available. Install pngpaste, ensure osascript is available, or ensure @mariozechner/clipboard is installed.";
55
+ case "win32":
56
+ return "No Windows clipboard image reader is available. Ensure PowerShell is available or @mariozechner/clipboard is installed.";
57
+ default:
58
+ return `Clipboard image paste is not supported on platform: ${platform}`;
59
+ }
60
+ }
61
+
62
+ export async function readClipboardImage(options?: {
63
+ environment?: NodeJS.ProcessEnv;
64
+ platform?: NodeJS.Platform;
65
+ registry?: ClipboardProviderRegistry;
66
+ }): Promise<ClipboardImage | null> {
67
+ const environment = options?.environment ?? process.env;
68
+ const platform = options?.platform ?? process.platform;
69
+
70
+ if (environment.TERMUX_VERSION) {
71
+ return null;
72
+ }
73
+
74
+ if (!hasGraphicalSession(platform, environment)) {
75
+ throw new Error("Clipboard image paste requires a graphical desktop session with DISPLAY or WAYLAND_DISPLAY.");
76
+ }
77
+
78
+ const registry = options?.registry ?? buildDefaultClipboardProviderRegistry(platform, environment);
79
+ const result = await registry.read({ platform, environment });
80
+ if (result.image) {
81
+ return result.image;
82
+ }
83
+
84
+ if (result.attempts.some((attempt) => attempt.available)) {
85
+ return null;
86
+ }
87
+
88
+ throw new Error(getUnavailableReaderMessage(platform));
89
+ }