pi-image-tools 1.2.1 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,28 @@
1
1
  # Changelog
2
2
 
3
+ ## [Unreleased]
4
+
5
+ ## [1.3.1] - 2026-06-16
6
+
7
+ ### Fixed
8
+ - Default paste-image shortcuts now avoid Pi built-in shortcut conflicts on all platforms, including macOS. Previously macOS defaulted to overriding Pi's built-in `ctrl+v` image paste binding and printed a startup conflict warning. (reported by @gaodes in [#8](https://github.com/MasuRii/pi-image-tools/issues/8))
9
+ - Transcode clipboard images that arrive in MIME types model providers reject (most notably `image/bmp` from WSLg's Wayland clipboard bridge for Windows clipboard images, and `image/tiff` from some macOS sources) into PNG via ImageMagick before attaching them. Previously, pasting a Windows screenshot inside WSL produced an `Unknown image type: image/bmp` error from the model provider that blocked the message from being sent. (contributed by @codeviking428 in [#9](https://github.com/MasuRii/pi-image-tools/pull/9))
10
+
11
+ ## [1.3.0] - 2026-06-01
12
+
13
+ ### Added
14
+ - Added a provider registry for platform-specific clipboard readers.
15
+ - Added macOS `pngpaste` and `osascript` clipboard fallbacks.
16
+ - Added an inline preview queue and chat component for user image previews.
17
+
18
+ ### Changed
19
+ - Deferred preview, clipboard, and recent-image module loading from the extension entrypoint.
20
+ - Made shortcut conflict defaults platform-aware.
21
+ - Updated Pi peer dependency ranges and the optional clipboard dependency for 0.78-compatible runtimes.
22
+
23
+ ### Fixed
24
+ - Improved shell and session handling for clipboard command providers.
25
+
3
26
  ## [1.2.1] - 2026-05-26
4
27
 
5
28
  ### Changed
package/README.md CHANGED
@@ -25,11 +25,10 @@ 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
+ | WSL2 (WSLg) | Yes | Yes | Linux providers see Windows clipboard images as `image/bmp`; install ImageMagick (`magick`/`convert`) so they can be transcoded to PNG before attaching |
29
+ | macOS | Yes | Yes | Uses `pngpaste` first, then `osascript` and native module fallbacks |
29
30
  | Termux / headless Linux | No | Limited | Clipboard image paste is disabled without a graphical session |
30
31
 
31
- \* macOS clipboard image support relies on the optional native clipboard module.
32
-
33
32
  ## Installation
34
33
 
35
34
  ### Extension folder
@@ -124,13 +123,15 @@ $env:PI_IMAGE_TOOLS_RECENT_DIRS = "C:\Users\me\Pictures\Screenshots;D:\Shares\Sc
124
123
 
125
124
  ### Runtime config
126
125
 
127
- A config file can be placed at:
126
+ A config file can be placed at the `pi-image-tools` package/install root next to `index.ts`. For npm-installed extensions this is the package directory under Pi's npm install area, for example:
128
127
 
129
128
  ```text
130
- Default global path: ~/.pi/agent/extensions/pi-image-tools/config.json
131
- Actual global path: $PI_CODING_AGENT_DIR/extensions/pi-image-tools/config.json when PI_CODING_AGENT_DIR is set
129
+ ~/.pi/agent/npm/node_modules/pi-image-tools/config.json
130
+ $PI_CODING_AGENT_DIR/npm/node_modules/pi-image-tools/config.json when PI_CODING_AGENT_DIR is set
132
131
  ```
133
132
 
133
+ For a local checkout or extension path, place `config.json` in that `pi-image-tools` directory.
134
+
134
135
  Starter template:
135
136
 
136
137
  ```json
@@ -273,7 +274,9 @@ Preview behavior:
273
274
  - `xclip` in X11 sessions
274
275
  - `@mariozechner/clipboard` fallback
275
276
  - **macOS**
276
- - `@mariozechner/clipboard`
277
+ - `pngpaste`
278
+ - `osascript` PNG clipboard readers
279
+ - `@mariozechner/clipboard` fallback
277
280
 
278
281
  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
282
 
package/package.json CHANGED
@@ -1,69 +1,69 @@
1
- {
2
- "name": "pi-image-tools",
3
- "version": "1.2.1",
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.74.0 || ^0.75.0",
64
- "@earendil-works/pi-tui": "^0.74.0 || ^0.75.0"
65
- },
66
- "optionalDependencies": {
67
- "@mariozechner/clipboard": "^0.3.6"
68
- }
69
- }
1
+ {
2
+ "name": "pi-image-tools",
3
+ "version": "1.3.1",
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 || ^0.79.0",
64
+ "@earendil-works/pi-tui": "^0.74.0 || ^0.75.0 || ^0.77.0 || ^0.78.0 || ^0.79.0"
65
+ },
66
+ "optionalDependencies": {
67
+ "@mariozechner/clipboard": "^0.3.9"
68
+ }
69
+ }
package/src/clipboard.ts CHANGED
@@ -1,263 +1,50 @@
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(
1
+ import { hasGraphicalSession, isWaylandSession } from "./shell-environment.js";
2
+ import { transcodeToSupportedFormat, type TranscodeOptions } from "./image-transcode.js";
3
+ import { NativeModuleProvider } from "./providers/native-module.js";
4
+ import { OsascriptPngfProvider } from "./providers/mac-osascript-pngf.js";
5
+ import { OsascriptPublicPngProvider } from "./providers/mac-osascript-publicpng.js";
6
+ import { PngpasteProvider } from "./providers/mac-pngpaste.js";
7
+ import { PowerShellFormsProvider } from "./providers/powershell-forms.js";
8
+ import { ClipboardProviderRegistry } from "./providers/registry.js";
9
+ import { WlPasteProvider } from "./providers/wl-paste.js";
10
+ import { XclipProvider } from "./providers/xclip.js";
11
+ import type { ClipboardImage } from "./types.js";
12
+
13
+ export { hasGraphicalSession } from "./shell-environment.js";
14
+
15
+ export function buildDefaultClipboardProviderRegistry(
58
16
  platform: NodeJS.Platform,
59
17
  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
- }
18
+ ): ClipboardProviderRegistry {
19
+ const registry = new ClipboardProviderRegistry();
123
20
 
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 };
21
+ if (platform === "win32") {
22
+ registry
23
+ .register(new NativeModuleProvider({ priority: 10 }))
24
+ .register(new PowerShellFormsProvider({ priority: 20 }));
25
+ return registry;
206
26
  }
207
27
 
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 };
28
+ if (platform === "linux") {
29
+ const waylandFirst = isWaylandSession(environment);
30
+ registry
31
+ .register(new WlPasteProvider({ priority: waylandFirst ? 10 : 20 }))
32
+ .register(new XclipProvider({ priority: waylandFirst ? 20 : 10 }))
33
+ .register(new NativeModuleProvider({ priority: 30 }));
34
+ return registry;
226
35
  }
227
36
 
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
- }
37
+ if (platform === "darwin") {
38
+ registry
39
+ .register(new PngpasteProvider({ priority: 10 }))
40
+ .register(new OsascriptPublicPngProvider({ priority: 20 }))
41
+ .register(new OsascriptPngfProvider({ priority: 30 }))
42
+ .register(new NativeModuleProvider({ priority: 40 }));
43
+ return registry;
258
44
  }
259
45
 
260
- return { available: true, image: null };
46
+ registry.register(new NativeModuleProvider({ priority: 100 }));
47
+ return registry;
261
48
  }
262
49
 
263
50
  function getUnavailableReaderMessage(platform: NodeJS.Platform): string {
@@ -265,7 +52,7 @@ function getUnavailableReaderMessage(platform: NodeJS.Platform): string {
265
52
  case "linux":
266
53
  return "No Linux clipboard image reader is available. Install wl-clipboard or xclip, or ensure @mariozechner/clipboard is installed.";
267
54
  case "darwin":
268
- return "No macOS clipboard image reader is available. Ensure @mariozechner/clipboard is installed.";
55
+ return "No macOS clipboard image reader is available. Install pngpaste, ensure osascript is available, or ensure @mariozechner/clipboard is installed.";
269
56
  case "win32":
270
57
  return "No Windows clipboard image reader is available. Ensure PowerShell is available or @mariozechner/clipboard is installed.";
271
58
  default:
@@ -276,6 +63,14 @@ function getUnavailableReaderMessage(platform: NodeJS.Platform): string {
276
63
  export async function readClipboardImage(options?: {
277
64
  environment?: NodeJS.ProcessEnv;
278
65
  platform?: NodeJS.Platform;
66
+ registry?: ClipboardProviderRegistry;
67
+ /**
68
+ * Hooks for the post-read transcoding step that normalizes images into a
69
+ * MIME type accepted by model providers. Mainly for tests; the default
70
+ * implementation shells out to ImageMagick only when the source format isn't
71
+ * already supported.
72
+ */
73
+ transcode?: TranscodeOptions;
279
74
  }): Promise<ClipboardImage | null> {
280
75
  const environment = options?.environment ?? process.env;
281
76
  const platform = options?.platform ?? process.platform;
@@ -288,47 +83,15 @@ export async function readClipboardImage(options?: {
288
83
  throw new Error("Clipboard image paste requires a graphical desktop session with DISPLAY or WAYLAND_DISPLAY.");
289
84
  }
290
85
 
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
- }
86
+ const registry = options?.registry ?? buildDefaultClipboardProviderRegistry(platform, environment);
87
+ const result = await registry.read({ platform, environment });
88
+ if (result.image) {
89
+ // Providers (notably wl-paste under WSLg) may return formats like
90
+ // image/bmp that model providers reject. Normalize to PNG when needed.
91
+ return transcodeToSupportedFormat(result.image, { platform, ...options?.transcode });
329
92
  }
330
93
 
331
- if (readerResults.some((result) => result.available)) {
94
+ if (result.attempts.some((attempt) => attempt.available)) {
332
95
  return null;
333
96
  }
334
97