pi-image-tools 1.0.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 ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## 1.0.0
4
+
5
+ - Standardized repository layout to `src/` + root shim entrypoint.
6
+ - Added TypeScript/Bundler project config, package metadata, and publish whitelist.
7
+ - Added standard docs, license, and config template/runtime placeholder files.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 MasuRii
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,207 @@
1
+ # pi-image-tools
2
+
3
+ Image attachment and preview extension for the **Pi coding agent**.
4
+
5
+ This extension focuses on one workflow: quickly attach a clipboard image (or pick a recent screenshot) to the message you are about to send, and then render an inline preview in the TUI chat.
6
+
7
+ > **Windows-only:** `pi-image-tools` only registers commands/shortcuts on Windows (`win32`). On macOS/Linux it does nothing.
8
+
9
+ ![pi-image-tools](asset/pi-image-tools.png)
10
+
11
+ ## Features
12
+
13
+ - Paste images into your next message:
14
+ - `/paste-image clipboard` (default) reads an image from the clipboard and queues it for the next send.
15
+ - `/paste-image recent` opens a picker for recent screenshots/images and queues the selected file.
16
+ - Keyboard shortcuts for fast paste:
17
+ - `alt+v`
18
+ - `ctrl+alt+v`
19
+ - Inline image preview in the chat after you send your message (up to **3** images previewed per message).
20
+ - Recent-images support:
21
+ - Searches common Windows screenshot locations by default.
22
+ - Caches images you pasted from clipboard so they also show up in the recent picker.
23
+ - Preview modes:
24
+ - **Sixel** (preferred on Windows) when the PowerShell `Sixel` module is available.
25
+ - **Native** fallback rendering when Sixel conversion is unavailable.
26
+
27
+ ## Installation
28
+
29
+ ### Local extension folder
30
+
31
+ Place this folder in:
32
+
33
+ - Global: `~/.pi/agent/extensions/pi-image-tools`
34
+ - Project: `.pi/extensions/pi-image-tools`
35
+
36
+ Pi auto-discovers these paths.
37
+
38
+ ### As an npm package
39
+
40
+ ```bash
41
+ pi install npm:pi-image-tools
42
+ ```
43
+
44
+ Or from git:
45
+
46
+ ```bash
47
+ pi install git:github.com/MasuRii/pi-image-tools
48
+ ```
49
+
50
+ ## Usage
51
+
52
+ ### Paste from clipboard
53
+
54
+ Command:
55
+
56
+ ```text
57
+ /paste-image clipboard
58
+ ```
59
+
60
+ Notes:
61
+
62
+ - `/paste-image` (with no args) behaves the same as `/paste-image clipboard`.
63
+ - The extension inserts a marker into your draft (`[󰈟 Image Attached]`). When you send, that marker is removed and the queued image(s) are attached to the outgoing message.
64
+ - If you remove all markers from your draft before sending, the pending queued images are discarded.
65
+
66
+ ### Paste from recent images
67
+
68
+ ```text
69
+ /paste-image recent
70
+ ```
71
+
72
+ This opens an interactive picker (requires Pi’s interactive TUI mode). The extension searches for recent images and shows a list like:
73
+
74
+ ```text
75
+ 01. Screenshot 2026-03-02 142233.png • 2m ago • 412 KB • C:\Users\...\Pictures\Screenshots\...
76
+ ```
77
+
78
+ After selection, the image is queued for your next send.
79
+
80
+ ### Shortcuts
81
+
82
+ These shortcuts are equivalent to `/paste-image clipboard`:
83
+
84
+ - `alt+v`
85
+ - `ctrl+alt+v`
86
+
87
+ ## Recent images: what gets searched
88
+
89
+ `/paste-image recent` searches Windows paths in this order:
90
+
91
+ 1. The **recent cache directory** (images you pasted via clipboard are cached here).
92
+ 2. If configured via environment, the directories from `PI_IMAGE_TOOLS_RECENT_DIRS`.
93
+ 3. Otherwise, these defaults:
94
+ - `~/Pictures/Screenshots`
95
+ - `~/OneDrive/Pictures/Screenshots`
96
+ - `~/Desktop` (only files with screenshot-like names such as `Screenshot*`, `Snip*`, `IMG_*`, etc.)
97
+
98
+ Supported file types: `.png`, `.jpg`/`.jpeg`, `.webp`, `.gif`, `.bmp`.
99
+
100
+ ### Environment variables
101
+
102
+ - `PI_IMAGE_TOOLS_RECENT_DIRS`
103
+ - Semicolon-separated list of directories to search (Windows-style):
104
+ - Example: `C:\Users\you\Pictures\Screenshots;D:\Shares\Screens`
105
+ - `PI_IMAGE_TOOLS_RECENT_CACHE_DIR`
106
+ - Overrides where clipboard-pasted images are cached.
107
+ - Default: `%TEMP%\pi-image-tools\recent-cache`
108
+
109
+ ## Preview rendering (native vs Sixel)
110
+
111
+ When Pi displays a user message that contains image attachments, `pi-image-tools` renders an inline preview block under the message.
112
+
113
+ - **Sixel preview (Windows):**
114
+ - The extension tries to detect (and, if missing, install) the PowerShell module `Sixel` under the current user.
115
+ - It then converts the image to a Sixel escape sequence via PowerShell and renders it inline.
116
+ - **Native preview fallback:**
117
+ - If Sixel is unavailable or conversion fails, the extension renders via `@mariozechner/pi-tui`’s `Image` component.
118
+ - When a fallback is used, a warning line may be shown under the preview (and a one-time warning notification can appear on session start).
119
+
120
+ Limit: only the first **3** images in a message are previewed.
121
+
122
+ ## Dependencies / PowerShell notes
123
+
124
+ ### Clipboard access
125
+
126
+ - **Optional native module:** `@mariozechner/clipboard` (declared as an optional dependency).
127
+ - If it is available, `pi-image-tools` uses it first.
128
+ - **PowerShell fallback (Windows):** if the native module is unavailable, the extension calls `powershell.exe` to read an image from the clipboard using .NET (`System.Windows.Forms.Clipboard`).
129
+
130
+ ### Sixel module
131
+
132
+ - Sixel preview uses the **PowerShell module** `Sixel`.
133
+ - `pi-image-tools` attempts to install it automatically (CurrentUser scope) using either `Install-Module` or `Install-PSResource` (depending on what your PowerShell supports).
134
+
135
+ If your environment blocks module installation, you can install it manually (in a PowerShell prompt):
136
+
137
+ ```powershell
138
+ Install-Module -Name Sixel -Scope CurrentUser -Force -AllowClobber
139
+ ```
140
+
141
+ ## Configuration
142
+
143
+ Runtime config is stored at:
144
+
145
+ ```text
146
+ ~/.pi/agent/extensions/pi-image-tools/config.json
147
+ ```
148
+
149
+ A starter file is included as:
150
+
151
+ ```text
152
+ config/config.example.json
153
+ ```
154
+
155
+ Currently the template only contains:
156
+
157
+ ```json
158
+ { "enabled": true }
159
+ ```
160
+
161
+ ## Troubleshooting
162
+
163
+ ### Nothing happens on `/paste-image` or shortcuts
164
+
165
+ - This extension is **Windows-only**. On non-Windows platforms it does not register `/paste-image`.
166
+
167
+ ### “/paste-image recent requires interactive TUI mode.”
168
+
169
+ - The recent picker uses an interactive selection UI. Run Pi in interactive mode (TUI) and retry.
170
+
171
+ ### “No image found in clipboard.”
172
+
173
+ - Confirm you copied an actual image (not just a file path or text).
174
+ - If clipboard reads are failing in general, PowerShell may be restricted by policy or your environment may not allow access to `System.Windows.Forms.Clipboard`.
175
+
176
+ ### Recent picker is empty
177
+
178
+ - By default only a few directories are searched. Configure additional directories via `PI_IMAGE_TOOLS_RECENT_DIRS`.
179
+ - Clipboard-pasted images are cached under `%TEMP%\pi-image-tools\recent-cache` (override with `PI_IMAGE_TOOLS_RECENT_CACHE_DIR`).
180
+
181
+ ### Preview shows a warning about Sixel
182
+
183
+ - The extension falls back to native preview when the `Sixel` PowerShell module is missing or cannot be installed.
184
+ - Install the module manually (see above) and restart Pi.
185
+
186
+ ## Development
187
+
188
+ ```bash
189
+ npm run build
190
+ npm run lint
191
+ npm run test
192
+ npm run check
193
+ ```
194
+
195
+ ## Project layout
196
+
197
+ - `index.ts` - root Pi auto-discovery entrypoint
198
+ - `src/commands.ts` - `/paste-image` command registration and argument handling
199
+ - `src/keybindings.ts` - `alt+v` / `ctrl+alt+v` shortcut registration
200
+ - `src/clipboard.ts` - clipboard image read (optional native module + PowerShell fallback)
201
+ - `src/recent-images.ts` - recent discovery + cache management (`PI_IMAGE_TOOLS_RECENT_DIRS`, `PI_IMAGE_TOOLS_RECENT_CACHE_DIR`)
202
+ - `src/image-preview.ts` - preview item building, Sixel conversion, and message renderer
203
+ - `src/inline-user-preview.ts` - patches Pi TUI message rendering to show inline previews
204
+
205
+ ## License
206
+
207
+ MIT
@@ -0,0 +1,3 @@
1
+ {
2
+ "enabled": true
3
+ }
package/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ import imageToolsExtension from "./src/index.js";
2
+
3
+ export default imageToolsExtension;
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "pi-image-tools",
3
+ "version": "1.0.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
+ "build": "npx --yes -p typescript@5.7.3 tsc -p tsconfig.json --noCheck",
20
+ "lint": "npm run build",
21
+ "test": "node --test",
22
+ "check": "npm run lint && npm run test"
23
+ },
24
+ "keywords": [
25
+ "pi-package",
26
+ "pi",
27
+ "pi-extension",
28
+ "image",
29
+ "clipboard",
30
+ "windows"
31
+ ],
32
+ "author": "MasuRii",
33
+ "license": "MIT",
34
+ "engines": {
35
+ "node": ">=20"
36
+ },
37
+ "publishConfig": {
38
+ "access": "public"
39
+ },
40
+ "pi": {
41
+ "extensions": [
42
+ "./index.ts"
43
+ ]
44
+ },
45
+ "peerDependencies": {
46
+ "@mariozechner/pi-coding-agent": "*",
47
+ "@mariozechner/pi-tui": "*"
48
+ },
49
+ "optionalDependencies": {
50
+ "@mariozechner/clipboard": "*"
51
+ }
52
+ }
@@ -0,0 +1,117 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { createRequire } from "node:module";
3
+
4
+ import type { ClipboardImage, ClipboardModule } from "./types.js";
5
+
6
+ const require = createRequire(import.meta.url);
7
+
8
+ let cachedClipboardModule: ClipboardModule | null | undefined;
9
+
10
+ function loadClipboardModule(): ClipboardModule | null {
11
+ if (cachedClipboardModule !== undefined) {
12
+ return cachedClipboardModule;
13
+ }
14
+
15
+ try {
16
+ cachedClipboardModule = require("@mariozechner/clipboard") as ClipboardModule;
17
+ } catch {
18
+ cachedClipboardModule = null;
19
+ }
20
+
21
+ return cachedClipboardModule;
22
+ }
23
+
24
+ async function readClipboardImageViaNativeModule(): Promise<ClipboardImage | null> {
25
+ const clipboard = loadClipboardModule();
26
+ if (!clipboard || !clipboard.hasImage()) {
27
+ return null;
28
+ }
29
+
30
+ const imageData = await clipboard.getImageBinary();
31
+ if (!imageData || imageData.length === 0) {
32
+ return null;
33
+ }
34
+
35
+ const bytes = imageData instanceof Uint8Array ? imageData : Uint8Array.from(imageData);
36
+ return { bytes, mimeType: "image/png" };
37
+ }
38
+
39
+ function encodePowerShell(script: string): string {
40
+ return Buffer.from(script, "utf16le").toString("base64");
41
+ }
42
+
43
+ function readClipboardImageViaPowerShell(): ClipboardImage | null {
44
+ const script = `
45
+ $ErrorActionPreference = 'Stop'
46
+ Add-Type -AssemblyName System.Windows.Forms
47
+ Add-Type -AssemblyName System.Drawing
48
+
49
+ if (-not [System.Windows.Forms.Clipboard]::ContainsImage()) {
50
+ return
51
+ }
52
+
53
+ $image = [System.Windows.Forms.Clipboard]::GetImage()
54
+ if ($null -eq $image) {
55
+ return
56
+ }
57
+
58
+ $stream = New-Object System.IO.MemoryStream
59
+ try {
60
+ $image.Save($stream, [System.Drawing.Imaging.ImageFormat]::Png)
61
+ [System.Convert]::ToBase64String($stream.ToArray())
62
+ } finally {
63
+ $stream.Dispose()
64
+ $image.Dispose()
65
+ }
66
+ `;
67
+
68
+ const result = spawnSync(
69
+ "powershell.exe",
70
+ [
71
+ "-NoProfile",
72
+ "-NonInteractive",
73
+ "-ExecutionPolicy",
74
+ "Bypass",
75
+ "-STA",
76
+ "-EncodedCommand",
77
+ encodePowerShell(script),
78
+ ],
79
+ {
80
+ encoding: "utf8",
81
+ timeout: 6000,
82
+ maxBuffer: 50 * 1024 * 1024,
83
+ windowsHide: true,
84
+ },
85
+ );
86
+
87
+ if (result.error || result.status !== 0) {
88
+ return null;
89
+ }
90
+
91
+ const base64 = result.stdout.trim();
92
+ if (!base64) {
93
+ return null;
94
+ }
95
+
96
+ try {
97
+ const bytes = Buffer.from(base64, "base64");
98
+ if (bytes.length === 0) {
99
+ return null;
100
+ }
101
+ return { bytes: new Uint8Array(bytes), mimeType: "image/png" };
102
+ } catch {
103
+ return null;
104
+ }
105
+ }
106
+
107
+ export async function readClipboardImage(platform: NodeJS.Platform = process.platform): Promise<ClipboardImage | null> {
108
+ if (platform !== "win32") {
109
+ return null;
110
+ }
111
+
112
+ try {
113
+ return (await readClipboardImageViaNativeModule()) ?? readClipboardImageViaPowerShell();
114
+ } catch {
115
+ return readClipboardImageViaPowerShell();
116
+ }
117
+ }
@@ -0,0 +1,79 @@
1
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
2
+
3
+ import type { PasteImageCommandHandlers } from "./types.js";
4
+
5
+ const SUBCOMMAND_CLIPBOARD = "clipboard";
6
+ const SUBCOMMAND_RECENT = "recent";
7
+
8
+ const ARGUMENT_COMPLETIONS = [
9
+ {
10
+ value: SUBCOMMAND_CLIPBOARD,
11
+ label: SUBCOMMAND_CLIPBOARD,
12
+ description: "Attach image from clipboard",
13
+ },
14
+ {
15
+ value: SUBCOMMAND_RECENT,
16
+ label: SUBCOMMAND_RECENT,
17
+ description: "Open recent images picker and attach selected image",
18
+ },
19
+ {
20
+ value: "help",
21
+ label: "help",
22
+ description: "Show usage",
23
+ },
24
+ ] as const;
25
+
26
+ function parseArgs(args: string): string[] {
27
+ return args
28
+ .trim()
29
+ .split(/\s+/)
30
+ .map((token) => token.trim().toLowerCase())
31
+ .filter((token) => token.length > 0);
32
+ }
33
+
34
+ function usageMessage(): string {
35
+ return "Usage: /paste-image [clipboard|recent]";
36
+ }
37
+
38
+ export function registerPasteImageCommand(
39
+ pi: ExtensionAPI,
40
+ handlers: PasteImageCommandHandlers,
41
+ ): void {
42
+ pi.registerCommand("paste-image", {
43
+ description: "Attach an image from clipboard or use a recent-image picker",
44
+ getArgumentCompletions: (argumentPrefix) => {
45
+ const normalized = argumentPrefix.trim().toLowerCase();
46
+ if (!normalized) {
47
+ return [...ARGUMENT_COMPLETIONS];
48
+ }
49
+
50
+ const matches = ARGUMENT_COMPLETIONS.filter((item) => item.value.startsWith(normalized));
51
+ return matches.length > 0 ? matches.map((item) => ({ ...item })) : null;
52
+ },
53
+ handler: async (args, ctx) => {
54
+ const tokens = parseArgs(args);
55
+
56
+ if (tokens.length === 0 || tokens[0] === SUBCOMMAND_CLIPBOARD) {
57
+ await handlers.fromClipboard(ctx);
58
+ return;
59
+ }
60
+
61
+ if (tokens[0] === SUBCOMMAND_RECENT) {
62
+ if (tokens.length > 1) {
63
+ ctx.ui.notify(usageMessage(), "warning");
64
+ return;
65
+ }
66
+
67
+ await handlers.fromRecent(ctx);
68
+ return;
69
+ }
70
+
71
+ if (tokens[0] === "help") {
72
+ ctx.ui.notify(usageMessage(), "info");
73
+ return;
74
+ }
75
+
76
+ ctx.ui.notify(usageMessage(), "warning");
77
+ },
78
+ });
79
+ }