pi-image-tools 1.1.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.2.1] - 2026-05-26
4
+
5
+ ### Changed
6
+ - Widened `@earendil-works/pi-coding-agent` and `@earendil-works/pi-tui` peer dependency ranges to `^0.74.0 || ^0.75.0`.
7
+
8
+ ## [1.2.0] - 2026-05-22
9
+
10
+ ### Added
11
+ - Added Linux Sixel previews through `img2sixel` detection and conversion when the converter is available on `PATH`.
12
+ - Added asynchronous image preview conversion so user-message previews can fall back without blocking the TUI.
13
+
14
+ ### Changed
15
+ - Updated Pi peer dependencies and runtime imports to the `@earendil-works` scope.
16
+ - Improved preview setup diagnostics so missing Sixel converters report actionable fallback warnings.
17
+
18
+ ### Fixed
19
+ - Hardened debug logging with redaction and asynchronous file writes while keeping logging disabled by default.
20
+
3
21
  ## [1.1.0] - 2026-05-03
4
22
 
5
23
  ### 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.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
- }
1
+ {
2
+ "name": "pi-image-tools",
3
+ "version": "1.2.1",
4
+ "description": "Image attachment and rendering extension for Pi TUI",
5
+ "type": "module",
6
+ "main": "./index.ts",
7
+ "exports": {
8
+ ".": "./index.ts"
9
+ },
10
+ "files": [
11
+ "index.ts",
12
+ "src",
13
+ "config/config.example.json",
14
+ "README.md",
15
+ "CHANGELOG.md",
16
+ "LICENSE"
17
+ ],
18
+ "scripts": {
19
+ "typecheck": "npx --yes -p typescript@5.7.3 tsc -p tsconfig.json --noEmit",
20
+ "build": "npm run typecheck",
21
+ "lint": "npm run typecheck",
22
+ "test": "node --test",
23
+ "check": "npm run build && npm run test"
24
+ },
25
+ "keywords": [
26
+ "pi-package",
27
+ "pi",
28
+ "pi-extension",
29
+ "pi-coding-agent",
30
+ "pi-tui",
31
+ "coding-agent",
32
+ "image",
33
+ "image-attachment",
34
+ "image-preview",
35
+ "clipboard",
36
+ "preview",
37
+ "windows",
38
+ "linux",
39
+ "cross-platform"
40
+ ],
41
+ "author": "MasuRii",
42
+ "license": "MIT",
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "git+https://github.com/MasuRii/pi-image-tools.git"
46
+ },
47
+ "homepage": "https://github.com/MasuRii/pi-image-tools#readme",
48
+ "bugs": {
49
+ "url": "https://github.com/MasuRii/pi-image-tools/issues"
50
+ },
51
+ "engines": {
52
+ "node": ">=20"
53
+ },
54
+ "publishConfig": {
55
+ "access": "public"
56
+ },
57
+ "pi": {
58
+ "extensions": [
59
+ "./index.ts"
60
+ ]
61
+ },
62
+ "peerDependencies": {
63
+ "@earendil-works/pi-coding-agent": "^0.74.0 || ^0.75.0",
64
+ "@earendil-works/pi-tui": "^0.74.0 || ^0.75.0"
65
+ },
66
+ "optionalDependencies": {
67
+ "@mariozechner/clipboard": "^0.3.6"
68
+ }
69
+ }
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
@@ -1,4 +1,4 @@
1
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
 
3
3
  import type { PasteImageCommandHandlers } from "./types.js";
4
4
 
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 "@mariozechner/pi-tui";
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)), "..");
@@ -1,23 +1,43 @@
1
- import { appendFileSync, mkdirSync } from "node:fs";
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.logPath = enabled ? join(getExtensionRoot(), DEBUG_DIRECTORY_NAME, DEBUG_LOG_FILE_NAME) : undefined;
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
- mkdirSync(debugDirectory, { recursive: true });
32
- appendFileSync(
33
- this.logPath,
34
- `${JSON.stringify({ timestamp: new Date().toISOString(), event, ...fields })}\n`,
35
- "utf-8",
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
- } catch (error) {
38
- throw new Error(`pi-image-tools debug logging failed at ${this.logPath}: ${getErrorMessage(error)}`);
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
  }
@@ -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 "@mariozechner/pi-coding-agent";
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 "@mariozechner/pi-tui";
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 { runPowerShellCommand } from "./powershell.js";
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(): boolean {
95
- if (process.platform !== "win32") {
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 (isTruthyEnvFlag(process.env[DISABLE_SIXEL_ENV_VAR])) {
117
+ if (platform !== "win32" && platform !== "linux") {
100
118
  return false;
101
119
  }
102
120
 
103
- if (isTruthyEnvFlag(process.env[FORCE_SIXEL_ENV_VAR])) {
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(forceRefresh = false): SixelAvailability {
116
- if (sixelAvailabilityState.checked && !forceRefresh) {
117
- return sixelAvailabilityState;
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 = runPowerShellCommand(script, {
156
+ const result = await powerShellRunner(script, {
133
157
  timeout: POWER_SHELL_TIMEOUT_MS,
134
158
  maxBuffer: POWER_SHELL_MAX_BUFFER_BYTES,
135
159
  });
136
- sixelAvailabilityState.checked = true;
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
- sixelAvailabilityState.available = false;
142
- sixelAvailabilityState.version = undefined;
143
- sixelAvailabilityState.reason =
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 sixelAvailabilityState;
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
- sixelAvailabilityState.available = true;
152
- sixelAvailabilityState.version = marker ? marker.slice("Sixel/".length) : undefined;
153
- sixelAvailabilityState.reason = undefined;
154
- return sixelAvailabilityState;
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
- ): { sequence?: string; error?: string } {
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 = runPowerShellCommand(script, {
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
- export function buildPreviewItems(
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 attemptSixel = shouldAttemptSixelRendering();
307
- const sixelState = attemptSixel ? ensureSixelModuleAvailable() : undefined;
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
- return selectedImages.map((image) => {
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
- return {
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
- return {
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
- return {
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 PowerShell Sixel module."}`
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 = ensureSixelModuleAvailable();
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 "@mariozechner/pi-coding-agent";
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
- ): void {
116
- const previewItems = buildPreviewItems(
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
- try {
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 "@mariozechner/pi-coding-agent";
6
- import { Image, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
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 (previewItems.length === 0) {
298
+ if (imagePayloads.length === 0) {
309
299
  return;
310
300
  }
311
301
 
312
- assignPreviewItemsToLatestUserMessage(mode, beforeCount, previewItems);
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;
@@ -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 "@mariozechner/pi-coding-agent";
5
- import { TUI_KEYBINDINGS, type KeyId, type KeybindingsConfig } from "@mariozechner/pi-tui";
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 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
- }
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
+ }
@@ -1,4 +1,4 @@
1
- import { SettingsManager, getAgentDir } from "@mariozechner/pi-coding-agent";
1
+ import { SettingsManager, getAgentDir } from "@earendil-works/pi-coding-agent";
2
2
  import { resolve } from "node:path";
3
3
 
4
4
  import { isRecord } from "./config.js";
package/src/types.ts CHANGED
@@ -1,20 +1,20 @@
1
- import type { ExtensionCommandContext, ExtensionContext } from "@mariozechner/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
- }
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
+ }