pi-image-tools 1.1.0 → 1.2.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 +13 -0
- package/README.md +9 -4
- package/package.json +69 -66
- package/src/clipboard.ts +1 -1
- package/src/commands.ts +1 -1
- package/src/config.ts +1 -1
- package/src/debug-logger.ts +57 -16
- package/src/image-preview.ts +204 -40
- package/src/index.ts +8 -9
- package/src/inline-user-preview.ts +14 -14
- package/src/keybindings.ts +2 -2
- package/src/powershell.ts +222 -71
- package/src/terminal-image-width.ts +1 -1
- package/src/types.ts +20 -20
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.2.0] - 2026-05-22
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- Added Linux Sixel previews through `img2sixel` detection and conversion when the converter is available on `PATH`.
|
|
7
|
+
- Added asynchronous image preview conversion so user-message previews can fall back without blocking the TUI.
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
- Updated Pi peer dependencies and runtime imports to the `@earendil-works` scope.
|
|
11
|
+
- Improved preview setup diagnostics so missing Sixel converters report actionable fallback warnings.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
- Hardened debug logging with redaction and asynchronous file writes while keeping logging disabled by default.
|
|
15
|
+
|
|
3
16
|
## [1.1.0] - 2026-05-03
|
|
4
17
|
|
|
5
18
|
### Added
|
package/README.md
CHANGED
|
@@ -255,8 +255,8 @@ When you queue one or more images, the extension renders an inline preview insid
|
|
|
255
255
|
|
|
256
256
|
Preview behavior:
|
|
257
257
|
- up to **3 images** are previewed per message
|
|
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
|
+
- Sixel rendering is attempted on Windows when the PowerShell `Sixel` module is already installed and on Linux when `img2sixel` is available on `PATH`
|
|
259
|
+
- no PowerShell modules or Linux packages are installed automatically at runtime
|
|
260
260
|
- native TUI image rendering is used as the fallback
|
|
261
261
|
- image payloads over `PI_IMAGE_TOOLS_MAX_IMAGE_BYTES` are rejected before attachment, recent-cache writes, or Sixel conversion
|
|
262
262
|
- inline width fitting now preserves Sixel, Kitty, and iTerm image protocol rows instead of truncating them like plain text
|
|
@@ -311,9 +311,14 @@ pi-image-tools/
|
|
|
311
311
|
| Linux clipboard paste fails | Make sure you are in a graphical session and install `wl-clipboard` or `xclip` |
|
|
312
312
|
| Recent picker is empty | Add directories via `PI_IMAGE_TOOLS_RECENT_DIRS` or paste images from clipboard first so they enter the recent cache |
|
|
313
313
|
| `/paste-image recent` says it requires interactive mode | Run Pi in interactive TUI mode |
|
|
314
|
-
| Sixel preview warning appears | On Windows, install the PowerShell `Sixel` module and restart Pi |
|
|
314
|
+
| Sixel preview warning appears | On Linux, install `img2sixel` (for example from `libsixel-bin`); on Windows, install the PowerShell `Sixel` module and restart Pi |
|
|
315
315
|
|
|
316
|
-
Manual Sixel installation:
|
|
316
|
+
Manual Sixel installation examples:
|
|
317
|
+
|
|
318
|
+
```bash
|
|
319
|
+
# Debian/Ubuntu
|
|
320
|
+
sudo apt install libsixel-bin
|
|
321
|
+
```
|
|
317
322
|
|
|
318
323
|
```powershell
|
|
319
324
|
Install-Module -Name Sixel -Scope CurrentUser -Force -AllowClobber
|
package/package.json
CHANGED
|
@@ -1,66 +1,69 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "pi-image-tools",
|
|
3
|
-
"version": "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
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
"pi",
|
|
27
|
-
"pi
|
|
28
|
-
"pi-
|
|
29
|
-
"pi-
|
|
30
|
-
"
|
|
31
|
-
"
|
|
32
|
-
"image
|
|
33
|
-
"image-
|
|
34
|
-
"
|
|
35
|
-
"
|
|
36
|
-
"
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
"
|
|
49
|
-
"
|
|
50
|
-
},
|
|
51
|
-
"
|
|
52
|
-
"
|
|
53
|
-
},
|
|
54
|
-
"
|
|
55
|
-
"
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
"@
|
|
65
|
-
}
|
|
66
|
-
|
|
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
|
+
}
|
package/src/clipboard.ts
CHANGED
|
@@ -24,7 +24,7 @@ interface ClipboardReadResult {
|
|
|
24
24
|
image: ClipboardImage | null;
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
-
function hasGraphicalSession(platform: NodeJS.Platform, environment: NodeJS.ProcessEnv): boolean {
|
|
27
|
+
export function hasGraphicalSession(platform: NodeJS.Platform, environment: NodeJS.ProcessEnv): boolean {
|
|
28
28
|
return platform !== "linux" || Boolean(environment.DISPLAY || environment.WAYLAND_DISPLAY);
|
|
29
29
|
}
|
|
30
30
|
|
package/src/commands.ts
CHANGED
package/src/config.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
2
2
|
import { dirname, join, resolve } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
4
|
|
|
5
|
-
import type { KeyId } from "@
|
|
5
|
+
import type { KeyId } from "@earendil-works/pi-tui";
|
|
6
6
|
|
|
7
7
|
const CONFIG_FILE_NAME = "config.json";
|
|
8
8
|
const EXTENSION_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
package/src/debug-logger.ts
CHANGED
|
@@ -1,23 +1,43 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { mkdirSync } from "node:fs";
|
|
2
|
+
import { appendFile } from "node:fs/promises";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
|
|
4
5
|
import { getExtensionRoot, type ImageToolsConfig } from "./config.js";
|
|
5
|
-
import { getErrorMessage } from "./errors.js";
|
|
6
|
-
|
|
7
6
|
const DEBUG_DIRECTORY_NAME = "debug";
|
|
8
7
|
const DEBUG_LOG_FILE_NAME = "debug.log";
|
|
8
|
+
const SECRET_KEYS = /api[_-]?key|authorization|token|secret|password/i;
|
|
9
9
|
|
|
10
10
|
type DebugFields = Record<string, unknown>;
|
|
11
11
|
|
|
12
|
+
interface DebugLoggerCreateOptions {
|
|
13
|
+
extensionRoot?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function redactFields(value: unknown): unknown {
|
|
17
|
+
if (Array.isArray(value)) return value.map((entry) => redactFields(entry));
|
|
18
|
+
if (value && typeof value === "object") {
|
|
19
|
+
const output: Record<string, unknown> = {};
|
|
20
|
+
for (const [key, nestedValue] of Object.entries(value)) {
|
|
21
|
+
output[key] = SECRET_KEYS.test(key) ? "[REDACTED]" : redactFields(nestedValue);
|
|
22
|
+
}
|
|
23
|
+
return output;
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
|
|
12
28
|
export class DebugLogger {
|
|
29
|
+
private readonly debugDirectory: string | undefined;
|
|
13
30
|
private readonly logPath: string | undefined;
|
|
31
|
+
private debugDirectoryReady = false;
|
|
32
|
+
private writeQueue: Promise<void> = Promise.resolve();
|
|
14
33
|
|
|
15
|
-
private constructor(private readonly enabled: boolean) {
|
|
16
|
-
this.
|
|
34
|
+
private constructor(private readonly enabled: boolean, extensionRoot = getExtensionRoot()) {
|
|
35
|
+
this.debugDirectory = enabled ? join(extensionRoot, DEBUG_DIRECTORY_NAME) : undefined;
|
|
36
|
+
this.logPath = this.debugDirectory ? join(this.debugDirectory, DEBUG_LOG_FILE_NAME) : undefined;
|
|
17
37
|
}
|
|
18
38
|
|
|
19
|
-
static create(config: ImageToolsConfig): DebugLogger {
|
|
20
|
-
return new DebugLogger(config.debug);
|
|
39
|
+
static create(config: ImageToolsConfig, options: DebugLoggerCreateOptions = {}): DebugLogger {
|
|
40
|
+
return new DebugLogger(config.debug, options.extensionRoot);
|
|
21
41
|
}
|
|
22
42
|
|
|
23
43
|
log(event: string, fields: DebugFields = {}): void {
|
|
@@ -25,17 +45,38 @@ export class DebugLogger {
|
|
|
25
45
|
return;
|
|
26
46
|
}
|
|
27
47
|
|
|
28
|
-
const debugDirectory = join(getExtensionRoot(), DEBUG_DIRECTORY_NAME);
|
|
29
|
-
|
|
30
48
|
try {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
49
|
+
const redactedFields = redactFields(fields) as DebugFields;
|
|
50
|
+
const line = `${JSON.stringify({ timestamp: new Date().toISOString(), event, ...redactedFields })}\n`;
|
|
51
|
+
this.writeQueue = this.writeQueue.then(
|
|
52
|
+
() => this.appendLine(line),
|
|
53
|
+
() => this.appendLine(line),
|
|
36
54
|
);
|
|
37
|
-
|
|
38
|
-
|
|
55
|
+
void this.writeQueue.catch(() => undefined);
|
|
56
|
+
} catch {
|
|
57
|
+
// Debug logging must never affect extension behavior.
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
flush(): Promise<void> {
|
|
62
|
+
return this.writeQueue.catch(() => undefined);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private async appendLine(line: string): Promise<void> {
|
|
66
|
+
if (!this.logPath) {
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
this.ensureDebugDirectory();
|
|
71
|
+
await appendFile(this.logPath, line, "utf-8");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private ensureDebugDirectory(): void {
|
|
75
|
+
if (this.debugDirectoryReady || !this.debugDirectory) {
|
|
76
|
+
return;
|
|
39
77
|
}
|
|
78
|
+
|
|
79
|
+
mkdirSync(this.debugDirectory, { recursive: true });
|
|
80
|
+
this.debugDirectoryReady = true;
|
|
40
81
|
}
|
|
41
82
|
}
|
package/src/image-preview.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { tmpdir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
|
|
5
|
-
import type { ExtensionAPI } from "@
|
|
5
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import {
|
|
7
7
|
calculateImageRows,
|
|
8
8
|
Container,
|
|
@@ -12,14 +12,14 @@ import {
|
|
|
12
12
|
Spacer,
|
|
13
13
|
Text,
|
|
14
14
|
type Component,
|
|
15
|
-
} from "@
|
|
15
|
+
} from "@earendil-works/pi-tui";
|
|
16
16
|
|
|
17
17
|
import { isRecord } from "./config.js";
|
|
18
18
|
import type { DebugLogger } from "./debug-logger.js";
|
|
19
19
|
import { getErrorMessage } from "./errors.js";
|
|
20
20
|
import { getBase64DecodedByteLength, assertImageWithinByteLimit } from "./image-size.js";
|
|
21
21
|
import { mimeTypeToExtension } from "./image-mime.js";
|
|
22
|
-
import {
|
|
22
|
+
import { runBufferedCommand, runPowerShellCommandAsync, type BufferedCommandResult, type PowerShellCommandResult, type RunPowerShellCommandOptions } from "./powershell.js";
|
|
23
23
|
import { buildSixelRenderLines, ensureCompleteSixelSequence } from "./sixel-protocol.js";
|
|
24
24
|
import {
|
|
25
25
|
DEFAULT_TERMINAL_IMAGE_WIDTH_CELLS,
|
|
@@ -31,6 +31,8 @@ export const IMAGE_PREVIEW_CUSTOM_TYPE = "pi-image-tools-preview";
|
|
|
31
31
|
const MAX_IMAGES_PER_MESSAGE = 3;
|
|
32
32
|
const POWER_SHELL_TIMEOUT_MS = 120_000;
|
|
33
33
|
const POWER_SHELL_MAX_BUFFER_BYTES = 128 * 1024 * 1024;
|
|
34
|
+
const LINUX_SIXEL_TIMEOUT_MS = 120_000;
|
|
35
|
+
const LINUX_SIXEL_MAX_BUFFER_BYTES = 128 * 1024 * 1024;
|
|
34
36
|
const FORCE_SIXEL_ENV_VAR = "PI_IMAGE_TOOLS_FORCE_SIXEL";
|
|
35
37
|
const DISABLE_SIXEL_ENV_VAR = "PI_IMAGE_TOOLS_DISABLE_SIXEL";
|
|
36
38
|
|
|
@@ -40,9 +42,22 @@ export type ImagePayload = {
|
|
|
40
42
|
mimeType: string;
|
|
41
43
|
};
|
|
42
44
|
|
|
45
|
+
type SixelConverter = "powershell-sixel" | "img2sixel";
|
|
46
|
+
type SixelProcessRunner = (
|
|
47
|
+
command: string,
|
|
48
|
+
args: readonly string[],
|
|
49
|
+
options: { timeout: number; maxBuffer: number; windowsHide?: boolean },
|
|
50
|
+
) => Promise<BufferedCommandResult>;
|
|
51
|
+
|
|
52
|
+
type SixelPowerShellRunner = (
|
|
53
|
+
script: string,
|
|
54
|
+
options: RunPowerShellCommandOptions,
|
|
55
|
+
) => Promise<PowerShellCommandResult>;
|
|
56
|
+
|
|
43
57
|
type SixelAvailability = {
|
|
44
58
|
checked: boolean;
|
|
45
59
|
available: boolean;
|
|
60
|
+
converter?: SixelConverter;
|
|
46
61
|
version?: string;
|
|
47
62
|
reason?: string;
|
|
48
63
|
};
|
|
@@ -91,16 +106,19 @@ function isTruthyEnvFlag(value: string | undefined): boolean {
|
|
|
91
106
|
return normalized === "1" || normalized === "true" || normalized === "yes" || normalized === "on";
|
|
92
107
|
}
|
|
93
108
|
|
|
94
|
-
function shouldAttemptSixelRendering(
|
|
95
|
-
|
|
109
|
+
function shouldAttemptSixelRendering(
|
|
110
|
+
environment: NodeJS.ProcessEnv = process.env,
|
|
111
|
+
platform: NodeJS.Platform = process.platform,
|
|
112
|
+
): boolean {
|
|
113
|
+
if (isTruthyEnvFlag(environment[DISABLE_SIXEL_ENV_VAR])) {
|
|
96
114
|
return false;
|
|
97
115
|
}
|
|
98
116
|
|
|
99
|
-
if (
|
|
117
|
+
if (platform !== "win32" && platform !== "linux") {
|
|
100
118
|
return false;
|
|
101
119
|
}
|
|
102
120
|
|
|
103
|
-
if (isTruthyEnvFlag(
|
|
121
|
+
if (isTruthyEnvFlag(environment[FORCE_SIXEL_ENV_VAR])) {
|
|
104
122
|
return true;
|
|
105
123
|
}
|
|
106
124
|
|
|
@@ -112,9 +130,15 @@ const sixelAvailabilityState: SixelAvailability = {
|
|
|
112
130
|
available: false,
|
|
113
131
|
};
|
|
114
132
|
|
|
115
|
-
function ensureSixelModuleAvailable(
|
|
116
|
-
|
|
117
|
-
|
|
133
|
+
async function ensureSixelModuleAvailable(
|
|
134
|
+
forceRefresh = false,
|
|
135
|
+
powerShellRunner: SixelPowerShellRunner = runPowerShellCommandAsync,
|
|
136
|
+
): Promise<SixelAvailability> {
|
|
137
|
+
const useCache = powerShellRunner === runPowerShellCommandAsync;
|
|
138
|
+
const state: SixelAvailability = useCache ? sixelAvailabilityState : { checked: false, available: false };
|
|
139
|
+
|
|
140
|
+
if (state.checked && !forceRefresh) {
|
|
141
|
+
return state;
|
|
118
142
|
}
|
|
119
143
|
|
|
120
144
|
const script = `
|
|
@@ -129,38 +153,103 @@ if ($null -eq $module) {
|
|
|
129
153
|
Write-Output ('Sixel/' + $module.Version.ToString())
|
|
130
154
|
`;
|
|
131
155
|
|
|
132
|
-
const result =
|
|
156
|
+
const result = await powerShellRunner(script, {
|
|
133
157
|
timeout: POWER_SHELL_TIMEOUT_MS,
|
|
134
158
|
maxBuffer: POWER_SHELL_MAX_BUFFER_BYTES,
|
|
135
159
|
});
|
|
136
|
-
|
|
160
|
+
state.checked = true;
|
|
137
161
|
|
|
138
162
|
if (!result.ok) {
|
|
139
163
|
const stderr = normalizeText(result.stderr);
|
|
140
164
|
const stdout = normalizeText(result.stdout);
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
165
|
+
state.available = false;
|
|
166
|
+
state.converter = undefined;
|
|
167
|
+
state.version = undefined;
|
|
168
|
+
state.reason =
|
|
144
169
|
stderr || stdout || result.reason || "Failed to detect the Sixel PowerShell module.";
|
|
145
|
-
return
|
|
170
|
+
return state;
|
|
146
171
|
}
|
|
147
172
|
|
|
148
173
|
const marker = normalizeText(result.stdout)
|
|
149
174
|
.split(/\r?\n/)
|
|
150
175
|
.find((line) => line.startsWith("Sixel/"));
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
176
|
+
state.available = true;
|
|
177
|
+
state.converter = "powershell-sixel";
|
|
178
|
+
state.version = marker ? marker.slice("Sixel/".length) : undefined;
|
|
179
|
+
state.reason = undefined;
|
|
180
|
+
return state;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function ensureLinuxSixelConverterAvailable(
|
|
184
|
+
forceRefresh = false,
|
|
185
|
+
processRunner: SixelProcessRunner = runBufferedCommand,
|
|
186
|
+
): Promise<SixelAvailability> {
|
|
187
|
+
const useCache = processRunner === runBufferedCommand;
|
|
188
|
+
const state: SixelAvailability = useCache ? sixelAvailabilityState : { checked: false, available: false };
|
|
189
|
+
|
|
190
|
+
if (state.checked && !forceRefresh) {
|
|
191
|
+
return state;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
state.checked = true;
|
|
195
|
+
|
|
196
|
+
const result = await processRunner("img2sixel", ["--version"], {
|
|
197
|
+
timeout: 5_000,
|
|
198
|
+
maxBuffer: 1024 * 1024,
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
if (result.error) {
|
|
202
|
+
state.available = false;
|
|
203
|
+
state.converter = undefined;
|
|
204
|
+
state.version = undefined;
|
|
205
|
+
state.reason = isErrnoLike(result.error, "ENOENT")
|
|
206
|
+
? "img2sixel is not installed. Install libsixel-bin or an equivalent package to enable Linux Sixel previews."
|
|
207
|
+
: getErrorMessage(result.error);
|
|
208
|
+
return state;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (result.status !== 0) {
|
|
212
|
+
state.available = false;
|
|
213
|
+
state.converter = undefined;
|
|
214
|
+
state.version = undefined;
|
|
215
|
+
state.reason = normalizeText(result.stderr.toString("utf8")) || normalizeText(result.stdout.toString("utf8")) || "img2sixel detection failed.";
|
|
216
|
+
return state;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
state.available = true;
|
|
220
|
+
state.converter = "img2sixel";
|
|
221
|
+
state.version = normalizeText(result.stdout.toString("utf8")).split(/\r?\n/)[0] || undefined;
|
|
222
|
+
state.reason = undefined;
|
|
223
|
+
return state;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function ensureSixelConverterAvailable(
|
|
227
|
+
forceRefresh = false,
|
|
228
|
+
platform: NodeJS.Platform = process.platform,
|
|
229
|
+
processRunner: SixelProcessRunner = runBufferedCommand,
|
|
230
|
+
powerShellRunner: SixelPowerShellRunner = runPowerShellCommandAsync,
|
|
231
|
+
): Promise<SixelAvailability> {
|
|
232
|
+
if (platform === "linux") {
|
|
233
|
+
return ensureLinuxSixelConverterAvailable(forceRefresh, processRunner);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
return ensureSixelModuleAvailable(forceRefresh, powerShellRunner);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function isErrnoLike(error: unknown, code: string): boolean {
|
|
240
|
+
return error instanceof Error && "code" in error && (error as NodeJS.ErrnoException).code === code;
|
|
155
241
|
}
|
|
156
242
|
|
|
157
243
|
function escapePowerShellSingleQuoted(value: string): string {
|
|
158
244
|
return value.replace(/'/g, "''");
|
|
159
245
|
}
|
|
160
246
|
|
|
161
|
-
function convertImageToSixelSequence(
|
|
247
|
+
async function convertImageToSixelSequence(
|
|
162
248
|
image: ImagePayload,
|
|
163
|
-
|
|
249
|
+
converter: SixelConverter,
|
|
250
|
+
processRunner: SixelProcessRunner = runBufferedCommand,
|
|
251
|
+
powerShellRunner: SixelPowerShellRunner = runPowerShellCommandAsync,
|
|
252
|
+
): Promise<{ sequence?: string; error?: string }> {
|
|
164
253
|
const tempBaseDir = mkdtempSync(join(tmpdir(), "pi-image-tools-image-"));
|
|
165
254
|
const imagePath = join(tempBaseDir, `preview.${mimeTypeToExtension(image.mimeType)}`);
|
|
166
255
|
|
|
@@ -173,6 +262,33 @@ function convertImageToSixelSequence(
|
|
|
173
262
|
|
|
174
263
|
writeFileSync(imagePath, bytes);
|
|
175
264
|
|
|
265
|
+
if (converter === "img2sixel") {
|
|
266
|
+
const result = await processRunner("img2sixel", [imagePath], {
|
|
267
|
+
timeout: LINUX_SIXEL_TIMEOUT_MS,
|
|
268
|
+
maxBuffer: LINUX_SIXEL_MAX_BUFFER_BYTES,
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
if (result.error) {
|
|
272
|
+
return { error: `Sixel conversion failed: ${getErrorMessage(result.error)}` };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (result.status !== 0) {
|
|
276
|
+
const detail = normalizeText(result.stderr.toString("utf8")) || normalizeText(result.stdout.toString("utf8"));
|
|
277
|
+
return {
|
|
278
|
+
error: detail
|
|
279
|
+
? `Sixel conversion failed: ${detail}`
|
|
280
|
+
: "Sixel conversion failed for an unknown reason.",
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const normalized = ensureCompleteSixelSequence(result.stdout.toString("utf8"));
|
|
285
|
+
if (!normalized) {
|
|
286
|
+
return { error: "Sixel conversion produced empty output." };
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
return { sequence: normalized };
|
|
290
|
+
}
|
|
291
|
+
|
|
176
292
|
const escapedPath = escapePowerShellSingleQuoted(imagePath);
|
|
177
293
|
|
|
178
294
|
const script = `
|
|
@@ -195,7 +311,7 @@ if ([string]::IsNullOrWhiteSpace($rendered)) {
|
|
|
195
311
|
Write-Output $rendered
|
|
196
312
|
`;
|
|
197
313
|
|
|
198
|
-
const result =
|
|
314
|
+
const result = await powerShellRunner(script, {
|
|
199
315
|
timeout: POWER_SHELL_TIMEOUT_MS,
|
|
200
316
|
maxBuffer: POWER_SHELL_MAX_BUFFER_BYTES,
|
|
201
317
|
});
|
|
@@ -291,47 +407,93 @@ function parseImagePreviewDetails(value: unknown): ImagePreviewDetails | null {
|
|
|
291
407
|
return { items };
|
|
292
408
|
}
|
|
293
409
|
|
|
294
|
-
export type BuildPreviewItemsOptions = TerminalImageWidthOptions
|
|
410
|
+
export type BuildPreviewItemsOptions = TerminalImageWidthOptions & {
|
|
411
|
+
environment?: NodeJS.ProcessEnv;
|
|
412
|
+
logger?: DebugLogger;
|
|
413
|
+
platform?: NodeJS.Platform;
|
|
414
|
+
sixelProcessRunner?: SixelProcessRunner;
|
|
415
|
+
sixelPowerShellRunner?: SixelPowerShellRunner;
|
|
416
|
+
};
|
|
295
417
|
|
|
296
|
-
|
|
418
|
+
function logSixelEvent(
|
|
419
|
+
logger: DebugLogger | undefined,
|
|
420
|
+
event: string,
|
|
421
|
+
fields: Record<string, unknown> = {},
|
|
422
|
+
): void {
|
|
423
|
+
try {
|
|
424
|
+
logger?.log(event, fields);
|
|
425
|
+
} catch {
|
|
426
|
+
// Debug logging is best-effort and must never block image rendering.
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
export async function buildPreviewItems(
|
|
297
431
|
images: readonly ImagePayload[],
|
|
298
432
|
options: BuildPreviewItemsOptions = {},
|
|
299
|
-
): ImagePreviewItem[] {
|
|
433
|
+
): Promise<ImagePreviewItem[]> {
|
|
300
434
|
const selectedImages = images.slice(0, MAX_IMAGES_PER_MESSAGE);
|
|
301
435
|
if (selectedImages.length === 0) {
|
|
302
436
|
return [];
|
|
303
437
|
}
|
|
304
438
|
|
|
305
439
|
const maxWidthCells = resolveTerminalImageWidthCells(options);
|
|
306
|
-
const
|
|
307
|
-
const
|
|
440
|
+
const platform = options.platform ?? process.platform;
|
|
441
|
+
const processRunner = options.sixelProcessRunner ?? runBufferedCommand;
|
|
442
|
+
const powerShellRunner = options.sixelPowerShellRunner ?? runPowerShellCommandAsync;
|
|
443
|
+
const attemptSixel = shouldAttemptSixelRendering(options.environment, platform);
|
|
444
|
+
const sixelState = attemptSixel
|
|
445
|
+
? await ensureSixelConverterAvailable(false, platform, processRunner, powerShellRunner)
|
|
446
|
+
: undefined;
|
|
447
|
+
|
|
448
|
+
logSixelEvent(options.logger, "image-preview.sixel.detected", {
|
|
449
|
+
attemptSixel,
|
|
450
|
+
available: sixelState?.available ?? false,
|
|
451
|
+
converter: sixelState?.converter ?? null,
|
|
452
|
+
reason: sixelState?.reason ?? null,
|
|
453
|
+
platform,
|
|
454
|
+
});
|
|
308
455
|
|
|
309
|
-
|
|
456
|
+
const items: ImagePreviewItem[] = [];
|
|
457
|
+
for (const image of selectedImages) {
|
|
310
458
|
const rows = estimateImageRows(image, maxWidthCells);
|
|
311
459
|
|
|
312
|
-
if (attemptSixel && sixelState?.available) {
|
|
313
|
-
const conversion = convertImageToSixelSequence(image);
|
|
460
|
+
if (attemptSixel && sixelState?.available && sixelState.converter) {
|
|
461
|
+
const conversion = await convertImageToSixelSequence(image, sixelState.converter, processRunner, powerShellRunner);
|
|
314
462
|
if (conversion.sequence) {
|
|
315
|
-
|
|
463
|
+
logSixelEvent(options.logger, "image-preview.sixel.converted", {
|
|
464
|
+
converter: sixelState.converter,
|
|
465
|
+
mimeType: image.mimeType,
|
|
466
|
+
rows,
|
|
467
|
+
maxWidthCells,
|
|
468
|
+
});
|
|
469
|
+
items.push({
|
|
316
470
|
protocol: "sixel",
|
|
317
471
|
mimeType: image.mimeType,
|
|
318
472
|
rows,
|
|
319
473
|
maxWidthCells,
|
|
320
474
|
sixelSequence: conversion.sequence,
|
|
321
|
-
};
|
|
475
|
+
});
|
|
476
|
+
continue;
|
|
322
477
|
}
|
|
323
478
|
|
|
324
|
-
|
|
479
|
+
logSixelEvent(options.logger, "image-preview.sixel.conversion_failed", {
|
|
480
|
+
converter: sixelState.converter,
|
|
481
|
+
mimeType: image.mimeType,
|
|
482
|
+
error: conversion.error ?? "unknown",
|
|
483
|
+
});
|
|
484
|
+
|
|
485
|
+
items.push({
|
|
325
486
|
protocol: "native",
|
|
326
487
|
mimeType: image.mimeType,
|
|
327
488
|
rows,
|
|
328
489
|
maxWidthCells,
|
|
329
490
|
data: image.data,
|
|
330
491
|
warning: conversion.error,
|
|
331
|
-
};
|
|
492
|
+
});
|
|
493
|
+
continue;
|
|
332
494
|
}
|
|
333
495
|
|
|
334
|
-
|
|
496
|
+
items.push({
|
|
335
497
|
protocol: "native",
|
|
336
498
|
mimeType: image.mimeType,
|
|
337
499
|
rows,
|
|
@@ -339,10 +501,12 @@ export function buildPreviewItems(
|
|
|
339
501
|
data: image.data,
|
|
340
502
|
warning:
|
|
341
503
|
attemptSixel && sixelState && !sixelState.available
|
|
342
|
-
? `Sixel preview unavailable: ${sixelState.reason || "missing
|
|
504
|
+
? `Sixel preview unavailable: ${sixelState.reason || "missing Sixel converter."}`
|
|
343
505
|
: undefined,
|
|
344
|
-
};
|
|
345
|
-
}
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
return items;
|
|
346
510
|
}
|
|
347
511
|
|
|
348
512
|
export interface RegisterImagePreviewDisplayOptions {
|
|
@@ -418,7 +582,7 @@ export function registerImagePreviewDisplay(
|
|
|
418
582
|
return;
|
|
419
583
|
}
|
|
420
584
|
|
|
421
|
-
const availability =
|
|
585
|
+
const availability = await ensureSixelConverterAvailable();
|
|
422
586
|
if (!availability.available && !warnedSixelSetup && ctx.hasUI) {
|
|
423
587
|
warnedSixelSetup = true;
|
|
424
588
|
ctx.ui.notify(
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
|
|
3
3
|
import { readClipboardImage } from "./clipboard.js";
|
|
4
4
|
import { registerPasteImageCommand } from "./commands.js";
|
|
@@ -108,12 +108,13 @@ function buildRecentImageEmptyStateMessage(searchedDirectories: readonly string[
|
|
|
108
108
|
].join(" ");
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
function showRecentSelectionPreview(
|
|
111
|
+
async function showRecentSelectionPreview(
|
|
112
112
|
pi: ExtensionAPI,
|
|
113
113
|
image: ClipboardImage,
|
|
114
114
|
cwd: string,
|
|
115
|
-
|
|
116
|
-
|
|
115
|
+
logger: DebugLogger,
|
|
116
|
+
): Promise<void> {
|
|
117
|
+
const previewItems = await buildPreviewItems(
|
|
117
118
|
[
|
|
118
119
|
{
|
|
119
120
|
type: "image",
|
|
@@ -121,7 +122,7 @@ function showRecentSelectionPreview(
|
|
|
121
122
|
mimeType: image.mimeType,
|
|
122
123
|
},
|
|
123
124
|
],
|
|
124
|
-
{ cwd },
|
|
125
|
+
{ cwd, logger },
|
|
125
126
|
);
|
|
126
127
|
|
|
127
128
|
if (previewItems.length === 0) {
|
|
@@ -209,11 +210,9 @@ export default function imageToolsExtension(pi: ExtensionAPI): void {
|
|
|
209
210
|
const selectedCandidate = discovery.candidates[selectedIndex];
|
|
210
211
|
const selectedImage = loadRecentImage(selectedCandidate);
|
|
211
212
|
|
|
212
|
-
|
|
213
|
-
showRecentSelectionPreview(pi, selectedImage, ctx.cwd);
|
|
214
|
-
} catch (error) {
|
|
213
|
+
void showRecentSelectionPreview(pi, selectedImage, ctx.cwd, logger).catch((error: unknown) => {
|
|
215
214
|
ctx.ui.notify(`Could not render recent image preview: ${getErrorMessage(error)}`, "warning");
|
|
216
|
-
}
|
|
215
|
+
});
|
|
217
216
|
|
|
218
217
|
queueImageAttachment(
|
|
219
218
|
ctx,
|
|
@@ -2,8 +2,8 @@ import {
|
|
|
2
2
|
type ExtensionAPI,
|
|
3
3
|
InteractiveMode,
|
|
4
4
|
UserMessageComponent,
|
|
5
|
-
} from "@
|
|
6
|
-
import { Image, truncateToWidth, visibleWidth } from "@
|
|
5
|
+
} from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { Image, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
7
7
|
|
|
8
8
|
import { isRecord } from "./config.js";
|
|
9
9
|
import type { DebugLogger } from "./debug-logger.js";
|
|
@@ -288,16 +288,6 @@ function patchInteractiveMode(logger?: DebugLogger): void {
|
|
|
288
288
|
: 0;
|
|
289
289
|
|
|
290
290
|
const imagePayloads = extractImagePayloads(message);
|
|
291
|
-
let previewItems: ImagePreviewItem[] = [];
|
|
292
|
-
if (imagePayloads.length > 0) {
|
|
293
|
-
try {
|
|
294
|
-
previewItems = buildPreviewItems(imagePayloads);
|
|
295
|
-
} catch (error) {
|
|
296
|
-
logInlinePreviewError(logger, "inline-user-preview.build_preview_failed", error);
|
|
297
|
-
previewItems = [];
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
|
|
301
291
|
const original = prototype.__piImageToolsOriginalAddMessageToChat;
|
|
302
292
|
if (!original) {
|
|
303
293
|
return;
|
|
@@ -305,11 +295,21 @@ function patchInteractiveMode(logger?: DebugLogger): void {
|
|
|
305
295
|
|
|
306
296
|
original.call(this, message, options);
|
|
307
297
|
|
|
308
|
-
if (
|
|
298
|
+
if (imagePayloads.length === 0) {
|
|
309
299
|
return;
|
|
310
300
|
}
|
|
311
301
|
|
|
312
|
-
|
|
302
|
+
void buildPreviewItems(imagePayloads, { logger })
|
|
303
|
+
.then((previewItems) => {
|
|
304
|
+
if (previewItems.length === 0) {
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
assignPreviewItemsToLatestUserMessage(mode, beforeCount, previewItems);
|
|
309
|
+
})
|
|
310
|
+
.catch((error: unknown) => {
|
|
311
|
+
logInlinePreviewError(logger, "inline-user-preview.build_preview_failed", error);
|
|
312
|
+
});
|
|
313
313
|
};
|
|
314
314
|
|
|
315
315
|
prototype.__piImageToolsPreviewPatched = true;
|
package/src/keybindings.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
|
|
4
|
-
import { getAgentDir, type ExtensionAPI } from "@
|
|
5
|
-
import { TUI_KEYBINDINGS, type KeyId, type KeybindingsConfig } from "@
|
|
4
|
+
import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import { TUI_KEYBINDINGS, type KeyId, type KeybindingsConfig } from "@earendil-works/pi-tui";
|
|
6
6
|
|
|
7
7
|
import { isRecord, type ImageToolsConfig } from "./config.js";
|
|
8
8
|
import type { DebugLogger } from "./debug-logger.js";
|
package/src/powershell.ts
CHANGED
|
@@ -1,71 +1,222 @@
|
|
|
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
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
1
|
+
import { spawn, 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 BufferedCommandResult {
|
|
14
|
+
status: number | null;
|
|
15
|
+
stdout: Buffer;
|
|
16
|
+
stderr: Buffer;
|
|
17
|
+
error?: Error;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface RunBufferedCommandOptions {
|
|
21
|
+
maxBuffer: number;
|
|
22
|
+
timeout: number;
|
|
23
|
+
windowsHide?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface RunPowerShellCommandOptions {
|
|
27
|
+
args?: string[];
|
|
28
|
+
encoded?: boolean;
|
|
29
|
+
maxBuffer: number;
|
|
30
|
+
sta?: boolean;
|
|
31
|
+
timeout: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function encodePowerShell(script: string): string {
|
|
35
|
+
return Buffer.from(script, "utf16le").toString("base64");
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function runBufferedCommand(
|
|
39
|
+
command: string,
|
|
40
|
+
args: readonly string[],
|
|
41
|
+
options: RunBufferedCommandOptions,
|
|
42
|
+
): Promise<BufferedCommandResult> {
|
|
43
|
+
return new Promise((resolve) => {
|
|
44
|
+
let child: ReturnType<typeof spawn>;
|
|
45
|
+
try {
|
|
46
|
+
child = spawn(command, [...args], {
|
|
47
|
+
windowsHide: options.windowsHide,
|
|
48
|
+
});
|
|
49
|
+
} catch (error) {
|
|
50
|
+
resolve({
|
|
51
|
+
status: null,
|
|
52
|
+
stdout: Buffer.alloc(0),
|
|
53
|
+
stderr: Buffer.alloc(0),
|
|
54
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
55
|
+
});
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const stdoutChunks: Buffer[] = [];
|
|
60
|
+
const stderrChunks: Buffer[] = [];
|
|
61
|
+
let stdoutBytes = 0;
|
|
62
|
+
let stderrBytes = 0;
|
|
63
|
+
let settled = false;
|
|
64
|
+
let processError: Error | undefined;
|
|
65
|
+
|
|
66
|
+
const finish = (result: BufferedCommandResult): void => {
|
|
67
|
+
if (settled) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
settled = true;
|
|
72
|
+
clearTimeout(timeout);
|
|
73
|
+
resolve(result);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const failAndKill = (error: Error): void => {
|
|
77
|
+
processError = error;
|
|
78
|
+
child.kill();
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const appendChunk = (chunks: Buffer[], chunk: unknown, currentBytes: number): number => {
|
|
82
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
|
83
|
+
const nextBytes = currentBytes + buffer.length;
|
|
84
|
+
if (nextBytes > options.maxBuffer && !processError) {
|
|
85
|
+
failAndKill(new Error(`Command output exceeded maxBuffer (${options.maxBuffer} bytes).`));
|
|
86
|
+
return nextBytes;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
chunks.push(buffer);
|
|
90
|
+
return nextBytes;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const timeout = setTimeout(() => {
|
|
94
|
+
failAndKill(new Error(`Command timed out after ${options.timeout}ms.`));
|
|
95
|
+
}, options.timeout);
|
|
96
|
+
timeout.unref?.();
|
|
97
|
+
|
|
98
|
+
child.stdout?.on("data", (chunk: unknown) => {
|
|
99
|
+
stdoutBytes = appendChunk(stdoutChunks, chunk, stdoutBytes);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
child.stderr?.on("data", (chunk: unknown) => {
|
|
103
|
+
stderrBytes = appendChunk(stderrChunks, chunk, stderrBytes);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
child.on("error", (error: Error) => {
|
|
107
|
+
processError = error;
|
|
108
|
+
finish({
|
|
109
|
+
status: null,
|
|
110
|
+
stdout: Buffer.concat(stdoutChunks),
|
|
111
|
+
stderr: Buffer.concat(stderrChunks),
|
|
112
|
+
error: processError,
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
child.on("close", (status: number | null) => {
|
|
117
|
+
finish({
|
|
118
|
+
status,
|
|
119
|
+
stdout: Buffer.concat(stdoutChunks),
|
|
120
|
+
stderr: Buffer.concat(stderrChunks),
|
|
121
|
+
error: processError,
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function runPowerShellCommand(
|
|
128
|
+
script: string,
|
|
129
|
+
options: RunPowerShellCommandOptions,
|
|
130
|
+
): PowerShellCommandResult {
|
|
131
|
+
if (process.platform !== "win32") {
|
|
132
|
+
return {
|
|
133
|
+
ok: false,
|
|
134
|
+
stdout: "",
|
|
135
|
+
stderr: "",
|
|
136
|
+
missingCommand: false,
|
|
137
|
+
reason: "PowerShell is only available through pi-image-tools on Windows.",
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const commandArgs = [
|
|
142
|
+
"-NoProfile",
|
|
143
|
+
"-NonInteractive",
|
|
144
|
+
...(options.sta ? ["-STA"] : []),
|
|
145
|
+
...(options.encoded ? ["-EncodedCommand", encodePowerShell(script)] : ["-Command", script]),
|
|
146
|
+
...(options.args ?? []),
|
|
147
|
+
];
|
|
148
|
+
|
|
149
|
+
const result = spawnSync("powershell.exe", commandArgs, {
|
|
150
|
+
encoding: "utf8",
|
|
151
|
+
timeout: options.timeout,
|
|
152
|
+
maxBuffer: options.maxBuffer,
|
|
153
|
+
windowsHide: true,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
if (result.error) {
|
|
157
|
+
return {
|
|
158
|
+
ok: false,
|
|
159
|
+
stdout: result.stdout ?? "",
|
|
160
|
+
stderr: result.stderr ?? "",
|
|
161
|
+
missingCommand: isErrnoException(result.error) && result.error.code === "ENOENT",
|
|
162
|
+
reason: getErrorMessage(result.error),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
ok: result.status === 0,
|
|
168
|
+
stdout: result.stdout ?? "",
|
|
169
|
+
stderr: result.stderr ?? "",
|
|
170
|
+
missingCommand: false,
|
|
171
|
+
reason: result.status === 0 ? undefined : `PowerShell exited with code ${result.status}`,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export async function runPowerShellCommandAsync(
|
|
176
|
+
script: string,
|
|
177
|
+
options: RunPowerShellCommandOptions,
|
|
178
|
+
): Promise<PowerShellCommandResult> {
|
|
179
|
+
if (process.platform !== "win32") {
|
|
180
|
+
return {
|
|
181
|
+
ok: false,
|
|
182
|
+
stdout: "",
|
|
183
|
+
stderr: "",
|
|
184
|
+
missingCommand: false,
|
|
185
|
+
reason: "PowerShell is only available through pi-image-tools on Windows.",
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const commandArgs = [
|
|
190
|
+
"-NoProfile",
|
|
191
|
+
"-NonInteractive",
|
|
192
|
+
...(options.sta ? ["-STA"] : []),
|
|
193
|
+
...(options.encoded ? ["-EncodedCommand", encodePowerShell(script)] : ["-Command", script]),
|
|
194
|
+
...(options.args ?? []),
|
|
195
|
+
];
|
|
196
|
+
|
|
197
|
+
const result = await runBufferedCommand("powershell.exe", commandArgs, {
|
|
198
|
+
timeout: options.timeout,
|
|
199
|
+
maxBuffer: options.maxBuffer,
|
|
200
|
+
windowsHide: true,
|
|
201
|
+
});
|
|
202
|
+
const stdout = result.stdout.toString("utf8");
|
|
203
|
+
const stderr = result.stderr.toString("utf8");
|
|
204
|
+
|
|
205
|
+
if (result.error) {
|
|
206
|
+
return {
|
|
207
|
+
ok: false,
|
|
208
|
+
stdout,
|
|
209
|
+
stderr,
|
|
210
|
+
missingCommand: isErrnoException(result.error) && result.error.code === "ENOENT",
|
|
211
|
+
reason: getErrorMessage(result.error),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return {
|
|
216
|
+
ok: result.status === 0,
|
|
217
|
+
stdout,
|
|
218
|
+
stderr,
|
|
219
|
+
missingCommand: false,
|
|
220
|
+
reason: result.status === 0 ? undefined : `PowerShell exited with code ${result.status}`,
|
|
221
|
+
};
|
|
222
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -1,20 +1,20 @@
|
|
|
1
|
-
import type { ExtensionCommandContext, ExtensionContext } from "@
|
|
2
|
-
|
|
3
|
-
export type PasteContext = ExtensionContext | ExtensionCommandContext;
|
|
4
|
-
|
|
5
|
-
export interface ClipboardImage {
|
|
6
|
-
bytes: Uint8Array;
|
|
7
|
-
mimeType: string;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export interface ClipboardModule {
|
|
11
|
-
hasImage: () => boolean;
|
|
12
|
-
getImageBinary: () => Promise<Array<number> | Uint8Array>;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export type PasteImageHandler = (ctx: PasteContext) => Promise<void>;
|
|
16
|
-
|
|
17
|
-
export interface PasteImageCommandHandlers {
|
|
18
|
-
fromClipboard: PasteImageHandler;
|
|
19
|
-
fromRecent: PasteImageHandler;
|
|
20
|
-
}
|
|
1
|
+
import type { ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export type PasteContext = ExtensionContext | ExtensionCommandContext;
|
|
4
|
+
|
|
5
|
+
export interface ClipboardImage {
|
|
6
|
+
bytes: Uint8Array;
|
|
7
|
+
mimeType: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ClipboardModule {
|
|
11
|
+
hasImage: () => boolean;
|
|
12
|
+
getImageBinary: () => Promise<Array<number> | Uint8Array>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type PasteImageHandler = (ctx: PasteContext) => Promise<void>;
|
|
16
|
+
|
|
17
|
+
export interface PasteImageCommandHandlers {
|
|
18
|
+
fromClipboard: PasteImageHandler;
|
|
19
|
+
fromRecent: PasteImageHandler;
|
|
20
|
+
}
|