pi-image-tools 1.2.1 → 1.3.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/src/config.ts CHANGED
@@ -18,6 +18,10 @@ export interface ImageToolsConfig {
18
18
  shortcuts: ImageToolsShortcutConfig;
19
19
  }
20
20
 
21
+ export interface LoadImageToolsConfigOptions {
22
+ platform?: NodeJS.Platform;
23
+ }
24
+
21
25
  export function isRecord(value: unknown): value is Record<string, unknown> {
22
26
  return typeof value === "object" && value !== null && !Array.isArray(value);
23
27
  }
@@ -34,9 +38,9 @@ function formatConfigPath(path: string, property: string): string {
34
38
  return `${path}${property.length > 0 ? ` property \"${property}\"` : ""}`;
35
39
  }
36
40
 
37
- function parseBoolean(value: unknown, property: string, path: string): boolean {
41
+ function parseBoolean(value: unknown, property: string, path: string, defaultValue = false): boolean {
38
42
  if (value === undefined) {
39
- return false;
43
+ return defaultValue;
40
44
  }
41
45
 
42
46
  if (typeof value !== "boolean") {
@@ -82,12 +86,17 @@ function parseShortcutList(value: unknown, property: string, path: string): KeyI
82
86
  return shortcuts;
83
87
  }
84
88
 
85
- function parseShortcutConfig(value: unknown, path: string): ImageToolsShortcutConfig {
89
+ function getDefaultShortcutConfig(_platform: NodeJS.Platform): ImageToolsShortcutConfig {
90
+ return {
91
+ avoidBuiltinConflicts: true,
92
+ suppressBuiltinConflictWarnings: false,
93
+ };
94
+ }
95
+
96
+ function parseShortcutConfig(value: unknown, path: string, platform: NodeJS.Platform): ImageToolsShortcutConfig {
97
+ const defaults = getDefaultShortcutConfig(platform);
86
98
  if (value === undefined) {
87
- return {
88
- avoidBuiltinConflicts: false,
89
- suppressBuiltinConflictWarnings: false,
90
- };
99
+ return defaults;
91
100
  }
92
101
 
93
102
  if (!isRecord(value)) {
@@ -98,11 +107,13 @@ function parseShortcutConfig(value: unknown, path: string): ImageToolsShortcutCo
98
107
  value.avoidBuiltinConflicts,
99
108
  "shortcuts.avoidBuiltinConflicts",
100
109
  path,
110
+ defaults.avoidBuiltinConflicts,
101
111
  );
102
112
  const suppressBuiltinConflictWarnings = parseBoolean(
103
113
  value.suppressBuiltinConflictWarnings,
104
114
  "shortcuts.suppressBuiltinConflictWarnings",
105
115
  path,
116
+ defaults.suppressBuiltinConflictWarnings,
106
117
  );
107
118
  const pasteImage = parseShortcutList(value.pasteImage, "shortcuts.pasteImage", path);
108
119
 
@@ -111,31 +122,29 @@ function parseShortcutConfig(value: unknown, path: string): ImageToolsShortcutCo
111
122
  : { avoidBuiltinConflicts, suppressBuiltinConflictWarnings, pasteImage };
112
123
  }
113
124
 
114
- function parseConfig(rawConfig: unknown, path: string): ImageToolsConfig {
125
+ function parseConfig(rawConfig: unknown, path: string, platform: NodeJS.Platform): ImageToolsConfig {
115
126
  if (!isRecord(rawConfig)) {
116
127
  throw new Error(`Invalid pi-image-tools config at ${path}: expected a JSON object.`);
117
128
  }
118
129
 
119
130
  return {
120
131
  debug: parseBoolean(rawConfig.debug, "debug", path),
121
- shortcuts: parseShortcutConfig(rawConfig.shortcuts, path),
132
+ shortcuts: parseShortcutConfig(rawConfig.shortcuts, path, platform),
122
133
  };
123
134
  }
124
135
 
125
- export function loadImageToolsConfig(path = getConfigPath()): ImageToolsConfig {
136
+ export function loadImageToolsConfig(path = getConfigPath(), options: LoadImageToolsConfigOptions = {}): ImageToolsConfig {
137
+ const platform = options.platform ?? process.platform;
126
138
  if (!existsSync(path)) {
127
139
  return {
128
140
  debug: false,
129
- shortcuts: {
130
- avoidBuiltinConflicts: false,
131
- suppressBuiltinConflictWarnings: false,
132
- },
141
+ shortcuts: getDefaultShortcutConfig(platform),
133
142
  };
134
143
  }
135
144
 
136
145
  try {
137
146
  const rawConfig = JSON.parse(readFileSync(path, "utf-8")) as unknown;
138
- return parseConfig(rawConfig, path);
147
+ return parseConfig(rawConfig, path, platform);
139
148
  } catch (error) {
140
149
  if (error instanceof SyntaxError) {
141
150
  throw new Error(`Invalid pi-image-tools config at ${path}: ${error.message}`);
package/src/image-size.ts CHANGED
@@ -1,63 +1,62 @@
1
- export const IMAGE_TOOLS_MAX_IMAGE_BYTES_ENV_VAR = "PI_IMAGE_TOOLS_MAX_IMAGE_BYTES";
2
- export const DEFAULT_MAX_IMAGE_BYTES = 20 * 1024 * 1024;
3
-
4
- function parseMaxImageBytes(environment: NodeJS.ProcessEnv): number {
5
- const rawValue = environment[IMAGE_TOOLS_MAX_IMAGE_BYTES_ENV_VAR]?.trim();
6
- if (!rawValue) {
7
- return DEFAULT_MAX_IMAGE_BYTES;
8
- }
9
-
10
- const parsed = Number(rawValue);
11
- if (!Number.isFinite(parsed) || parsed < 1) {
12
- throw new Error(
13
- `${IMAGE_TOOLS_MAX_IMAGE_BYTES_ENV_VAR} must be a positive byte count when set.`,
14
- );
15
- }
16
-
17
- return Math.floor(parsed);
18
- }
19
-
20
- export function getMaxImageBytes(environment: NodeJS.ProcessEnv = process.env): number {
21
- return parseMaxImageBytes(environment);
22
- }
23
-
24
- export function formatByteLimit(bytes: number): string {
25
- if (bytes < 1024) {
26
- return `${bytes} B`;
27
- }
28
-
29
- const units = ["KB", "MB", "GB"] as const;
30
- let value = bytes / 1024;
31
- let unitIndex = 0;
32
-
33
- while (value >= 1024 && unitIndex < units.length - 1) {
34
- value /= 1024;
35
- unitIndex += 1;
36
- }
37
-
38
- return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`;
39
- }
40
-
41
- export function assertImageWithinByteLimit(
42
- sizeBytes: number,
43
- label: string,
44
- environment: NodeJS.ProcessEnv = process.env,
45
- ): void {
46
- const maxImageBytes = getMaxImageBytes(environment);
47
- if (sizeBytes > maxImageBytes) {
48
- throw new Error(
49
- `${label} is too large (${formatByteLimit(sizeBytes)}). The pi-image-tools limit is ${formatByteLimit(maxImageBytes)}. Set ${IMAGE_TOOLS_MAX_IMAGE_BYTES_ENV_VAR} to a larger byte count if needed.`,
50
- );
51
- }
52
- }
53
-
54
- export function getBase64DecodedByteLength(base64Data: string): number {
55
- const normalized = base64Data.trim().replace(/^data:[^,]*,/, "").replace(/\s/g, "");
56
- if (normalized.length === 0) {
57
- return 0;
58
- }
59
-
60
- const padding = normalized.endsWith("==") ? 2 : normalized.endsWith("=") ? 1 : 0;
61
- return Math.max(0, Math.floor((normalized.length * 3) / 4) - padding);
62
- }
63
-
1
+ export const IMAGE_TOOLS_MAX_IMAGE_BYTES_ENV_VAR = "PI_IMAGE_TOOLS_MAX_IMAGE_BYTES";
2
+ export const DEFAULT_MAX_IMAGE_BYTES = 20 * 1024 * 1024;
3
+
4
+ function parseMaxImageBytes(environment: NodeJS.ProcessEnv): number {
5
+ const rawValue = environment[IMAGE_TOOLS_MAX_IMAGE_BYTES_ENV_VAR]?.trim();
6
+ if (!rawValue) {
7
+ return DEFAULT_MAX_IMAGE_BYTES;
8
+ }
9
+
10
+ const parsed = Number(rawValue);
11
+ if (!Number.isFinite(parsed) || parsed < 1) {
12
+ throw new Error(
13
+ `${IMAGE_TOOLS_MAX_IMAGE_BYTES_ENV_VAR} must be a positive byte count when set.`,
14
+ );
15
+ }
16
+
17
+ return Math.floor(parsed);
18
+ }
19
+
20
+ export function getMaxImageBytes(environment: NodeJS.ProcessEnv = process.env): number {
21
+ return parseMaxImageBytes(environment);
22
+ }
23
+
24
+ export function formatByteLimit(bytes: number): string {
25
+ if (bytes < 1024) {
26
+ return `${bytes} B`;
27
+ }
28
+
29
+ const units = ["KB", "MB", "GB"] as const;
30
+ let value = bytes / 1024;
31
+ let unitIndex = 0;
32
+
33
+ while (value >= 1024 && unitIndex < units.length - 1) {
34
+ value /= 1024;
35
+ unitIndex += 1;
36
+ }
37
+
38
+ return `${value.toFixed(value >= 10 ? 0 : 1)} ${units[unitIndex]}`;
39
+ }
40
+
41
+ export function assertImageWithinByteLimit(
42
+ sizeBytes: number,
43
+ label: string,
44
+ environment: NodeJS.ProcessEnv = process.env,
45
+ ): void {
46
+ const maxImageBytes = getMaxImageBytes(environment);
47
+ if (sizeBytes > maxImageBytes) {
48
+ throw new Error(
49
+ `${label} is too large (${formatByteLimit(sizeBytes)}). The pi-image-tools limit is ${formatByteLimit(maxImageBytes)}. Set ${IMAGE_TOOLS_MAX_IMAGE_BYTES_ENV_VAR} to a larger byte count if needed.`,
50
+ );
51
+ }
52
+ }
53
+
54
+ export function getBase64DecodedByteLength(base64Data: string): number {
55
+ const normalized = base64Data.trim().replace(/^data:[^,]*,/, "").replace(/\s/g, "");
56
+ if (normalized.length === 0) {
57
+ return 0;
58
+ }
59
+
60
+ return Buffer.from(normalized, "base64").length;
61
+ }
62
+
@@ -0,0 +1,198 @@
1
+ import { spawnSync, type SpawnSyncReturns } from "node:child_process";
2
+
3
+ import { normalizeMimeType } from "./image-mime.js";
4
+ import type { ClipboardImage } from "./types.js";
5
+
6
+ const DEFAULT_PRIMARY_TRANSCODE_TOOL = "magick";
7
+ const LEGACY_UNIX_TRANSCODE_TOOL = "convert";
8
+
9
+ /**
10
+ * Maximum wall-clock time for a single ImageMagick invocation. Bounded so a
11
+ * malformed input cannot hang the extension indefinitely; well above what a
12
+ * typical clipboard-sized image needs to convert.
13
+ */
14
+ const TRANSCODE_TIMEOUT_MS = 30_000;
15
+
16
+ /**
17
+ * Maximum buffered output from ImageMagick. PNG re-encoding of a typical
18
+ * Windows screenshot is well under 5 MB; 64 MB gives us several orders of
19
+ * magnitude of headroom while still bounding memory.
20
+ */
21
+ const TRANSCODE_MAX_BUFFER_BYTES = 64 * 1024 * 1024;
22
+
23
+ /**
24
+ * MIME types that the major model providers (Anthropic, OpenAI, Bedrock, Gemini)
25
+ * accept as image input. Anything outside this set must be transcoded before
26
+ * being attached to a user message, otherwise providers return errors such as
27
+ * "Unknown image type: image/bmp".
28
+ *
29
+ * Distinct from `SUPPORTED_IMAGE_MIME_TYPES` in `./image-mime.ts`, which
30
+ * describes formats the *clipboard providers* know how to read (including
31
+ * `image/bmp`). The two sets serve opposite ends of the pipeline.
32
+ */
33
+ export const MODEL_PROVIDER_IMAGE_MIME_TYPES: ReadonlySet<string> = new Set([
34
+ "image/png",
35
+ "image/jpeg",
36
+ "image/gif",
37
+ "image/webp",
38
+ ]);
39
+
40
+ /**
41
+ * Aliases and legacy MIME spellings that providers or clipboard sources may
42
+ * emit. Canonicalization keeps supported formats on the fast path and gives
43
+ * ImageMagick stable input format names for formats that need transcoding.
44
+ */
45
+ const MIME_ALIASES: ReadonlyMap<string, string> = new Map([
46
+ ["image/jpg", "image/jpeg"],
47
+ ["image/pjpeg", "image/jpeg"],
48
+ ["image/x-png", "image/png"],
49
+ ["image/x-bmp", "image/bmp"],
50
+ ["image/x-ms-bmp", "image/bmp"],
51
+ ["image/tif", "image/tiff"],
52
+ ]);
53
+
54
+ const IMAGE_MAGICK_INPUT_FORMAT_BY_MIME_TYPE: ReadonlyMap<string, string> = new Map([
55
+ ["image/bmp", "bmp"],
56
+ ["image/tiff", "tiff"],
57
+ ["image/svg+xml", "svg"],
58
+ ["image/heic", "heic"],
59
+ ["image/heif", "heif"],
60
+ ["image/avif", "avif"],
61
+ ]);
62
+
63
+ function getDefaultTranscodeTools(platform?: NodeJS.Platform): readonly string[] {
64
+ if (platform === "win32") {
65
+ // Windows ships C:\Windows\System32\convert.exe, which is unrelated to
66
+ // ImageMagick and can produce confusing failures. Prefer the modern
67
+ // ImageMagick 7 `magick` launcher by default on Windows; callers that know
68
+ // they have an ImageMagick `convert` on PATH can still pass `tools`.
69
+ return [DEFAULT_PRIMARY_TRANSCODE_TOOL];
70
+ }
71
+
72
+ return [DEFAULT_PRIMARY_TRANSCODE_TOOL, LEGACY_UNIX_TRANSCODE_TOOL];
73
+ }
74
+
75
+ function describeTranscodeTools(tools: readonly string[]): string {
76
+ const uniqueTools = [...new Set(tools.length > 0 ? tools : [DEFAULT_PRIMARY_TRANSCODE_TOOL])];
77
+ if (uniqueTools.length === 1) {
78
+ return `\`${uniqueTools[0]}\``;
79
+ }
80
+
81
+ const quotedTools = uniqueTools.map((tool) => `\`${tool}\``);
82
+ return `${quotedTools.slice(0, -1).join(", ")} or ${quotedTools[quotedTools.length - 1]}`;
83
+ }
84
+
85
+ function imageMagickInputFormatForMimeType(canonicalMimeType: string): string {
86
+ const mappedFormat = IMAGE_MAGICK_INPUT_FORMAT_BY_MIME_TYPE.get(canonicalMimeType);
87
+ if (mappedFormat) {
88
+ return mappedFormat;
89
+ }
90
+
91
+ if (!canonicalMimeType.startsWith("image/")) {
92
+ return "";
93
+ }
94
+
95
+ const subtype = canonicalMimeType.slice("image/".length);
96
+ return subtype.split("+")[0] ?? "";
97
+ }
98
+
99
+ function isMissingCommandError(error: Error): boolean {
100
+ return (error as NodeJS.ErrnoException).code === "ENOENT";
101
+ }
102
+
103
+ function canonicalizeMimeType(mimeType: string): string {
104
+ const normalized = normalizeMimeType(mimeType);
105
+ return MIME_ALIASES.get(normalized) ?? normalized;
106
+ }
107
+
108
+ export interface TranscodeRunner {
109
+ (
110
+ command: string,
111
+ args: readonly string[],
112
+ input: Uint8Array,
113
+ ): SpawnSyncReturns<Buffer>;
114
+ }
115
+
116
+ const defaultTranscodeRunner: TranscodeRunner = (command, args, input) =>
117
+ spawnSync(command, args as string[], {
118
+ input: Buffer.from(input),
119
+ maxBuffer: TRANSCODE_MAX_BUFFER_BYTES,
120
+ timeout: TRANSCODE_TIMEOUT_MS,
121
+ windowsHide: true,
122
+ });
123
+
124
+ export interface TranscodeOptions {
125
+ /** Override the spawn implementation. Mainly for tests. */
126
+ runner?: TranscodeRunner;
127
+ /** Override the list of candidate ImageMagick executables. */
128
+ tools?: readonly string[];
129
+ /** Platform used to choose safe default ImageMagick executable fallbacks. */
130
+ platform?: NodeJS.Platform;
131
+ }
132
+
133
+ /**
134
+ * Ensure a clipboard image is in a MIME type accepted by model providers.
135
+ *
136
+ * Images already in a supported format are returned with a canonicalized MIME
137
+ * (lower-cased, parameters stripped, `image/jpg` upgraded to `image/jpeg`) and
138
+ * otherwise unchanged bytes. Other formats (most notably `image/bmp`, which is
139
+ * what WSLg exposes for Windows clipboard images on the Wayland clipboard, and
140
+ * `image/tiff` from some macOS sources) are piped through ImageMagick and
141
+ * re-encoded as PNG.
142
+ *
143
+ * Throws if the source format is unsupported and no ImageMagick binary
144
+ * (`magick` or the legacy `convert`) is available, or if the conversion fails.
145
+ */
146
+ export function transcodeToSupportedFormat(
147
+ image: ClipboardImage,
148
+ options: TranscodeOptions = {},
149
+ ): ClipboardImage {
150
+ const canonicalMimeType = canonicalizeMimeType(image.mimeType);
151
+
152
+ if (MODEL_PROVIDER_IMAGE_MIME_TYPES.has(canonicalMimeType)) {
153
+ // Fast path: bytes are already in a provider-accepted format.
154
+ // Return the canonicalized MIME so downstream consumers don't have to
155
+ // worry about parameters/casing/aliases.
156
+ return canonicalMimeType === image.mimeType
157
+ ? image
158
+ : { bytes: image.bytes, mimeType: canonicalMimeType };
159
+ }
160
+
161
+ const runner = options.runner ?? defaultTranscodeRunner;
162
+ const platform = options.platform ?? process.platform;
163
+ const tools = options.tools ?? getDefaultTranscodeTools(platform);
164
+
165
+ const inputFormat = imageMagickInputFormatForMimeType(canonicalMimeType);
166
+ const inputSpec = inputFormat.length > 0 ? `${inputFormat}:-` : "-";
167
+
168
+ const failures: string[] = [];
169
+ for (const tool of tools) {
170
+ const result = runner(tool, [inputSpec, "png:-"], image.bytes);
171
+ if (result.error) {
172
+ failures.push(`${tool}: ${result.error.message}`);
173
+ if (isMissingCommandError(result.error)) {
174
+ continue;
175
+ }
176
+ break;
177
+ }
178
+ if (result.status !== 0 || !result.stdout || result.stdout.length === 0) {
179
+ const stderr = result.stderr ? result.stderr.toString("utf8").trim() : "";
180
+ failures.push(`${tool} exited with status ${result.status}${stderr ? `: ${stderr}` : ""}`);
181
+ break;
182
+ }
183
+ return {
184
+ bytes: new Uint8Array(result.stdout),
185
+ mimeType: "image/png",
186
+ };
187
+ }
188
+
189
+ const detail = failures.length > 0
190
+ ? ` (${failures.join("; ")})`
191
+ : tools.length === 0
192
+ ? " (no transcode tools configured)"
193
+ : "";
194
+ throw new Error(
195
+ `Clipboard image is in unsupported format "${image.mimeType}" and could not be transcoded to PNG. ` +
196
+ `Install ImageMagick (${describeTranscodeTools(tools)}) so pi-image-tools can convert images that providers don't accept natively.${detail}`,
197
+ );
198
+ }
package/src/index.ts CHANGED
@@ -1,34 +1,89 @@
1
1
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
 
3
- import { readClipboardImage } from "./clipboard.js";
4
3
  import { registerPasteImageCommand } from "./commands.js";
5
4
  import { loadImageToolsConfig } from "./config.js";
6
5
  import { DebugLogger } from "./debug-logger.js";
7
6
  import { getErrorMessage } from "./errors.js";
8
7
  import { assertImageWithinByteLimit } from "./image-size.js";
9
- import {
10
- IMAGE_PREVIEW_CUSTOM_TYPE,
11
- buildPreviewItems,
12
- registerImagePreviewDisplay,
13
- type ImagePayload,
14
- } from "./image-preview.js";
15
- import { registerInlineUserImagePreview } from "./inline-user-preview.js";
16
8
  import { registerImagePasteKeybindings } from "./keybindings.js";
17
- import {
18
- RECENT_IMAGE_CACHE_DIR_ENV_VAR,
19
- RECENT_IMAGE_ENV_VAR,
20
- discoverRecentImages,
21
- formatRecentImageLabel,
22
- getRecentImageCacheDirectory,
23
- loadRecentImage,
24
- persistImageToRecentCache,
25
- } from "./recent-images.js";
26
9
  import type { ClipboardImage, PasteContext } from "./types.js";
27
10
 
28
11
  const IMAGE_ATTACHMENT_INDICATOR = "[󰈟 Image Attached]";
12
+ const IMAGE_PREVIEW_CUSTOM_TYPE = "pi-image-tools-preview";
13
+ const RECENT_IMAGE_ENV_VAR = "PI_IMAGE_TOOLS_RECENT_DIRS";
14
+ const RECENT_IMAGE_CACHE_DIR_ENV_VAR = "PI_IMAGE_TOOLS_RECENT_CACHE_DIR";
15
+
16
+ type ImagePayload = {
17
+ type: "image";
18
+ data: string;
19
+ mimeType: string;
20
+ };
29
21
 
30
22
  interface PendingImage extends ImagePayload {}
31
23
 
24
+ type ClipboardModule = typeof import("./clipboard.js");
25
+ type ImagePreviewModule = typeof import("./image-preview.js");
26
+ type InlineUserPreviewModule = typeof import("./inline-user-preview.js");
27
+ type RecentImagesModule = typeof import("./recent-images.js");
28
+
29
+ let clipboardModulePromise: Promise<ClipboardModule> | undefined;
30
+ let imagePreviewModulePromise: Promise<ImagePreviewModule> | undefined;
31
+ let inlineUserPreviewModulePromise: Promise<InlineUserPreviewModule> | undefined;
32
+ let recentImagesModulePromise: Promise<RecentImagesModule> | undefined;
33
+ const imagePreviewDisplayRegistrations = new WeakSet<ExtensionAPI>();
34
+ const inlineUserPreviewRegistrations = new WeakSet<ExtensionAPI>();
35
+ const previewRegistrationPromises = new WeakMap<ExtensionAPI, Promise<void>>();
36
+
37
+ function loadClipboardModule(): Promise<ClipboardModule> {
38
+ clipboardModulePromise ??= import("./clipboard.js");
39
+ return clipboardModulePromise;
40
+ }
41
+
42
+ function loadImagePreviewModule(): Promise<ImagePreviewModule> {
43
+ imagePreviewModulePromise ??= import("./image-preview.js");
44
+ return imagePreviewModulePromise;
45
+ }
46
+
47
+ function loadInlineUserPreviewModule(): Promise<InlineUserPreviewModule> {
48
+ inlineUserPreviewModulePromise ??= import("./inline-user-preview.js");
49
+ return inlineUserPreviewModulePromise;
50
+ }
51
+
52
+ function loadRecentImagesModule(): Promise<RecentImagesModule> {
53
+ recentImagesModulePromise ??= import("./recent-images.js");
54
+ return recentImagesModulePromise;
55
+ }
56
+
57
+ async function ensureImagePreviewDisplayRegistered(
58
+ pi: ExtensionAPI,
59
+ logger: DebugLogger,
60
+ ): Promise<ImagePreviewModule> {
61
+ const module = await loadImagePreviewModule();
62
+ if (!imagePreviewDisplayRegistrations.has(pi)) {
63
+ imagePreviewDisplayRegistrations.add(pi);
64
+ module.registerImagePreviewDisplay(pi, { logger });
65
+ }
66
+ return module;
67
+ }
68
+
69
+ async function ensurePreviewRegistrations(pi: ExtensionAPI, logger: DebugLogger): Promise<void> {
70
+ let registrationPromise = previewRegistrationPromises.get(pi);
71
+ if (!registrationPromise) {
72
+ registrationPromise = (async () => {
73
+ await ensureImagePreviewDisplayRegistered(pi, logger);
74
+
75
+ if (!inlineUserPreviewRegistrations.has(pi)) {
76
+ const module = await loadInlineUserPreviewModule();
77
+ inlineUserPreviewRegistrations.add(pi);
78
+ module.registerInlineUserImagePreview(pi, { logger });
79
+ }
80
+ })();
81
+ previewRegistrationPromises.set(pi, registrationPromise);
82
+ }
83
+
84
+ await registrationPromise;
85
+ }
86
+
32
87
  function imageToBase64(image: ClipboardImage): string {
33
88
  assertImageWithinByteLimit(image.bytes.length, "Image attachment");
34
89
  return Buffer.from(image.bytes).toString("base64");
@@ -59,6 +114,20 @@ function removeAttachmentIndicators(text: string): string {
59
114
  return withoutMarkers.replace(/\n{3,}/g, "\n\n").trim();
60
115
  }
61
116
 
117
+ async function cacheImageForRecentPicker(ctx: PasteContext, image: ClipboardImage): Promise<void> {
118
+ try {
119
+ const { persistImageToRecentCache } = await loadRecentImagesModule();
120
+ persistImageToRecentCache(image);
121
+ } catch (error) {
122
+ if (ctx.hasUI) {
123
+ ctx.ui.notify(
124
+ `Image attached, but failed to cache for /paste-image recent: ${getErrorMessage(error)}`,
125
+ "warning",
126
+ );
127
+ }
128
+ }
129
+ }
130
+
62
131
  function queueImageAttachment(
63
132
  ctx: PasteContext,
64
133
  pendingImages: PendingImage[],
@@ -73,16 +142,7 @@ function queueImageAttachment(
73
142
  });
74
143
 
75
144
  if (options.cacheForRecentPicker) {
76
- try {
77
- persistImageToRecentCache(image);
78
- } catch (error) {
79
- if (ctx.hasUI) {
80
- ctx.ui.notify(
81
- `Image attached, but failed to cache for /paste-image recent: ${getErrorMessage(error)}`,
82
- "warning",
83
- );
84
- }
85
- }
145
+ void cacheImageForRecentPicker(ctx, image);
86
146
  }
87
147
 
88
148
  if (!ctx.hasUI) {
@@ -93,12 +153,14 @@ function queueImageAttachment(
93
153
  ctx.ui.notify(successMessage, "info");
94
154
  }
95
155
 
96
- function buildRecentImageEmptyStateMessage(searchedDirectories: readonly string[]): string {
156
+ function buildRecentImageEmptyStateMessage(
157
+ searchedDirectories: readonly string[],
158
+ cacheDirectory: string,
159
+ ): string {
97
160
  const searched =
98
161
  searchedDirectories.length > 0
99
162
  ? searchedDirectories.join("; ")
100
163
  : "No directories configured";
101
- const cacheDirectory = getRecentImageCacheDirectory();
102
164
 
103
165
  return [
104
166
  `No recent images found. Searched: ${searched}`,
@@ -114,6 +176,7 @@ async function showRecentSelectionPreview(
114
176
  cwd: string,
115
177
  logger: DebugLogger,
116
178
  ): Promise<void> {
179
+ const { buildPreviewItems } = await ensureImagePreviewDisplayRegistered(pi, logger);
117
180
  const previewItems = await buildPreviewItems(
118
181
  [
119
182
  {
@@ -151,8 +214,15 @@ export default function imageToolsExtension(pi: ExtensionAPI): void {
151
214
  pasteImageShortcutsConfigured: config.shortcuts.pasteImage !== undefined,
152
215
  });
153
216
 
154
- registerInlineUserImagePreview(pi, { logger });
155
- registerImagePreviewDisplay(pi, { logger });
217
+ pi.on("session_start", async (_event, ctx) => {
218
+ try {
219
+ const { setActiveTerminalImageSettingsCwd } = await import("./terminal-image-width.js");
220
+ setActiveTerminalImageSettingsCwd(ctx.cwd);
221
+ await ensurePreviewRegistrations(pi, logger);
222
+ } catch (error) {
223
+ logger.log("preview.lazy_registration_failed", { error: getErrorMessage(error) });
224
+ }
225
+ });
156
226
 
157
227
  const pasteImageFromClipboard = async (ctx: PasteContext): Promise<void> => {
158
228
  if (!ctx.hasUI) {
@@ -160,6 +230,7 @@ export default function imageToolsExtension(pi: ExtensionAPI): void {
160
230
  }
161
231
 
162
232
  try {
233
+ const { readClipboardImage } = await loadClipboardModule();
163
234
  const image = await readClipboardImage();
164
235
  if (!image) {
165
236
  ctx.ui.notify("No image found in clipboard.", "warning");
@@ -185,9 +256,21 @@ export default function imageToolsExtension(pi: ExtensionAPI): void {
185
256
  }
186
257
 
187
258
  try {
259
+ const {
260
+ discoverRecentImages,
261
+ formatRecentImageLabel,
262
+ getRecentImageCacheDirectory,
263
+ loadRecentImage,
264
+ } = await loadRecentImagesModule();
188
265
  const discovery = discoverRecentImages();
189
266
  if (discovery.candidates.length === 0) {
190
- ctx.ui.notify(buildRecentImageEmptyStateMessage(discovery.searchedDirectories), "warning");
267
+ ctx.ui.notify(
268
+ buildRecentImageEmptyStateMessage(
269
+ discovery.searchedDirectories,
270
+ getRecentImageCacheDirectory(),
271
+ ),
272
+ "warning",
273
+ );
191
274
  return;
192
275
  }
193
276