pi-image-tools 1.0.11 → 1.1.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 +15 -0
- package/README.md +10 -2
- package/package.json +66 -66
- package/src/clipboard.ts +12 -61
- package/src/debug-logger.ts +1 -8
- package/src/errors.ts +11 -0
- package/src/image-mime.ts +60 -0
- package/src/image-preview.ts +62 -126
- package/src/image-size.ts +63 -0
- package/src/index.ts +5 -10
- package/src/inline-user-preview.ts +59 -25
- package/src/powershell.ts +71 -0
- package/src/recent-images.ts +14 -49
- package/src/terminal-image-width.ts +3 -1
- package/src/temp-file.ts +0 -82
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.1.0] - 2026-05-03
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Added centralized image MIME, image-size, PowerShell, and error utilities used by clipboard, preview, attachment, and recent-image flows.
|
|
7
|
+
- Added size-limit enforcement for image attachments, recent-cache writes, recent-image loads, and Sixel preview conversion through `PI_IMAGE_TOOLS_MAX_IMAGE_BYTES`.
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
- Reworked recent-image caching to use an extension-owned cache directory with safe pruning that preserves user files.
|
|
11
|
+
- Reworked Sixel preview rendering to use an existing PowerShell `Sixel` module only, normalize converter output into complete terminal sequences, and fall back to native previews with actionable warnings.
|
|
12
|
+
- Reworked terminal image width resolution to honor Pi project/global `terminal.imageWidthCells` settings with a documented default fallback.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
- Prevented extension debug logging from writing terminal output; debug events now remain file-based and disabled by default.
|
|
16
|
+
- Preserved Sixel, Kitty, and iTerm inline image protocol rows during preview width fitting.
|
|
17
|
+
|
|
3
18
|
## [1.0.11] - 2026-04-25
|
|
4
19
|
|
|
5
20
|
### Changed
|
package/README.md
CHANGED
|
@@ -114,6 +114,7 @@ Pi's built-in image paste shortcut is not overridden by default. To keep the pre
|
|
|
114
114
|
|----------|-------------|---------|
|
|
115
115
|
| `PI_IMAGE_TOOLS_RECENT_DIRS` | Semicolon-separated directories to search for recent images | Platform defaults listed below |
|
|
116
116
|
| `PI_IMAGE_TOOLS_RECENT_CACHE_DIR` | Custom cache directory for clipboard-pasted images | OS temp dir + `pi-image-tools/recent-cache` |
|
|
117
|
+
| `PI_IMAGE_TOOLS_MAX_IMAGE_BYTES` | Maximum accepted image payload size before attachment, recent-cache writes, and preview conversion | `20971520` (20 MB) |
|
|
117
118
|
|
|
118
119
|
Example:
|
|
119
120
|
|
|
@@ -254,8 +255,10 @@ When you queue one or more images, the extension renders an inline preview insid
|
|
|
254
255
|
|
|
255
256
|
Preview behavior:
|
|
256
257
|
- up to **3 images** are previewed per message
|
|
257
|
-
- Sixel rendering is attempted on Windows when
|
|
258
|
+
- Sixel rendering is attempted on Windows when the PowerShell `Sixel` module is already installed
|
|
259
|
+
- no PowerShell modules are installed automatically at runtime
|
|
258
260
|
- native TUI image rendering is used as the fallback
|
|
261
|
+
- image payloads over `PI_IMAGE_TOOLS_MAX_IMAGE_BYTES` are rejected before attachment, recent-cache writes, or Sixel conversion
|
|
259
262
|
- inline width fitting now preserves Sixel, Kitty, and iTerm image protocol rows instead of truncating them like plain text
|
|
260
263
|
|
|
261
264
|
### Clipboard readers
|
|
@@ -285,11 +288,16 @@ pi-image-tools/
|
|
|
285
288
|
│ ├── clipboard.ts # Cross-platform clipboard image reading
|
|
286
289
|
│ ├── config.ts # Runtime config loading and validation
|
|
287
290
|
│ ├── debug-logger.ts # File-based debug logging
|
|
291
|
+
│ ├── errors.ts # Shared error normalization
|
|
292
|
+
│ ├── image-mime.ts # Shared image MIME and extension mapping
|
|
293
|
+
│ ├── image-size.ts # Shared image byte-size limits
|
|
288
294
|
│ ├── recent-images.ts # Recent image discovery and cache management
|
|
289
295
|
│ ├── image-preview.ts # Preview building and Sixel/native rendering
|
|
290
296
|
│ ├── inline-user-preview.ts # Inline preview patching for user messages
|
|
291
297
|
│ ├── keybindings.ts # Keyboard shortcut registration
|
|
292
|
-
│ ├──
|
|
298
|
+
│ ├── powershell.ts # Shared PowerShell command runner
|
|
299
|
+
│ ├── sixel-protocol.ts # Sixel protocol normalization and render lines
|
|
300
|
+
│ ├── terminal-image-width.ts # Terminal image width settings resolution
|
|
293
301
|
│ └── types.ts # Shared TypeScript types
|
|
294
302
|
└── config/
|
|
295
303
|
└── config.example.json # Starter runtime config
|
package/package.json
CHANGED
|
@@ -1,66 +1,66 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "pi-image-tools",
|
|
3
|
-
"version": "1.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
|
-
"pi-coding-agent",
|
|
29
|
-
"pi-tui",
|
|
30
|
-
"coding-agent",
|
|
31
|
-
"image",
|
|
32
|
-
"image-attachment",
|
|
33
|
-
"image-preview",
|
|
34
|
-
"clipboard",
|
|
35
|
-
"preview",
|
|
36
|
-
"windows"
|
|
37
|
-
],
|
|
38
|
-
"author": "MasuRii",
|
|
39
|
-
"license": "MIT",
|
|
40
|
-
"repository": {
|
|
41
|
-
"type": "git",
|
|
42
|
-
"url": "git+https://github.com/MasuRii/pi-image-tools.git"
|
|
43
|
-
},
|
|
44
|
-
"homepage": "https://github.com/MasuRii/pi-image-tools#readme",
|
|
45
|
-
"bugs": {
|
|
46
|
-
"url": "https://github.com/MasuRii/pi-image-tools/issues"
|
|
47
|
-
},
|
|
48
|
-
"engines": {
|
|
49
|
-
"node": ">=20"
|
|
50
|
-
},
|
|
51
|
-
"publishConfig": {
|
|
52
|
-
"access": "public"
|
|
53
|
-
},
|
|
54
|
-
"pi": {
|
|
55
|
-
"extensions": [
|
|
56
|
-
"./index.ts"
|
|
57
|
-
]
|
|
58
|
-
},
|
|
59
|
-
"peerDependencies": {
|
|
60
|
-
"@mariozechner/pi-coding-agent": "^0.
|
|
61
|
-
"@mariozechner/pi-tui": "^0.
|
|
62
|
-
},
|
|
63
|
-
"optionalDependencies": {
|
|
64
|
-
"@mariozechner/clipboard": "^0.3.
|
|
65
|
-
}
|
|
66
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "pi-image-tools",
|
|
3
|
+
"version": "1.1.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
|
+
"pi-coding-agent",
|
|
29
|
+
"pi-tui",
|
|
30
|
+
"coding-agent",
|
|
31
|
+
"image",
|
|
32
|
+
"image-attachment",
|
|
33
|
+
"image-preview",
|
|
34
|
+
"clipboard",
|
|
35
|
+
"preview",
|
|
36
|
+
"windows"
|
|
37
|
+
],
|
|
38
|
+
"author": "MasuRii",
|
|
39
|
+
"license": "MIT",
|
|
40
|
+
"repository": {
|
|
41
|
+
"type": "git",
|
|
42
|
+
"url": "git+https://github.com/MasuRii/pi-image-tools.git"
|
|
43
|
+
},
|
|
44
|
+
"homepage": "https://github.com/MasuRii/pi-image-tools#readme",
|
|
45
|
+
"bugs": {
|
|
46
|
+
"url": "https://github.com/MasuRii/pi-image-tools/issues"
|
|
47
|
+
},
|
|
48
|
+
"engines": {
|
|
49
|
+
"node": ">=20"
|
|
50
|
+
},
|
|
51
|
+
"publishConfig": {
|
|
52
|
+
"access": "public"
|
|
53
|
+
},
|
|
54
|
+
"pi": {
|
|
55
|
+
"extensions": [
|
|
56
|
+
"./index.ts"
|
|
57
|
+
]
|
|
58
|
+
},
|
|
59
|
+
"peerDependencies": {
|
|
60
|
+
"@mariozechner/pi-coding-agent": "^0.72.0",
|
|
61
|
+
"@mariozechner/pi-tui": "^0.72.0"
|
|
62
|
+
},
|
|
63
|
+
"optionalDependencies": {
|
|
64
|
+
"@mariozechner/clipboard": "^0.3.5"
|
|
65
|
+
}
|
|
66
|
+
}
|
package/src/clipboard.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { createRequire } from "node:module";
|
|
3
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";
|
|
4
7
|
import type { ClipboardImage, ClipboardModule } from "./types.js";
|
|
5
8
|
|
|
6
9
|
const require = createRequire(import.meta.url);
|
|
@@ -8,14 +11,6 @@ const require = createRequire(import.meta.url);
|
|
|
8
11
|
const LIST_TYPES_TIMEOUT_MS = 1000;
|
|
9
12
|
const READ_TIMEOUT_MS = 5000;
|
|
10
13
|
const MAX_BUFFER_BYTES = 50 * 1024 * 1024;
|
|
11
|
-
const SUPPORTED_IMAGE_MIME_TYPES = [
|
|
12
|
-
"image/png",
|
|
13
|
-
"image/jpeg",
|
|
14
|
-
"image/webp",
|
|
15
|
-
"image/gif",
|
|
16
|
-
"image/bmp",
|
|
17
|
-
] as const;
|
|
18
|
-
|
|
19
14
|
let cachedClipboardModule: ClipboardModule | null | undefined;
|
|
20
15
|
|
|
21
16
|
interface CommandResult {
|
|
@@ -29,10 +24,6 @@ interface ClipboardReadResult {
|
|
|
29
24
|
image: ClipboardImage | null;
|
|
30
25
|
}
|
|
31
26
|
|
|
32
|
-
function isErrnoException(error: Error): error is NodeJS.ErrnoException {
|
|
33
|
-
return "code" in error;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
27
|
function hasGraphicalSession(platform: NodeJS.Platform, environment: NodeJS.ProcessEnv): boolean {
|
|
37
28
|
return platform !== "linux" || Boolean(environment.DISPLAY || environment.WAYLAND_DISPLAY);
|
|
38
29
|
}
|
|
@@ -41,27 +32,6 @@ function isWaylandSession(environment: NodeJS.ProcessEnv): boolean {
|
|
|
41
32
|
return Boolean(environment.WAYLAND_DISPLAY) || environment.XDG_SESSION_TYPE === "wayland";
|
|
42
33
|
}
|
|
43
34
|
|
|
44
|
-
function normalizeMimeType(mimeType: string): string {
|
|
45
|
-
return mimeType.split(";")[0]?.trim().toLowerCase() ?? mimeType.toLowerCase();
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function selectPreferredImageMimeType(mimeTypes: readonly string[]): string | null {
|
|
49
|
-
const normalized = mimeTypes
|
|
50
|
-
.map((mimeType) => mimeType.trim())
|
|
51
|
-
.filter((mimeType) => mimeType.length > 0)
|
|
52
|
-
.map((mimeType) => ({ raw: mimeType, normalized: normalizeMimeType(mimeType) }));
|
|
53
|
-
|
|
54
|
-
for (const preferredMimeType of SUPPORTED_IMAGE_MIME_TYPES) {
|
|
55
|
-
const match = normalized.find((mimeType) => mimeType.normalized === preferredMimeType);
|
|
56
|
-
if (match) {
|
|
57
|
-
return match.raw;
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const firstImage = normalized.find((mimeType) => mimeType.normalized.startsWith("image/"));
|
|
62
|
-
return firstImage?.raw ?? null;
|
|
63
|
-
}
|
|
64
|
-
|
|
65
35
|
function loadClipboardModule(
|
|
66
36
|
platform: NodeJS.Platform = process.platform,
|
|
67
37
|
environment: NodeJS.ProcessEnv = process.env,
|
|
@@ -141,10 +111,6 @@ function runCommand(
|
|
|
141
111
|
};
|
|
142
112
|
}
|
|
143
113
|
|
|
144
|
-
function encodePowerShell(script: string): string {
|
|
145
|
-
return Buffer.from(script, "utf16le").toString("base64");
|
|
146
|
-
}
|
|
147
|
-
|
|
148
114
|
function readClipboardImageViaPowerShell(): ClipboardReadResult {
|
|
149
115
|
const script = `
|
|
150
116
|
$ErrorActionPreference = 'Stop'
|
|
@@ -170,33 +136,18 @@ try {
|
|
|
170
136
|
}
|
|
171
137
|
`;
|
|
172
138
|
|
|
173
|
-
const result =
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
"Bypass",
|
|
180
|
-
"-STA",
|
|
181
|
-
"-EncodedCommand",
|
|
182
|
-
encodePowerShell(script),
|
|
183
|
-
],
|
|
184
|
-
{
|
|
185
|
-
encoding: "utf8",
|
|
186
|
-
timeout: READ_TIMEOUT_MS,
|
|
187
|
-
maxBuffer: MAX_BUFFER_BYTES,
|
|
188
|
-
windowsHide: true,
|
|
189
|
-
},
|
|
190
|
-
);
|
|
139
|
+
const result = runPowerShellCommand(script, {
|
|
140
|
+
encoded: true,
|
|
141
|
+
sta: true,
|
|
142
|
+
timeout: READ_TIMEOUT_MS,
|
|
143
|
+
maxBuffer: MAX_BUFFER_BYTES,
|
|
144
|
+
});
|
|
191
145
|
|
|
192
|
-
if (result.
|
|
193
|
-
return {
|
|
194
|
-
available: !isErrnoException(result.error) || result.error.code !== "ENOENT",
|
|
195
|
-
image: null,
|
|
196
|
-
};
|
|
146
|
+
if (result.missingCommand) {
|
|
147
|
+
return { available: false, image: null };
|
|
197
148
|
}
|
|
198
149
|
|
|
199
|
-
if (result.
|
|
150
|
+
if (!result.ok) {
|
|
200
151
|
return { available: true, image: null };
|
|
201
152
|
}
|
|
202
153
|
|
package/src/debug-logger.ts
CHANGED
|
@@ -2,20 +2,13 @@ import { appendFileSync, mkdirSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
|
|
4
4
|
import { getExtensionRoot, type ImageToolsConfig } from "./config.js";
|
|
5
|
+
import { getErrorMessage } from "./errors.js";
|
|
5
6
|
|
|
6
7
|
const DEBUG_DIRECTORY_NAME = "debug";
|
|
7
8
|
const DEBUG_LOG_FILE_NAME = "debug.log";
|
|
8
9
|
|
|
9
10
|
type DebugFields = Record<string, unknown>;
|
|
10
11
|
|
|
11
|
-
function getErrorMessage(error: unknown): string {
|
|
12
|
-
if (error instanceof Error && error.message.trim().length > 0) {
|
|
13
|
-
return error.message;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
return "Unknown error";
|
|
17
|
-
}
|
|
18
|
-
|
|
19
12
|
export class DebugLogger {
|
|
20
13
|
private readonly logPath: string | undefined;
|
|
21
14
|
|
package/src/errors.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export function getErrorMessage(error: unknown): string {
|
|
2
|
+
if (error instanceof Error && error.message.trim().length > 0) {
|
|
3
|
+
return error.message;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
return "Unknown error";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function isErrnoException(error: unknown): error is NodeJS.ErrnoException {
|
|
10
|
+
return error instanceof Error && "code" in error;
|
|
11
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
const PREFERRED_IMAGE_MIME_TYPES = [
|
|
2
|
+
"image/png",
|
|
3
|
+
"image/jpeg",
|
|
4
|
+
"image/webp",
|
|
5
|
+
"image/gif",
|
|
6
|
+
"image/bmp",
|
|
7
|
+
] as const;
|
|
8
|
+
|
|
9
|
+
const MIME_TYPE_TO_EXTENSION = new Map<string, string>([
|
|
10
|
+
["image/png", "png"],
|
|
11
|
+
["image/jpeg", "jpg"],
|
|
12
|
+
["image/webp", "webp"],
|
|
13
|
+
["image/gif", "gif"],
|
|
14
|
+
["image/bmp", "bmp"],
|
|
15
|
+
["image/tiff", "tiff"],
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
const EXTENSION_TO_MIME_TYPE = new Map<string, string>([
|
|
19
|
+
[".png", "image/png"],
|
|
20
|
+
[".jpg", "image/jpeg"],
|
|
21
|
+
[".jpeg", "image/jpeg"],
|
|
22
|
+
[".webp", "image/webp"],
|
|
23
|
+
[".gif", "image/gif"],
|
|
24
|
+
[".bmp", "image/bmp"],
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
export const SUPPORTED_IMAGE_MIME_TYPES: readonly string[] = PREFERRED_IMAGE_MIME_TYPES;
|
|
28
|
+
|
|
29
|
+
export function normalizeMimeType(mimeType: string): string {
|
|
30
|
+
return mimeType.split(";")[0]?.trim().toLowerCase() ?? mimeType.toLowerCase();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function selectPreferredImageMimeType(mimeTypes: readonly string[]): string | null {
|
|
34
|
+
const normalized = mimeTypes
|
|
35
|
+
.map((mimeType) => mimeType.trim())
|
|
36
|
+
.filter((mimeType) => mimeType.length > 0)
|
|
37
|
+
.map((mimeType) => ({ raw: mimeType, normalized: normalizeMimeType(mimeType) }));
|
|
38
|
+
|
|
39
|
+
for (const preferredMimeType of SUPPORTED_IMAGE_MIME_TYPES) {
|
|
40
|
+
const match = normalized.find((mimeType) => mimeType.normalized === preferredMimeType);
|
|
41
|
+
if (match) {
|
|
42
|
+
return match.raw;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const firstImage = normalized.find((mimeType) => mimeType.normalized.startsWith("image/"));
|
|
47
|
+
return firstImage?.raw ?? null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function mimeTypeToExtension(mimeType: string, fallbackExtension = "png"): string {
|
|
51
|
+
return MIME_TYPE_TO_EXTENSION.get(normalizeMimeType(mimeType)) ?? fallbackExtension;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function extensionToMimeType(fileNameOrExtension: string): string | null {
|
|
55
|
+
const extension = fileNameOrExtension.startsWith(".")
|
|
56
|
+
? fileNameOrExtension.toLowerCase()
|
|
57
|
+
: `.${fileNameOrExtension.toLowerCase()}`;
|
|
58
|
+
|
|
59
|
+
return EXTENSION_TO_MIME_TYPE.get(extension) ?? null;
|
|
60
|
+
}
|
package/src/image-preview.ts
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { spawnSync } from "node:child_process";
|
|
2
1
|
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
3
2
|
import { tmpdir } from "node:os";
|
|
4
3
|
import { join } from "node:path";
|
|
@@ -15,6 +14,12 @@ import {
|
|
|
15
14
|
type Component,
|
|
16
15
|
} from "@mariozechner/pi-tui";
|
|
17
16
|
|
|
17
|
+
import { isRecord } from "./config.js";
|
|
18
|
+
import type { DebugLogger } from "./debug-logger.js";
|
|
19
|
+
import { getErrorMessage } from "./errors.js";
|
|
20
|
+
import { getBase64DecodedByteLength, assertImageWithinByteLimit } from "./image-size.js";
|
|
21
|
+
import { mimeTypeToExtension } from "./image-mime.js";
|
|
22
|
+
import { runPowerShellCommand } from "./powershell.js";
|
|
18
23
|
import { buildSixelRenderLines, ensureCompleteSixelSequence } from "./sixel-protocol.js";
|
|
19
24
|
import {
|
|
20
25
|
DEFAULT_TERMINAL_IMAGE_WIDTH_CELLS,
|
|
@@ -77,22 +82,6 @@ function normalizeText(value: unknown): string {
|
|
|
77
82
|
return typeof value === "string" ? value.trim() : "";
|
|
78
83
|
}
|
|
79
84
|
|
|
80
|
-
function getErrorMessage(error: unknown): string {
|
|
81
|
-
if (error instanceof Error && error.message.trim()) {
|
|
82
|
-
return error.message;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
return "Unknown error";
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function toRecord(value: unknown): Record<string, unknown> {
|
|
89
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
90
|
-
return {};
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
return value as Record<string, unknown>;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
85
|
function normalizeEnvValue(value: string | undefined): string {
|
|
97
86
|
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
98
87
|
}
|
|
@@ -118,63 +107,6 @@ function shouldAttemptSixelRendering(): boolean {
|
|
|
118
107
|
return !getCapabilities().images;
|
|
119
108
|
}
|
|
120
109
|
|
|
121
|
-
function runPowerShellCommand(
|
|
122
|
-
script: string,
|
|
123
|
-
args: string[] = [],
|
|
124
|
-
): { ok: boolean; stdout: string; stderr: string; reason?: string } {
|
|
125
|
-
if (process.platform !== "win32") {
|
|
126
|
-
return {
|
|
127
|
-
ok: false,
|
|
128
|
-
stdout: "",
|
|
129
|
-
stderr: "",
|
|
130
|
-
reason: "PowerShell-based Sixel rendering is only available on Windows.",
|
|
131
|
-
};
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
const result = spawnSync(
|
|
135
|
-
"powershell.exe",
|
|
136
|
-
[
|
|
137
|
-
"-NoProfile",
|
|
138
|
-
"-NonInteractive",
|
|
139
|
-
"-ExecutionPolicy",
|
|
140
|
-
"Bypass",
|
|
141
|
-
"-Command",
|
|
142
|
-
script,
|
|
143
|
-
...args,
|
|
144
|
-
],
|
|
145
|
-
{
|
|
146
|
-
encoding: "utf8",
|
|
147
|
-
timeout: POWER_SHELL_TIMEOUT_MS,
|
|
148
|
-
maxBuffer: POWER_SHELL_MAX_BUFFER_BYTES,
|
|
149
|
-
windowsHide: true,
|
|
150
|
-
},
|
|
151
|
-
);
|
|
152
|
-
|
|
153
|
-
if (result.error) {
|
|
154
|
-
return {
|
|
155
|
-
ok: false,
|
|
156
|
-
stdout: result.stdout ?? "",
|
|
157
|
-
stderr: result.stderr ?? "",
|
|
158
|
-
reason: getErrorMessage(result.error),
|
|
159
|
-
};
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
if (result.status !== 0) {
|
|
163
|
-
return {
|
|
164
|
-
ok: false,
|
|
165
|
-
stdout: result.stdout ?? "",
|
|
166
|
-
stderr: result.stderr ?? "",
|
|
167
|
-
reason: `PowerShell exited with code ${result.status}`,
|
|
168
|
-
};
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
return {
|
|
172
|
-
ok: true,
|
|
173
|
-
stdout: result.stdout ?? "",
|
|
174
|
-
stderr: result.stderr ?? "",
|
|
175
|
-
};
|
|
176
|
-
}
|
|
177
|
-
|
|
178
110
|
const sixelAvailabilityState: SixelAvailability = {
|
|
179
111
|
checked: false,
|
|
180
112
|
available: false,
|
|
@@ -191,26 +123,16 @@ $ProgressPreference = 'SilentlyContinue'
|
|
|
191
123
|
|
|
192
124
|
$module = Get-Module -ListAvailable -Name Sixel | Sort-Object Version -Descending | Select-Object -First 1
|
|
193
125
|
if ($null -eq $module) {
|
|
194
|
-
|
|
195
|
-
if (Get-Command Install-Module -ErrorAction SilentlyContinue) {
|
|
196
|
-
Install-Module -Name Sixel -Scope CurrentUser -Force -AllowClobber -Repository PSGallery -ErrorAction Stop | Out-Null
|
|
197
|
-
} elseif (Get-Command Install-PSResource -ErrorAction SilentlyContinue) {
|
|
198
|
-
Install-PSResource -Name Sixel -Scope CurrentUser -TrustRepository -Reinstall -Force -ErrorAction Stop | Out-Null
|
|
199
|
-
}
|
|
200
|
-
} catch {
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
$module = Get-Module -ListAvailable -Name Sixel | Sort-Object Version -Descending | Select-Object -First 1
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
if ($null -eq $module) {
|
|
207
|
-
Write-Error 'Sixel PowerShell module is unavailable.'
|
|
126
|
+
Write-Error 'Sixel PowerShell module is unavailable. Install the Sixel module manually to enable Sixel previews.'
|
|
208
127
|
}
|
|
209
128
|
|
|
210
129
|
Write-Output ('Sixel/' + $module.Version.ToString())
|
|
211
130
|
`;
|
|
212
131
|
|
|
213
|
-
const result = runPowerShellCommand(script
|
|
132
|
+
const result = runPowerShellCommand(script, {
|
|
133
|
+
timeout: POWER_SHELL_TIMEOUT_MS,
|
|
134
|
+
maxBuffer: POWER_SHELL_MAX_BUFFER_BYTES,
|
|
135
|
+
});
|
|
214
136
|
sixelAvailabilityState.checked = true;
|
|
215
137
|
|
|
216
138
|
if (!result.ok) {
|
|
@@ -219,7 +141,7 @@ Write-Output ('Sixel/' + $module.Version.ToString())
|
|
|
219
141
|
sixelAvailabilityState.available = false;
|
|
220
142
|
sixelAvailabilityState.version = undefined;
|
|
221
143
|
sixelAvailabilityState.reason =
|
|
222
|
-
stderr || stdout || result.reason || "Failed to detect
|
|
144
|
+
stderr || stdout || result.reason || "Failed to detect the Sixel PowerShell module.";
|
|
223
145
|
return sixelAvailabilityState;
|
|
224
146
|
}
|
|
225
147
|
|
|
@@ -232,26 +154,6 @@ Write-Output ('Sixel/' + $module.Version.ToString())
|
|
|
232
154
|
return sixelAvailabilityState;
|
|
233
155
|
}
|
|
234
156
|
|
|
235
|
-
function extensionForImageMimeType(mimeType: string): string {
|
|
236
|
-
const normalized = mimeType.split(";")[0]?.trim().toLowerCase() ?? mimeType.toLowerCase();
|
|
237
|
-
switch (normalized) {
|
|
238
|
-
case "image/png":
|
|
239
|
-
return "png";
|
|
240
|
-
case "image/jpeg":
|
|
241
|
-
return "jpg";
|
|
242
|
-
case "image/webp":
|
|
243
|
-
return "webp";
|
|
244
|
-
case "image/gif":
|
|
245
|
-
return "gif";
|
|
246
|
-
case "image/bmp":
|
|
247
|
-
return "bmp";
|
|
248
|
-
case "image/tiff":
|
|
249
|
-
return "tiff";
|
|
250
|
-
default:
|
|
251
|
-
return "png";
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
157
|
function escapePowerShellSingleQuoted(value: string): string {
|
|
256
158
|
return value.replace(/'/g, "''");
|
|
257
159
|
}
|
|
@@ -260,9 +162,10 @@ function convertImageToSixelSequence(
|
|
|
260
162
|
image: ImagePayload,
|
|
261
163
|
): { sequence?: string; error?: string } {
|
|
262
164
|
const tempBaseDir = mkdtempSync(join(tmpdir(), "pi-image-tools-image-"));
|
|
263
|
-
const imagePath = join(tempBaseDir, `preview.${
|
|
165
|
+
const imagePath = join(tempBaseDir, `preview.${mimeTypeToExtension(image.mimeType)}`);
|
|
264
166
|
|
|
265
167
|
try {
|
|
168
|
+
assertImageWithinByteLimit(getBase64DecodedByteLength(image.data), "Preview image");
|
|
266
169
|
const bytes = Buffer.from(image.data, "base64");
|
|
267
170
|
if (bytes.length === 0) {
|
|
268
171
|
return { error: "Image conversion failed: clipboard payload was empty." };
|
|
@@ -292,7 +195,10 @@ if ([string]::IsNullOrWhiteSpace($rendered)) {
|
|
|
292
195
|
Write-Output $rendered
|
|
293
196
|
`;
|
|
294
197
|
|
|
295
|
-
const result = runPowerShellCommand(script
|
|
198
|
+
const result = runPowerShellCommand(script, {
|
|
199
|
+
timeout: POWER_SHELL_TIMEOUT_MS,
|
|
200
|
+
maxBuffer: POWER_SHELL_MAX_BUFFER_BYTES,
|
|
201
|
+
});
|
|
296
202
|
if (!result.ok) {
|
|
297
203
|
const detail = normalizeText(result.stderr) || normalizeText(result.stdout) || result.reason;
|
|
298
204
|
return {
|
|
@@ -328,15 +234,22 @@ function estimateImageRows(image: ImagePayload, maxWidthCells: number): number {
|
|
|
328
234
|
}
|
|
329
235
|
|
|
330
236
|
function parseImagePreviewDetails(value: unknown): ImagePreviewDetails | null {
|
|
331
|
-
|
|
332
|
-
|
|
237
|
+
if (!isRecord(value)) {
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const itemsRaw = value.items;
|
|
333
242
|
if (!Array.isArray(itemsRaw)) {
|
|
334
243
|
return null;
|
|
335
244
|
}
|
|
336
245
|
|
|
337
246
|
const items: ImagePreviewItem[] = [];
|
|
338
247
|
for (const raw of itemsRaw) {
|
|
339
|
-
|
|
248
|
+
if (!isRecord(raw)) {
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const itemRecord = raw;
|
|
340
253
|
const protocol = itemRecord.protocol === "sixel" ? "sixel" : "native";
|
|
341
254
|
const mimeType = typeof itemRecord.mimeType === "string" ? itemRecord.mimeType : "image/png";
|
|
342
255
|
const rows =
|
|
@@ -432,7 +345,26 @@ export function buildPreviewItems(
|
|
|
432
345
|
});
|
|
433
346
|
}
|
|
434
347
|
|
|
435
|
-
export
|
|
348
|
+
export interface RegisterImagePreviewDisplayOptions {
|
|
349
|
+
logger?: DebugLogger;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function logPreviewHandlerError(
|
|
353
|
+
logger: DebugLogger | undefined,
|
|
354
|
+
event: string,
|
|
355
|
+
error: unknown,
|
|
356
|
+
): void {
|
|
357
|
+
try {
|
|
358
|
+
logger?.log(event, { error: getErrorMessage(error) });
|
|
359
|
+
} catch {
|
|
360
|
+
// Debug logging is best-effort inside Pi event handlers.
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
export function registerImagePreviewDisplay(
|
|
365
|
+
pi: ExtensionAPI,
|
|
366
|
+
options: RegisterImagePreviewDisplayOptions = {},
|
|
367
|
+
): void {
|
|
436
368
|
let warnedSixelSetup = false;
|
|
437
369
|
|
|
438
370
|
pi.registerMessageRenderer<ImagePreviewDetails>(
|
|
@@ -481,17 +413,21 @@ export function registerImagePreviewDisplay(pi: ExtensionAPI): void {
|
|
|
481
413
|
);
|
|
482
414
|
|
|
483
415
|
pi.on("session_start", async (_event, ctx) => {
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
416
|
+
try {
|
|
417
|
+
if (!shouldAttemptSixelRendering()) {
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
487
420
|
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
421
|
+
const availability = ensureSixelModuleAvailable();
|
|
422
|
+
if (!availability.available && !warnedSixelSetup && ctx.hasUI) {
|
|
423
|
+
warnedSixelSetup = true;
|
|
424
|
+
ctx.ui.notify(
|
|
425
|
+
`Image preview fallback active: ${availability.reason || "Sixel module unavailable."}`,
|
|
426
|
+
"warning",
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
} catch (error) {
|
|
430
|
+
logPreviewHandlerError(options.logger, "image-preview.session_start_failed", error);
|
|
495
431
|
}
|
|
496
432
|
});
|
|
497
433
|
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
export const IMAGE_TOOLS_MAX_IMAGE_BYTES_ENV_VAR = "PI_IMAGE_TOOLS_MAX_IMAGE_BYTES";
|
|
2
|
+
export const DEFAULT_MAX_IMAGE_BYTES = 20 * 1024 * 1024;
|
|
3
|
+
|
|
4
|
+
function parseMaxImageBytes(environment: NodeJS.ProcessEnv): number {
|
|
5
|
+
const rawValue = environment[IMAGE_TOOLS_MAX_IMAGE_BYTES_ENV_VAR]?.trim();
|
|
6
|
+
if (!rawValue) {
|
|
7
|
+
return DEFAULT_MAX_IMAGE_BYTES;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const parsed = Number(rawValue);
|
|
11
|
+
if (!Number.isFinite(parsed) || parsed < 1) {
|
|
12
|
+
throw new Error(
|
|
13
|
+
`${IMAGE_TOOLS_MAX_IMAGE_BYTES_ENV_VAR} must be a positive byte count when set.`,
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return Math.floor(parsed);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function getMaxImageBytes(environment: NodeJS.ProcessEnv = process.env): number {
|
|
21
|
+
return parseMaxImageBytes(environment);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function formatByteLimit(bytes: number): string {
|
|
25
|
+
if (bytes < 1024) {
|
|
26
|
+
return `${bytes} B`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const units = ["KB", "MB", "GB"] as const;
|
|
30
|
+
let value = bytes / 1024;
|
|
31
|
+
let unitIndex = 0;
|
|
32
|
+
|
|
33
|
+
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
34
|
+
value /= 1024;
|
|
35
|
+
unitIndex += 1;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function assertImageWithinByteLimit(
|
|
42
|
+
sizeBytes: number,
|
|
43
|
+
label: string,
|
|
44
|
+
environment: NodeJS.ProcessEnv = process.env,
|
|
45
|
+
): void {
|
|
46
|
+
const maxImageBytes = getMaxImageBytes(environment);
|
|
47
|
+
if (sizeBytes > maxImageBytes) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`${label} is too large (${formatByteLimit(sizeBytes)}). The pi-image-tools limit is ${formatByteLimit(maxImageBytes)}. Set ${IMAGE_TOOLS_MAX_IMAGE_BYTES_ENV_VAR} to a larger byte count if needed.`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function getBase64DecodedByteLength(base64Data: string): number {
|
|
55
|
+
const normalized = base64Data.trim().replace(/^data:[^,]*,/, "").replace(/\s/g, "");
|
|
56
|
+
if (normalized.length === 0) {
|
|
57
|
+
return 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const padding = normalized.endsWith("==") ? 2 : normalized.endsWith("=") ? 1 : 0;
|
|
61
|
+
return Math.max(0, Math.floor((normalized.length * 3) / 4) - padding);
|
|
62
|
+
}
|
|
63
|
+
|
package/src/index.ts
CHANGED
|
@@ -4,6 +4,8 @@ import { readClipboardImage } from "./clipboard.js";
|
|
|
4
4
|
import { registerPasteImageCommand } from "./commands.js";
|
|
5
5
|
import { loadImageToolsConfig } from "./config.js";
|
|
6
6
|
import { DebugLogger } from "./debug-logger.js";
|
|
7
|
+
import { getErrorMessage } from "./errors.js";
|
|
8
|
+
import { assertImageWithinByteLimit } from "./image-size.js";
|
|
7
9
|
import {
|
|
8
10
|
IMAGE_PREVIEW_CUSTOM_TYPE,
|
|
9
11
|
buildPreviewItems,
|
|
@@ -27,15 +29,8 @@ const IMAGE_ATTACHMENT_INDICATOR = "[ Image Attached]";
|
|
|
27
29
|
|
|
28
30
|
interface PendingImage extends ImagePayload {}
|
|
29
31
|
|
|
30
|
-
function getErrorMessage(error: unknown): string {
|
|
31
|
-
if (error instanceof Error && error.message.trim().length > 0) {
|
|
32
|
-
return error.message;
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
return "Unknown error";
|
|
36
|
-
}
|
|
37
|
-
|
|
38
32
|
function imageToBase64(image: ClipboardImage): string {
|
|
33
|
+
assertImageWithinByteLimit(image.bytes.length, "Image attachment");
|
|
39
34
|
return Buffer.from(image.bytes).toString("base64");
|
|
40
35
|
}
|
|
41
36
|
|
|
@@ -155,8 +150,8 @@ export default function imageToolsExtension(pi: ExtensionAPI): void {
|
|
|
155
150
|
pasteImageShortcutsConfigured: config.shortcuts.pasteImage !== undefined,
|
|
156
151
|
});
|
|
157
152
|
|
|
158
|
-
registerInlineUserImagePreview(pi);
|
|
159
|
-
registerImagePreviewDisplay(pi);
|
|
153
|
+
registerInlineUserImagePreview(pi, { logger });
|
|
154
|
+
registerImagePreviewDisplay(pi, { logger });
|
|
160
155
|
|
|
161
156
|
const pasteImageFromClipboard = async (ctx: PasteContext): Promise<void> => {
|
|
162
157
|
if (!ctx.hasUI) {
|
|
@@ -5,6 +5,9 @@ import {
|
|
|
5
5
|
} from "@mariozechner/pi-coding-agent";
|
|
6
6
|
import { Image, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
7
7
|
|
|
8
|
+
import { isRecord } from "./config.js";
|
|
9
|
+
import type { DebugLogger } from "./debug-logger.js";
|
|
10
|
+
import { getErrorMessage } from "./errors.js";
|
|
8
11
|
import { buildPreviewItems, type ImagePayload, type ImagePreviewItem } from "./image-preview.js";
|
|
9
12
|
import { buildSixelRenderLines, isInlineImageProtocolLine } from "./sixel-protocol.js";
|
|
10
13
|
import { setActiveTerminalImageSettingsCwd } from "./terminal-image-width.js";
|
|
@@ -108,19 +111,15 @@ function renderPreviewLines(items: readonly ImagePreviewItem[], width: number):
|
|
|
108
111
|
}
|
|
109
112
|
|
|
110
113
|
function toUserMessage(value: unknown): UserMessageLike {
|
|
111
|
-
|
|
112
|
-
return {};
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
return value as UserMessageLike;
|
|
114
|
+
return isRecord(value) ? value : {};
|
|
116
115
|
}
|
|
117
116
|
|
|
118
117
|
function toImageContent(value: unknown): UserImageContent | null {
|
|
119
|
-
if (!
|
|
118
|
+
if (!isRecord(value)) {
|
|
120
119
|
return null;
|
|
121
120
|
}
|
|
122
121
|
|
|
123
|
-
const record = value
|
|
122
|
+
const record = value;
|
|
124
123
|
if (record.type !== "image") {
|
|
125
124
|
return null;
|
|
126
125
|
}
|
|
@@ -237,7 +236,19 @@ function assignPreviewItemsToLatestUserMessage(
|
|
|
237
236
|
}
|
|
238
237
|
}
|
|
239
238
|
|
|
240
|
-
function
|
|
239
|
+
function logInlinePreviewError(
|
|
240
|
+
logger: DebugLogger | undefined,
|
|
241
|
+
event: string,
|
|
242
|
+
error: unknown,
|
|
243
|
+
): void {
|
|
244
|
+
try {
|
|
245
|
+
logger?.log(event, { error: getErrorMessage(error) });
|
|
246
|
+
} catch {
|
|
247
|
+
// Debug logging is best-effort inside Pi event handlers.
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function patchInteractiveMode(logger?: DebugLogger): void {
|
|
241
252
|
const prototype = (InteractiveMode as unknown as { prototype: InteractiveModePrototype }).prototype;
|
|
242
253
|
if (!prototype) {
|
|
243
254
|
return;
|
|
@@ -281,7 +292,8 @@ function patchInteractiveMode(): void {
|
|
|
281
292
|
if (imagePayloads.length > 0) {
|
|
282
293
|
try {
|
|
283
294
|
previewItems = buildPreviewItems(imagePayloads);
|
|
284
|
-
} catch {
|
|
295
|
+
} catch (error) {
|
|
296
|
+
logInlinePreviewError(logger, "inline-user-preview.build_preview_failed", error);
|
|
285
297
|
previewItems = [];
|
|
286
298
|
}
|
|
287
299
|
}
|
|
@@ -303,31 +315,53 @@ function patchInteractiveMode(): void {
|
|
|
303
315
|
prototype.__piImageToolsPreviewPatched = true;
|
|
304
316
|
}
|
|
305
317
|
|
|
306
|
-
export
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
patchInteractiveMode();
|
|
310
|
-
patchUserMessageRender();
|
|
311
|
-
}, 0);
|
|
318
|
+
export interface RegisterInlineUserImagePreviewOptions {
|
|
319
|
+
logger?: DebugLogger;
|
|
320
|
+
}
|
|
312
321
|
|
|
322
|
+
export function registerInlineUserImagePreview(
|
|
323
|
+
pi: ExtensionAPI,
|
|
324
|
+
options: RegisterInlineUserImagePreviewOptions = {},
|
|
325
|
+
): void {
|
|
326
|
+
const runPatch = (delayMs: number): void => {
|
|
313
327
|
setTimeout(() => {
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
328
|
+
try {
|
|
329
|
+
patchInteractiveMode(options.logger);
|
|
330
|
+
patchUserMessageRender();
|
|
331
|
+
} catch (error) {
|
|
332
|
+
logInlinePreviewError(options.logger, "inline-user-preview.patch_failed", error);
|
|
333
|
+
}
|
|
334
|
+
}, delayMs);
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const schedulePatch = (): void => {
|
|
338
|
+
runPatch(0);
|
|
339
|
+
runPatch(25);
|
|
340
|
+
};
|
|
341
|
+
|
|
342
|
+
const handleSessionEvent = (eventName: string, cwd: string | undefined): void => {
|
|
343
|
+
try {
|
|
344
|
+
setActiveTerminalImageSettingsCwd(cwd);
|
|
345
|
+
schedulePatch();
|
|
346
|
+
} catch (error) {
|
|
347
|
+
logInlinePreviewError(options.logger, `inline-user-preview.${eventName}_failed`, error);
|
|
348
|
+
}
|
|
317
349
|
};
|
|
318
350
|
|
|
319
351
|
pi.on("session_start", async (_event, ctx) => {
|
|
320
|
-
|
|
321
|
-
schedulePatch();
|
|
352
|
+
handleSessionEvent("session_start", ctx.cwd);
|
|
322
353
|
});
|
|
323
354
|
|
|
324
355
|
pi.on("before_agent_start", async (_event, ctx) => {
|
|
325
|
-
|
|
326
|
-
schedulePatch();
|
|
356
|
+
handleSessionEvent("before_agent_start", ctx.cwd);
|
|
327
357
|
});
|
|
328
358
|
|
|
329
|
-
pi.on
|
|
330
|
-
|
|
331
|
-
|
|
359
|
+
const onSessionSwitch = pi.on as unknown as (
|
|
360
|
+
event: "session_switch",
|
|
361
|
+
handler: (_event: unknown, ctx: { cwd?: string }) => Promise<void>,
|
|
362
|
+
) => void;
|
|
363
|
+
|
|
364
|
+
onSessionSwitch("session_switch", async (_event, ctx) => {
|
|
365
|
+
handleSessionEvent("session_switch", ctx.cwd);
|
|
332
366
|
});
|
|
333
367
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
|
|
3
|
+
import { getErrorMessage, isErrnoException } from "./errors.js";
|
|
4
|
+
|
|
5
|
+
export interface PowerShellCommandResult {
|
|
6
|
+
ok: boolean;
|
|
7
|
+
stdout: string;
|
|
8
|
+
stderr: string;
|
|
9
|
+
missingCommand: boolean;
|
|
10
|
+
reason?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface RunPowerShellCommandOptions {
|
|
14
|
+
args?: string[];
|
|
15
|
+
encoded?: boolean;
|
|
16
|
+
maxBuffer: number;
|
|
17
|
+
sta?: boolean;
|
|
18
|
+
timeout: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function encodePowerShell(script: string): string {
|
|
22
|
+
return Buffer.from(script, "utf16le").toString("base64");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function runPowerShellCommand(
|
|
26
|
+
script: string,
|
|
27
|
+
options: RunPowerShellCommandOptions,
|
|
28
|
+
): PowerShellCommandResult {
|
|
29
|
+
if (process.platform !== "win32") {
|
|
30
|
+
return {
|
|
31
|
+
ok: false,
|
|
32
|
+
stdout: "",
|
|
33
|
+
stderr: "",
|
|
34
|
+
missingCommand: false,
|
|
35
|
+
reason: "PowerShell is only available through pi-image-tools on Windows.",
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const commandArgs = [
|
|
40
|
+
"-NoProfile",
|
|
41
|
+
"-NonInteractive",
|
|
42
|
+
...(options.sta ? ["-STA"] : []),
|
|
43
|
+
...(options.encoded ? ["-EncodedCommand", encodePowerShell(script)] : ["-Command", script]),
|
|
44
|
+
...(options.args ?? []),
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
const result = spawnSync("powershell.exe", commandArgs, {
|
|
48
|
+
encoding: "utf8",
|
|
49
|
+
timeout: options.timeout,
|
|
50
|
+
maxBuffer: options.maxBuffer,
|
|
51
|
+
windowsHide: true,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
if (result.error) {
|
|
55
|
+
return {
|
|
56
|
+
ok: false,
|
|
57
|
+
stdout: result.stdout ?? "",
|
|
58
|
+
stderr: result.stderr ?? "",
|
|
59
|
+
missingCommand: isErrnoException(result.error) && result.error.code === "ENOENT",
|
|
60
|
+
reason: getErrorMessage(result.error),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
ok: result.status === 0,
|
|
66
|
+
stdout: result.stdout ?? "",
|
|
67
|
+
stderr: result.stderr ?? "",
|
|
68
|
+
missingCommand: false,
|
|
69
|
+
reason: result.status === 0 ? undefined : `PowerShell exited with code ${result.status}`,
|
|
70
|
+
};
|
|
71
|
+
}
|
package/src/recent-images.ts
CHANGED
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
import { homedir, tmpdir } from "node:os";
|
|
12
12
|
import { basename, extname, join, resolve } from "node:path";
|
|
13
13
|
|
|
14
|
+
import { extensionToMimeType, mimeTypeToExtension } from "./image-mime.js";
|
|
15
|
+
import { assertImageWithinByteLimit, formatByteLimit } from "./image-size.js";
|
|
14
16
|
import type { ClipboardImage } from "./types.js";
|
|
15
17
|
|
|
16
18
|
export const RECENT_IMAGE_ENV_VAR = "PI_IMAGE_TOOLS_RECENT_DIRS";
|
|
@@ -30,15 +32,6 @@ const SCREENSHOT_NAME_PATTERNS: readonly RegExp[] = [
|
|
|
30
32
|
/^スクリーンショット/i,
|
|
31
33
|
];
|
|
32
34
|
|
|
33
|
-
const EXTENSION_TO_MIME = new Map<string, string>([
|
|
34
|
-
[".png", "image/png"],
|
|
35
|
-
[".jpg", "image/jpeg"],
|
|
36
|
-
[".jpeg", "image/jpeg"],
|
|
37
|
-
[".webp", "image/webp"],
|
|
38
|
-
[".gif", "image/gif"],
|
|
39
|
-
[".bmp", "image/bmp"],
|
|
40
|
-
]);
|
|
41
|
-
|
|
42
35
|
interface RecentImageSource {
|
|
43
36
|
path: string;
|
|
44
37
|
filterScreenshotNames: boolean;
|
|
@@ -193,27 +186,11 @@ function isLikelyScreenshotName(name: string): boolean {
|
|
|
193
186
|
}
|
|
194
187
|
|
|
195
188
|
function toMimeType(fileName: string): string | null {
|
|
196
|
-
|
|
197
|
-
return EXTENSION_TO_MIME.get(extension) ?? null;
|
|
189
|
+
return extensionToMimeType(extname(fileName));
|
|
198
190
|
}
|
|
199
191
|
|
|
200
|
-
function
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
switch (normalized) {
|
|
204
|
-
case "image/png":
|
|
205
|
-
return "png";
|
|
206
|
-
case "image/jpeg":
|
|
207
|
-
return "jpg";
|
|
208
|
-
case "image/webp":
|
|
209
|
-
return "webp";
|
|
210
|
-
case "image/gif":
|
|
211
|
-
return "gif";
|
|
212
|
-
case "image/bmp":
|
|
213
|
-
return "bmp";
|
|
214
|
-
default:
|
|
215
|
-
return "png";
|
|
216
|
-
}
|
|
192
|
+
function isExtensionOwnedCacheFileName(name: string): boolean {
|
|
193
|
+
return /^pi-recent-\d+-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i.test(name);
|
|
217
194
|
}
|
|
218
195
|
|
|
219
196
|
function listRecentImagesFromSource(source: RecentImageSource): RecentImageCandidate[] {
|
|
@@ -372,7 +349,7 @@ function pruneCacheDirectory(cacheDirectory: string, maxCacheFiles: number): voi
|
|
|
372
349
|
return null;
|
|
373
350
|
}
|
|
374
351
|
|
|
375
|
-
if (!toMimeType(name)) {
|
|
352
|
+
if (!isExtensionOwnedCacheFileName(name) || !toMimeType(name)) {
|
|
376
353
|
return null;
|
|
377
354
|
}
|
|
378
355
|
|
|
@@ -406,8 +383,9 @@ export function persistImageToRecentCache(
|
|
|
406
383
|
}
|
|
407
384
|
|
|
408
385
|
const environment = options.environment ?? process.env;
|
|
386
|
+
assertImageWithinByteLimit(image.bytes.length, "Cached image", environment);
|
|
409
387
|
const cacheDirectory = getRecentImageCacheDirectory(environment);
|
|
410
|
-
const extension =
|
|
388
|
+
const extension = mimeTypeToExtension(image.mimeType);
|
|
411
389
|
|
|
412
390
|
mkdirSync(cacheDirectory, { recursive: true });
|
|
413
391
|
|
|
@@ -451,23 +429,6 @@ function formatRelativeAge(modifiedAtMs: number, nowMs: number): string {
|
|
|
451
429
|
return `${deltaYears}y ago`;
|
|
452
430
|
}
|
|
453
431
|
|
|
454
|
-
function formatSize(sizeBytes: number): string {
|
|
455
|
-
if (sizeBytes < 1024) {
|
|
456
|
-
return `${sizeBytes} B`;
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
const units = ["KB", "MB", "GB"] as const;
|
|
460
|
-
let value = sizeBytes / 1024;
|
|
461
|
-
let unitIndex = 0;
|
|
462
|
-
|
|
463
|
-
while (value >= 1024 && unitIndex < units.length - 1) {
|
|
464
|
-
value /= 1024;
|
|
465
|
-
unitIndex += 1;
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`;
|
|
469
|
-
}
|
|
470
|
-
|
|
471
432
|
function detectPathSeparator(pathValue: string): string {
|
|
472
433
|
return pathValue.includes("\\") ? "\\" : "/";
|
|
473
434
|
}
|
|
@@ -489,13 +450,17 @@ function abbreviatePath(pathValue: string, maxChars: number): string {
|
|
|
489
450
|
|
|
490
451
|
export function formatRecentImageLabel(candidate: RecentImageCandidate, nowMs = Date.now()): string {
|
|
491
452
|
const age = formatRelativeAge(candidate.modifiedAtMs, nowMs);
|
|
492
|
-
const size =
|
|
453
|
+
const size = formatByteLimit(candidate.sizeBytes);
|
|
493
454
|
const shortPath = abbreviatePath(candidate.path, 64);
|
|
494
455
|
|
|
495
456
|
return `${candidate.name} • ${age} • ${size} • ${shortPath}`;
|
|
496
457
|
}
|
|
497
458
|
|
|
498
|
-
export function loadRecentImage(
|
|
459
|
+
export function loadRecentImage(
|
|
460
|
+
candidate: RecentImageCandidate,
|
|
461
|
+
environment: NodeJS.ProcessEnv = process.env,
|
|
462
|
+
): ClipboardImage {
|
|
463
|
+
assertImageWithinByteLimit(candidate.sizeBytes, `Recent image ${candidate.name}`, environment);
|
|
499
464
|
const raw = readFileSync(candidate.path);
|
|
500
465
|
if (raw.length === 0) {
|
|
501
466
|
throw new Error(`File is empty: ${candidate.path}`);
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { SettingsManager, getAgentDir } from "@mariozechner/pi-coding-agent";
|
|
2
2
|
import { resolve } from "node:path";
|
|
3
3
|
|
|
4
|
+
import { isRecord } from "./config.js";
|
|
5
|
+
|
|
4
6
|
export const DEFAULT_TERMINAL_IMAGE_WIDTH_CELLS = 60;
|
|
5
7
|
|
|
6
8
|
export interface TerminalImageWidthOptions {
|
|
@@ -42,7 +44,7 @@ function normalizeImageWidthCells(value: unknown): number {
|
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
function readRawImageWidthCells(settings: unknown): unknown {
|
|
45
|
-
if (!
|
|
47
|
+
if (!isRecord(settings)) {
|
|
46
48
|
return undefined;
|
|
47
49
|
}
|
|
48
50
|
|
package/src/temp-file.ts
DELETED
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { mkdirSync, readdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { tmpdir } from "node:os";
|
|
4
|
-
import { join } from "node:path";
|
|
5
|
-
|
|
6
|
-
import type { ClipboardImage } from "./types.js";
|
|
7
|
-
|
|
8
|
-
function extensionForImageMimeType(mimeType: string): string {
|
|
9
|
-
const normalized = mimeType.split(";")[0]?.trim().toLowerCase() ?? mimeType.toLowerCase();
|
|
10
|
-
|
|
11
|
-
switch (normalized) {
|
|
12
|
-
case "image/png":
|
|
13
|
-
return "png";
|
|
14
|
-
case "image/jpeg":
|
|
15
|
-
return "jpg";
|
|
16
|
-
case "image/webp":
|
|
17
|
-
return "webp";
|
|
18
|
-
case "image/gif":
|
|
19
|
-
return "gif";
|
|
20
|
-
default:
|
|
21
|
-
return "png";
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export class TempFileManager {
|
|
26
|
-
private readonly baseDir: string;
|
|
27
|
-
private readonly createdFiles = new Set<string>();
|
|
28
|
-
private exitHookRegistered = false;
|
|
29
|
-
|
|
30
|
-
constructor(baseDir: string = join(tmpdir(), "pi-images")) {
|
|
31
|
-
this.baseDir = baseDir;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
registerExitCleanup(): void {
|
|
35
|
-
if (this.exitHookRegistered) {
|
|
36
|
-
return;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
process.once("exit", () => {
|
|
40
|
-
this.cleanupSync();
|
|
41
|
-
});
|
|
42
|
-
|
|
43
|
-
this.exitHookRegistered = true;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
saveImage(image: ClipboardImage): string {
|
|
47
|
-
mkdirSync(this.baseDir, { recursive: true });
|
|
48
|
-
|
|
49
|
-
const ext = extensionForImageMimeType(image.mimeType);
|
|
50
|
-
const filePath = join(this.baseDir, `pi-image-${Date.now()}-${randomUUID()}.${ext}`);
|
|
51
|
-
|
|
52
|
-
writeFileSync(filePath, Buffer.from(image.bytes));
|
|
53
|
-
this.createdFiles.add(filePath);
|
|
54
|
-
|
|
55
|
-
return filePath;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
async cleanup(): Promise<void> {
|
|
59
|
-
this.cleanupSync();
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
cleanupSync(): void {
|
|
63
|
-
for (const filePath of this.createdFiles) {
|
|
64
|
-
try {
|
|
65
|
-
unlinkSync(filePath);
|
|
66
|
-
} catch {
|
|
67
|
-
// Best-effort cleanup only.
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
this.createdFiles.clear();
|
|
72
|
-
|
|
73
|
-
try {
|
|
74
|
-
const entries = readdirSync(this.baseDir);
|
|
75
|
-
if (entries.length === 0) {
|
|
76
|
-
rmSync(this.baseDir, { recursive: true, force: true });
|
|
77
|
-
}
|
|
78
|
-
} catch {
|
|
79
|
-
// Ignore directory cleanup errors.
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
}
|