pi-image-tools 1.2.0 → 1.3.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/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: platform !== "darwin",
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/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