pi-image-tools 1.0.11 → 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 +28 -0
- package/README.md +17 -4
- package/package.json +11 -8
- package/src/clipboard.ts +13 -62
- package/src/commands.ts +1 -1
- package/src/config.ts +1 -1
- package/src/debug-logger.ts +54 -20
- package/src/errors.ts +11 -0
- package/src/image-mime.ts +60 -0
- package/src/image-preview.ts +260 -160
- package/src/image-size.ts +63 -0
- package/src/index.ts +13 -19
- package/src/inline-user-preview.ts +71 -37
- package/src/keybindings.ts +2 -2
- package/src/powershell.ts +222 -0
- package/src/recent-images.ts +14 -49
- package/src/terminal-image-width.ts +4 -2
- package/src/types.ts +20 -20
- package/src/temp-file.ts +0 -82
package/src/index.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
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";
|
|
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
|
|
|
@@ -113,12 +108,13 @@ function buildRecentImageEmptyStateMessage(searchedDirectories: readonly string[
|
|
|
113
108
|
].join(" ");
|
|
114
109
|
}
|
|
115
110
|
|
|
116
|
-
function showRecentSelectionPreview(
|
|
111
|
+
async function showRecentSelectionPreview(
|
|
117
112
|
pi: ExtensionAPI,
|
|
118
113
|
image: ClipboardImage,
|
|
119
114
|
cwd: string,
|
|
120
|
-
|
|
121
|
-
|
|
115
|
+
logger: DebugLogger,
|
|
116
|
+
): Promise<void> {
|
|
117
|
+
const previewItems = await buildPreviewItems(
|
|
122
118
|
[
|
|
123
119
|
{
|
|
124
120
|
type: "image",
|
|
@@ -126,7 +122,7 @@ function showRecentSelectionPreview(
|
|
|
126
122
|
mimeType: image.mimeType,
|
|
127
123
|
},
|
|
128
124
|
],
|
|
129
|
-
{ cwd },
|
|
125
|
+
{ cwd, logger },
|
|
130
126
|
);
|
|
131
127
|
|
|
132
128
|
if (previewItems.length === 0) {
|
|
@@ -155,8 +151,8 @@ export default function imageToolsExtension(pi: ExtensionAPI): void {
|
|
|
155
151
|
pasteImageShortcutsConfigured: config.shortcuts.pasteImage !== undefined,
|
|
156
152
|
});
|
|
157
153
|
|
|
158
|
-
registerInlineUserImagePreview(pi);
|
|
159
|
-
registerImagePreviewDisplay(pi);
|
|
154
|
+
registerInlineUserImagePreview(pi, { logger });
|
|
155
|
+
registerImagePreviewDisplay(pi, { logger });
|
|
160
156
|
|
|
161
157
|
const pasteImageFromClipboard = async (ctx: PasteContext): Promise<void> => {
|
|
162
158
|
if (!ctx.hasUI) {
|
|
@@ -214,11 +210,9 @@ export default function imageToolsExtension(pi: ExtensionAPI): void {
|
|
|
214
210
|
const selectedCandidate = discovery.candidates[selectedIndex];
|
|
215
211
|
const selectedImage = loadRecentImage(selectedCandidate);
|
|
216
212
|
|
|
217
|
-
|
|
218
|
-
showRecentSelectionPreview(pi, selectedImage, ctx.cwd);
|
|
219
|
-
} catch (error) {
|
|
213
|
+
void showRecentSelectionPreview(pi, selectedImage, ctx.cwd, logger).catch((error: unknown) => {
|
|
220
214
|
ctx.ui.notify(`Could not render recent image preview: ${getErrorMessage(error)}`, "warning");
|
|
221
|
-
}
|
|
215
|
+
});
|
|
222
216
|
|
|
223
217
|
queueImageAttachment(
|
|
224
218
|
ctx,
|
|
@@ -2,9 +2,12 @@ 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
|
+
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;
|
|
@@ -277,15 +288,6 @@ function patchInteractiveMode(): void {
|
|
|
277
288
|
: 0;
|
|
278
289
|
|
|
279
290
|
const imagePayloads = extractImagePayloads(message);
|
|
280
|
-
let previewItems: ImagePreviewItem[] = [];
|
|
281
|
-
if (imagePayloads.length > 0) {
|
|
282
|
-
try {
|
|
283
|
-
previewItems = buildPreviewItems(imagePayloads);
|
|
284
|
-
} catch {
|
|
285
|
-
previewItems = [];
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
|
|
289
291
|
const original = prototype.__piImageToolsOriginalAddMessageToChat;
|
|
290
292
|
if (!original) {
|
|
291
293
|
return;
|
|
@@ -293,41 +295,73 @@ function patchInteractiveMode(): void {
|
|
|
293
295
|
|
|
294
296
|
original.call(this, message, options);
|
|
295
297
|
|
|
296
|
-
if (
|
|
298
|
+
if (imagePayloads.length === 0) {
|
|
297
299
|
return;
|
|
298
300
|
}
|
|
299
301
|
|
|
300
|
-
|
|
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
|
+
});
|
|
301
313
|
};
|
|
302
314
|
|
|
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
|
}
|
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";
|
|
@@ -0,0 +1,222 @@
|
|
|
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/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
|
-
import { SettingsManager, getAgentDir } from "@
|
|
1
|
+
import { SettingsManager, getAgentDir } from "@earendil-works/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/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
|
+
}
|