pi-image-tools 1.3.1 → 1.4.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.
@@ -1,523 +1,512 @@
1
- import {
2
- type ExtensionAPI,
3
- InteractiveMode,
4
- UserMessageComponent,
5
- } from "@earendil-works/pi-coding-agent";
6
- import { Image, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
7
-
8
- import { isRecord } from "./config.js";
9
- import type { DebugLogger } from "./debug-logger.js";
10
- import { getErrorMessage } from "./errors.js";
11
- import { buildPreviewItems, type ImagePayload, type ImagePreviewItem } from "./image-preview.js";
12
- import { buildSixelRenderLines, isInlineImageProtocolLine } from "./sixel-protocol.js";
13
- import { setActiveTerminalImageSettingsCwd } from "./terminal-image-width.js";
14
-
15
- const INLINE_PREVIEW_PATCH_VERSION = "pi-image-tools-inline-preview-chat-component-v4";
16
- const PREPARE_SUBMITTED_PREVIEW_TIMEOUT_MS = 2_500;
17
-
18
- type UserMessageInstance = {
19
- render?: (width: number) => string[];
20
- };
21
-
22
- type InteractiveModePrototype = {
23
- addMessageToChat: (message: unknown, options?: unknown) => void;
24
- getUserMessageText: (message: unknown) => string;
25
- __piImageToolsOriginalAddMessageToChat?: (message: unknown, options?: unknown) => void;
26
- __piImageToolsOriginalGetUserMessageText?: (message: unknown) => string;
27
- __piImageToolsPreviewPatched?: boolean;
28
- __piImageToolsPreviewPatchVersion?: string;
29
- };
30
-
31
- interface UserImageContent {
32
- type: "image";
33
- data: string;
34
- mimeType: string;
35
- }
36
-
37
- interface UserMessageLike {
38
- role?: unknown;
39
- content?: unknown;
40
- }
41
-
42
- interface InteractiveModeLike {
43
- chatContainer?: {
44
- addChild?: (component: unknown) => void;
45
- children?: unknown[];
46
- };
47
- ui?: {
48
- requestRender?: () => void;
49
- };
50
- }
51
-
52
- class ImagePreviewChatComponent {
53
- constructor(private readonly items: readonly ImagePreviewItem[]) {}
54
-
55
- invalidate(): void {}
56
-
57
- render(width: number): string[] {
58
- return renderPreviewLines(this.items, width);
59
- }
60
- }
61
-
62
- function buildNativeLines(item: ImagePreviewItem, width: number): string[] {
63
- if (!item.data) {
64
- return [];
65
- }
66
-
67
- const image = new Image(
68
- item.data,
69
- item.mimeType,
70
- {
71
- fallbackColor: (text: string) => text,
72
- },
73
- {
74
- maxWidthCells: item.maxWidthCells,
75
- },
76
- );
77
-
78
- return image.render(Math.max(8, width));
79
- }
80
-
81
- function fitLineToWidth(line: string, width: number): string {
82
- const safeWidth = Math.max(1, Math.floor(width));
83
- if (isInlineImageProtocolLine(line)) {
84
- return line;
85
- }
86
-
87
- if (visibleWidth(line) <= safeWidth) {
88
- return line;
89
- }
90
-
91
- return truncateToWidth(line, safeWidth, "", true);
92
- }
93
-
94
- function fitLinesToWidth(lines: readonly string[], width: number): string[] {
95
- return lines.map((line) => fitLineToWidth(line, width));
96
- }
97
-
98
- function renderPreviewLines(items: readonly ImagePreviewItem[], width: number): string[] {
99
- if (items.length === 0) {
100
- return [];
101
- }
102
-
103
- const lines: string[] = ["", "↳ pasted image preview"];
104
-
105
- for (const item of items) {
106
- lines.push("");
107
-
108
- if (item.protocol === "sixel" && item.sixelSequence) {
109
- lines.push(...buildSixelRenderLines(item.sixelSequence, item.rows));
110
- } else {
111
- lines.push(...buildNativeLines(item, width));
112
- }
113
-
114
- if (item.warning) {
115
- lines.push(...item.warning.split(/\r?\n/).filter((line) => line.length > 0));
116
- }
117
- }
118
-
119
- return fitLinesToWidth(lines, width);
120
- }
121
-
122
- function toUserMessage(value: unknown): UserMessageLike {
123
- return isRecord(value) ? value : {};
124
- }
125
-
126
- function toImageContent(value: unknown): UserImageContent | null {
127
- if (!isRecord(value)) {
128
- return null;
129
- }
130
-
131
- const record = value;
132
- if (record.type !== "image") {
133
- return null;
134
- }
135
-
136
- if (typeof record.data !== "string" || record.data.length === 0) {
137
- return null;
138
- }
139
-
140
- return {
141
- type: "image",
142
- data: record.data,
143
- mimeType: typeof record.mimeType === "string" && record.mimeType.length > 0
144
- ? record.mimeType
145
- : "image/png",
146
- };
147
- }
148
-
149
- function extractImagePayloadsFromContent(content: unknown): ImagePayload[] {
150
- if (!Array.isArray(content)) {
151
- return [];
152
- }
153
-
154
- const payloads: ImagePayload[] = [];
155
- for (const part of content) {
156
- const image = toImageContent(part);
157
- if (!image) {
158
- continue;
159
- }
160
-
161
- payloads.push({
162
- type: "image",
163
- data: image.data,
164
- mimeType: image.mimeType,
165
- });
166
- }
167
-
168
- return payloads;
169
- }
170
-
171
- function extractImagePayloads(message: unknown): ImagePayload[] {
172
- const userMessage = toUserMessage(message);
173
- if (userMessage.role !== "user") {
174
- return [];
175
- }
176
-
177
- return extractImagePayloadsFromContent(userMessage.content);
178
- }
179
-
180
- function getImagePayloadSignature(images: readonly ImagePayload[]): string {
181
- return images
182
- .map((image) => `${image.mimeType}:${image.data.length}:${image.data.slice(0, 32)}:${image.data.slice(-32)}`)
183
- .join("|");
184
- }
185
-
186
- interface PreparedPreviewItems {
187
- signature: string;
188
- items: ImagePreviewItem[];
189
- }
190
-
191
- const preparedPreviewItemsQueue: PreparedPreviewItems[] = [];
192
- const inFlightPreviewItems = new Map<string, Promise<ImagePreviewItem[]>>();
193
-
194
- function queuePreparedPreviewItems(images: readonly ImagePayload[], items: ImagePreviewItem[]): void {
195
- if (images.length === 0 || items.length === 0) {
196
- return;
197
- }
198
-
199
- preparedPreviewItemsQueue.push({
200
- signature: getImagePayloadSignature(images),
201
- items,
202
- });
203
-
204
- if (preparedPreviewItemsQueue.length > 8) {
205
- preparedPreviewItemsQueue.splice(0, preparedPreviewItemsQueue.length - 8);
206
- }
207
- }
208
-
209
- function consumePreparedPreviewItems(images: readonly ImagePayload[]): ImagePreviewItem[] | undefined {
210
- if (images.length === 0 || preparedPreviewItemsQueue.length === 0) {
211
- return undefined;
212
- }
213
-
214
- const signature = getImagePayloadSignature(images);
215
- const index = preparedPreviewItemsQueue.findIndex((entry) => entry.signature === signature);
216
- if (index === -1) {
217
- return undefined;
218
- }
219
-
220
- const [entry] = preparedPreviewItemsQueue.splice(index, 1);
221
- return entry?.items;
222
- }
223
-
224
- function getInFlightPreviewItems(images: readonly ImagePayload[]): Promise<ImagePreviewItem[]> | undefined {
225
- if (images.length === 0) {
226
- return undefined;
227
- }
228
-
229
- return inFlightPreviewItems.get(getImagePayloadSignature(images));
230
- }
231
-
232
- function buildPreviewItemsOnce(
233
- images: readonly ImagePayload[],
234
- options: { cwd?: string; logger?: DebugLogger } = {},
235
- ): Promise<ImagePreviewItem[]> {
236
- const signature = getImagePayloadSignature(images);
237
- const existing = inFlightPreviewItems.get(signature);
238
- if (existing) {
239
- return existing;
240
- }
241
-
242
- const promise = buildPreviewItems(images, options);
243
- promise.then(
244
- () => inFlightPreviewItems.delete(signature),
245
- () => inFlightPreviewItems.delete(signature),
246
- );
247
- promise.catch(() => {
248
- // Consumers log contextual errors; this prevents unhandled rejections when prebuild times out.
249
- });
250
- inFlightPreviewItems.set(signature, promise);
251
- return promise;
252
- }
253
-
254
- async function waitForPreviewItemsDeadline(
255
- previewItemsPromise: Promise<ImagePreviewItem[]>,
256
- ): Promise<ImagePreviewItem[] | undefined> {
257
- let timeout: ReturnType<typeof setTimeout> | undefined;
258
-
259
- try {
260
- return await Promise.race([
261
- previewItemsPromise,
262
- new Promise<undefined>((resolve) => {
263
- timeout = setTimeout(() => resolve(undefined), PREPARE_SUBMITTED_PREVIEW_TIMEOUT_MS);
264
- timeout.unref?.();
265
- }),
266
- ]);
267
- } finally {
268
- if (timeout) {
269
- clearTimeout(timeout);
270
- }
271
- }
272
- }
273
-
274
- function imagePlaceholderText(count: number): string {
275
- if (count <= 1) {
276
- return "[󰈟 1 image attached]";
277
- }
278
-
279
- return `[󰈟 ${count} images attached]`;
280
- }
281
-
282
- function isUserMessageComponentLike(value: unknown): value is UserMessageInstance {
283
- if (value instanceof UserMessageComponent) {
284
- return true;
285
- }
286
-
287
- if (!isRecord(value) || typeof value.render !== "function") {
288
- return false;
289
- }
290
-
291
- const constructorName =
292
- typeof value.constructor === "function" && typeof value.constructor.name === "string"
293
- ? value.constructor.name
294
- : undefined;
295
-
296
- return constructorName === "UserMessageComponent";
297
- }
298
-
299
- function requestInteractiveModeRender(mode: InteractiveModeLike): void {
300
- try {
301
- mode.ui?.requestRender?.();
302
- } catch {
303
- // Rendering will be retried by the next TUI update.
304
- }
305
- }
306
-
307
- function addPreviewItemsAfterLatestUserMessage(
308
- mode: InteractiveModeLike,
309
- fromChildIndex: number,
310
- previewItems: ImagePreviewItem[],
311
- ): boolean {
312
- const chatContainer = mode.chatContainer;
313
- const children = chatContainer?.children;
314
- if (!Array.isArray(children) || children.length === 0 || previewItems.length === 0) {
315
- return false;
316
- }
317
-
318
- const start = Math.max(0, fromChildIndex);
319
- for (let index = children.length - 1; index >= start; index -= 1) {
320
- const child = children[index];
321
- if (!isUserMessageComponentLike(child)) {
322
- continue;
323
- }
324
-
325
- const previewComponent = new ImagePreviewChatComponent(previewItems);
326
- const insertIndex = index + 1;
327
- if (insertIndex >= children.length && typeof chatContainer?.addChild === "function") {
328
- chatContainer.addChild(previewComponent);
329
- } else {
330
- children.splice(insertIndex, 0, previewComponent);
331
- }
332
- return true;
333
- }
334
-
335
- return false;
336
- }
337
-
338
- function logInlinePreviewError(
339
- logger: DebugLogger | undefined,
340
- event: string,
341
- error: unknown,
342
- ): void {
343
- try {
344
- logger?.log(event, { error: getErrorMessage(error) });
345
- } catch {
346
- // Debug logging is best-effort inside Pi event handlers.
347
- }
348
- }
349
-
350
- function patchInteractiveMode(logger?: DebugLogger): void {
351
- const prototype = (InteractiveMode as unknown as { prototype: InteractiveModePrototype }).prototype;
352
- if (!prototype) {
353
- return;
354
- }
355
-
356
- if (!prototype.__piImageToolsOriginalGetUserMessageText) {
357
- prototype.__piImageToolsOriginalGetUserMessageText = prototype.getUserMessageText;
358
- }
359
-
360
- if (!prototype.__piImageToolsOriginalAddMessageToChat) {
361
- prototype.__piImageToolsOriginalAddMessageToChat = prototype.addMessageToChat;
362
- }
363
-
364
- if (
365
- prototype.__piImageToolsPreviewPatched &&
366
- prototype.__piImageToolsPreviewPatchVersion === INLINE_PREVIEW_PATCH_VERSION
367
- ) {
368
- return;
369
- }
370
-
371
- prototype.getUserMessageText = function getUserMessageTextWithImagePlaceholder(message: unknown): string {
372
- const original = prototype.__piImageToolsOriginalGetUserMessageText;
373
- const text = original ? original.call(this, message) : "";
374
- if (text.trim().length > 0) {
375
- return text;
376
- }
377
-
378
- const images = extractImagePayloads(message);
379
- if (images.length === 0) {
380
- return text;
381
- }
382
-
383
- return imagePlaceholderText(images.length);
384
- };
385
-
386
- prototype.addMessageToChat = function addMessageToChatWithImagePreview(message: unknown, options?: unknown): void {
387
- const mode = this as unknown as InteractiveModeLike;
388
- const beforeCount = Array.isArray(mode.chatContainer?.children)
389
- ? mode.chatContainer?.children.length ?? 0
390
- : 0;
391
-
392
- const imagePayloads = extractImagePayloads(message);
393
- const original = prototype.__piImageToolsOriginalAddMessageToChat;
394
- if (!original) {
395
- return;
396
- }
397
-
398
- original.call(this, message, options);
399
-
400
- if (imagePayloads.length === 0) {
401
- return;
402
- }
403
-
404
- const preparedPreviewItems = consumePreparedPreviewItems(imagePayloads);
405
- if (preparedPreviewItems) {
406
- if (addPreviewItemsAfterLatestUserMessage(mode, beforeCount, preparedPreviewItems)) {
407
- requestInteractiveModeRender(mode);
408
- }
409
- return;
410
- }
411
-
412
- const previewItemsPromise = getInFlightPreviewItems(imagePayloads)
413
- ?? buildPreviewItemsOnce(imagePayloads, { logger });
414
-
415
- void previewItemsPromise
416
- .then((previewItems) => {
417
- if (previewItems.length === 0) {
418
- return;
419
- }
420
-
421
- if (addPreviewItemsAfterLatestUserMessage(mode, beforeCount, previewItems)) {
422
- requestInteractiveModeRender(mode);
423
- }
424
- })
425
- .catch((error: unknown) => {
426
- logInlinePreviewError(logger, "inline-user-preview.build_preview_failed", error);
427
- });
428
- };
429
-
430
- prototype.__piImageToolsPreviewPatched = true;
431
- prototype.__piImageToolsPreviewPatchVersion = INLINE_PREVIEW_PATCH_VERSION;
432
- }
433
-
434
- export interface RegisterInlineUserImagePreviewOptions {
435
- logger?: DebugLogger;
436
- }
437
-
438
- export function registerInlineUserImagePreview(
439
- pi: ExtensionAPI,
440
- options: RegisterInlineUserImagePreviewOptions = {},
441
- ): void {
442
- const applyPatch = (): void => {
443
- try {
444
- patchInteractiveMode(options.logger);
445
- } catch (error) {
446
- logInlinePreviewError(options.logger, "inline-user-preview.patch_failed", error);
447
- }
448
- };
449
-
450
- const runPatch = (delayMs: number): void => {
451
- setTimeout(applyPatch, delayMs);
452
- };
453
-
454
- const schedulePatch = (): void => {
455
- runPatch(0);
456
- runPatch(25);
457
- };
458
-
459
- const handleSessionEvent = (eventName: string, cwd: string | undefined): void => {
460
- try {
461
- setActiveTerminalImageSettingsCwd(cwd);
462
- applyPatch();
463
- schedulePatch();
464
- } catch (error) {
465
- logInlinePreviewError(options.logger, `inline-user-preview.${eventName}_failed`, error);
466
- }
467
- };
468
-
469
- const prepareSubmittedImagePreview = async (
470
- event: { images?: unknown },
471
- cwd: string | undefined,
472
- ): Promise<{ imagePayloads: ImagePayload[]; previewItems: ImagePreviewItem[] } | undefined> => {
473
- try {
474
- setActiveTerminalImageSettingsCwd(cwd);
475
- applyPatch();
476
- const imagePayloads = extractImagePayloadsFromContent(event.images);
477
- if (imagePayloads.length === 0) {
478
- return undefined;
479
- }
480
-
481
- const previewItemsPromise = buildPreviewItemsOnce(imagePayloads, { cwd, logger: options.logger });
482
- const previewItems = await waitForPreviewItemsDeadline(previewItemsPromise);
483
- if (!previewItems || previewItems.length === 0) {
484
- return undefined;
485
- }
486
-
487
- return { imagePayloads, previewItems };
488
- } catch (error) {
489
- logInlinePreviewError(options.logger, "inline-user-preview.prepare_submitted_preview_failed", error);
490
- return undefined;
491
- }
492
- };
493
-
494
- applyPatch();
495
-
496
- pi.on("session_start", async (_event, ctx) => {
497
- handleSessionEvent("session_start", ctx.cwd);
498
- });
499
-
500
- pi.on("before_agent_start", async (event, ctx) => {
501
- handleSessionEvent("before_agent_start", ctx.cwd);
502
- if (!ctx.hasUI) {
503
- return undefined;
504
- }
505
-
506
- const preparedPreview = await prepareSubmittedImagePreview(event, ctx.cwd);
507
- if (!preparedPreview) {
508
- return undefined;
509
- }
510
-
511
- queuePreparedPreviewItems(preparedPreview.imagePayloads, preparedPreview.previewItems);
512
- return undefined;
513
- });
514
-
515
- const onSessionSwitch = pi.on as unknown as (
516
- event: "session_switch",
517
- handler: (_event: unknown, ctx: { cwd?: string }) => Promise<void>,
518
- ) => void;
519
-
520
- onSessionSwitch("session_switch", async (_event, ctx) => {
521
- handleSessionEvent("session_switch", ctx.cwd);
522
- });
523
- }
1
+ import {
2
+ type ExtensionAPI,
3
+ InteractiveMode,
4
+ UserMessageComponent,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import { Image, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
7
+
8
+ import { isRecord } from "./config.js";
9
+ import type { DebugLogger } from "./debug-logger.js";
10
+ import { buildPreviewItems, type ImagePayload, type ImagePreviewItem } from "./image-preview.js";
11
+ import { logPreviewHandlerError } from "./preview-logging.js";
12
+ import { buildSixelRenderLines, isInlineImageProtocolLine } from "./sixel-protocol.js";
13
+ import { setActiveTerminalImageSettingsCwd } from "./terminal-image-width.js";
14
+
15
+ const INLINE_PREVIEW_PATCH_VERSION = "pi-image-tools-inline-preview-chat-component-v4";
16
+ const PREPARE_SUBMITTED_PREVIEW_TIMEOUT_MS = 2_500;
17
+
18
+ type UserMessageInstance = {
19
+ render?: (width: number) => string[];
20
+ };
21
+
22
+ type InteractiveModePrototype = {
23
+ addMessageToChat: (message: unknown, options?: unknown) => void;
24
+ getUserMessageText: (message: unknown) => string;
25
+ __piImageToolsOriginalAddMessageToChat?: (message: unknown, options?: unknown) => void;
26
+ __piImageToolsOriginalGetUserMessageText?: (message: unknown) => string;
27
+ __piImageToolsPreviewPatched?: boolean;
28
+ __piImageToolsPreviewPatchVersion?: string;
29
+ };
30
+
31
+ interface UserImageContent {
32
+ type: "image";
33
+ data: string;
34
+ mimeType: string;
35
+ }
36
+
37
+ interface UserMessageLike {
38
+ role?: unknown;
39
+ content?: unknown;
40
+ }
41
+
42
+ interface InteractiveModeLike {
43
+ chatContainer?: {
44
+ addChild?: (component: unknown) => void;
45
+ children?: unknown[];
46
+ };
47
+ ui?: {
48
+ requestRender?: () => void;
49
+ };
50
+ }
51
+
52
+ class ImagePreviewChatComponent {
53
+ constructor(private readonly items: readonly ImagePreviewItem[]) {}
54
+
55
+ invalidate(): void {}
56
+
57
+ render(width: number): string[] {
58
+ return renderPreviewLines(this.items, width);
59
+ }
60
+ }
61
+
62
+ function buildNativeLines(item: ImagePreviewItem, width: number): string[] {
63
+ if (!item.data) {
64
+ return [];
65
+ }
66
+
67
+ const image = new Image(
68
+ item.data,
69
+ item.mimeType,
70
+ {
71
+ fallbackColor: (text: string) => text,
72
+ },
73
+ {
74
+ maxWidthCells: item.maxWidthCells,
75
+ },
76
+ );
77
+
78
+ return image.render(Math.max(8, width));
79
+ }
80
+
81
+ function fitLineToWidth(line: string, width: number): string {
82
+ const safeWidth = Math.max(1, Math.floor(width));
83
+ if (isInlineImageProtocolLine(line)) {
84
+ return line;
85
+ }
86
+
87
+ if (visibleWidth(line) <= safeWidth) {
88
+ return line;
89
+ }
90
+
91
+ return truncateToWidth(line, safeWidth, "", true);
92
+ }
93
+
94
+ function fitLinesToWidth(lines: readonly string[], width: number): string[] {
95
+ return lines.map((line) => fitLineToWidth(line, width));
96
+ }
97
+
98
+ function renderPreviewLines(items: readonly ImagePreviewItem[], width: number): string[] {
99
+ if (items.length === 0) {
100
+ return [];
101
+ }
102
+
103
+ const lines: string[] = ["", "↳ pasted image preview"];
104
+
105
+ for (const item of items) {
106
+ lines.push("");
107
+
108
+ if (item.protocol === "sixel" && item.sixelSequence) {
109
+ lines.push(...buildSixelRenderLines(item.sixelSequence, item.rows));
110
+ } else {
111
+ lines.push(...buildNativeLines(item, width));
112
+ }
113
+
114
+ if (item.warning) {
115
+ lines.push(...item.warning.split(/\r?\n/).filter((line) => line.length > 0));
116
+ }
117
+ }
118
+
119
+ return fitLinesToWidth(lines, width);
120
+ }
121
+
122
+ function toUserMessage(value: unknown): UserMessageLike {
123
+ return isRecord(value) ? value : {};
124
+ }
125
+
126
+ function toImageContent(value: unknown): UserImageContent | null {
127
+ if (!isRecord(value)) {
128
+ return null;
129
+ }
130
+
131
+ const record = value;
132
+ if (record.type !== "image") {
133
+ return null;
134
+ }
135
+
136
+ if (typeof record.data !== "string" || record.data.length === 0) {
137
+ return null;
138
+ }
139
+
140
+ return {
141
+ type: "image",
142
+ data: record.data,
143
+ mimeType: typeof record.mimeType === "string" && record.mimeType.length > 0
144
+ ? record.mimeType
145
+ : "image/png",
146
+ };
147
+ }
148
+
149
+ function extractImagePayloadsFromContent(content: unknown): ImagePayload[] {
150
+ if (!Array.isArray(content)) {
151
+ return [];
152
+ }
153
+
154
+ const payloads: ImagePayload[] = [];
155
+ for (const part of content) {
156
+ const image = toImageContent(part);
157
+ if (!image) {
158
+ continue;
159
+ }
160
+
161
+ payloads.push({
162
+ type: "image",
163
+ data: image.data,
164
+ mimeType: image.mimeType,
165
+ });
166
+ }
167
+
168
+ return payloads;
169
+ }
170
+
171
+ function extractImagePayloads(message: unknown): ImagePayload[] {
172
+ const userMessage = toUserMessage(message);
173
+ if (userMessage.role !== "user") {
174
+ return [];
175
+ }
176
+
177
+ return extractImagePayloadsFromContent(userMessage.content);
178
+ }
179
+
180
+ function getImagePayloadSignature(images: readonly ImagePayload[]): string {
181
+ return images
182
+ .map((image) => `${image.mimeType}:${image.data.length}:${image.data.slice(0, 32)}:${image.data.slice(-32)}`)
183
+ .join("|");
184
+ }
185
+
186
+ interface PreparedPreviewItems {
187
+ signature: string;
188
+ items: ImagePreviewItem[];
189
+ }
190
+
191
+ const preparedPreviewItemsQueue: PreparedPreviewItems[] = [];
192
+ const inFlightPreviewItems = new Map<string, Promise<ImagePreviewItem[]>>();
193
+
194
+ function queuePreparedPreviewItems(images: readonly ImagePayload[], items: ImagePreviewItem[]): void {
195
+ if (images.length === 0 || items.length === 0) {
196
+ return;
197
+ }
198
+
199
+ preparedPreviewItemsQueue.push({
200
+ signature: getImagePayloadSignature(images),
201
+ items,
202
+ });
203
+
204
+ if (preparedPreviewItemsQueue.length > 8) {
205
+ preparedPreviewItemsQueue.splice(0, preparedPreviewItemsQueue.length - 8);
206
+ }
207
+ }
208
+
209
+ function consumePreparedPreviewItems(images: readonly ImagePayload[]): ImagePreviewItem[] | undefined {
210
+ if (images.length === 0 || preparedPreviewItemsQueue.length === 0) {
211
+ return undefined;
212
+ }
213
+
214
+ const signature = getImagePayloadSignature(images);
215
+ const index = preparedPreviewItemsQueue.findIndex((entry) => entry.signature === signature);
216
+ if (index === -1) {
217
+ return undefined;
218
+ }
219
+
220
+ const [entry] = preparedPreviewItemsQueue.splice(index, 1);
221
+ return entry?.items;
222
+ }
223
+
224
+ function getInFlightPreviewItems(images: readonly ImagePayload[]): Promise<ImagePreviewItem[]> | undefined {
225
+ if (images.length === 0) {
226
+ return undefined;
227
+ }
228
+
229
+ return inFlightPreviewItems.get(getImagePayloadSignature(images));
230
+ }
231
+
232
+ function buildPreviewItemsOnce(
233
+ images: readonly ImagePayload[],
234
+ options: { cwd?: string; logger?: DebugLogger } = {},
235
+ ): Promise<ImagePreviewItem[]> {
236
+ const signature = getImagePayloadSignature(images);
237
+ const existing = inFlightPreviewItems.get(signature);
238
+ if (existing !== undefined) {
239
+ return existing;
240
+ }
241
+
242
+ const promise = buildPreviewItems(images, options);
243
+ promise.then(
244
+ () => inFlightPreviewItems.delete(signature),
245
+ () => inFlightPreviewItems.delete(signature),
246
+ );
247
+ promise.catch(() => {
248
+ // Consumers log contextual errors; this prevents unhandled rejections when prebuild times out.
249
+ });
250
+ inFlightPreviewItems.set(signature, promise);
251
+ return promise;
252
+ }
253
+
254
+ async function waitForPreviewItemsDeadline(
255
+ previewItemsPromise: Promise<ImagePreviewItem[]>,
256
+ ): Promise<ImagePreviewItem[] | undefined> {
257
+ let timeout: ReturnType<typeof setTimeout> | undefined;
258
+
259
+ try {
260
+ return await Promise.race([
261
+ previewItemsPromise,
262
+ new Promise<undefined>((resolve) => {
263
+ timeout = setTimeout(() => resolve(undefined), PREPARE_SUBMITTED_PREVIEW_TIMEOUT_MS);
264
+ timeout.unref?.();
265
+ }),
266
+ ]);
267
+ } finally {
268
+ if (timeout) {
269
+ clearTimeout(timeout);
270
+ }
271
+ }
272
+ }
273
+
274
+ function imagePlaceholderText(count: number): string {
275
+ if (count <= 1) {
276
+ return "[󰈟 1 image attached]";
277
+ }
278
+
279
+ return `[󰈟 ${count} images attached]`;
280
+ }
281
+
282
+ function isUserMessageComponentLike(value: unknown): value is UserMessageInstance {
283
+ if (value instanceof UserMessageComponent) {
284
+ return true;
285
+ }
286
+
287
+ if (!isRecord(value) || typeof value.render !== "function") {
288
+ return false;
289
+ }
290
+
291
+ const constructorName =
292
+ typeof value.constructor === "function" && typeof value.constructor.name === "string"
293
+ ? value.constructor.name
294
+ : undefined;
295
+
296
+ return constructorName === "UserMessageComponent";
297
+ }
298
+
299
+ function requestInteractiveModeRender(mode: InteractiveModeLike): void {
300
+ try {
301
+ mode.ui?.requestRender?.();
302
+ } catch (error) {
303
+ // Rendering is retried by the next TUI update; never propagate render failures.
304
+ void error;
305
+ }
306
+ }
307
+
308
+ function addPreviewItemsAfterLatestUserMessage(
309
+ mode: InteractiveModeLike,
310
+ fromChildIndex: number,
311
+ previewItems: ImagePreviewItem[],
312
+ ): boolean {
313
+ const chatContainer = mode.chatContainer;
314
+ const children = chatContainer?.children;
315
+ if (!Array.isArray(children) || children.length === 0 || previewItems.length === 0) {
316
+ return false;
317
+ }
318
+
319
+ const start = Math.max(0, fromChildIndex);
320
+ for (let index = children.length - 1; index >= start; index -= 1) {
321
+ const child = children[index];
322
+ if (!isUserMessageComponentLike(child)) {
323
+ continue;
324
+ }
325
+
326
+ const previewComponent = new ImagePreviewChatComponent(previewItems);
327
+ const insertIndex = index + 1;
328
+ if (insertIndex >= children.length && typeof chatContainer?.addChild === "function") {
329
+ chatContainer.addChild(previewComponent);
330
+ } else {
331
+ children.splice(insertIndex, 0, previewComponent);
332
+ }
333
+ return true;
334
+ }
335
+
336
+ return false;
337
+ }
338
+
339
+ function patchInteractiveMode(logger?: DebugLogger): void {
340
+ const prototype = (InteractiveMode as unknown as { prototype: InteractiveModePrototype }).prototype;
341
+ if (!prototype) {
342
+ return;
343
+ }
344
+
345
+ if (!prototype.__piImageToolsOriginalGetUserMessageText) {
346
+ prototype.__piImageToolsOriginalGetUserMessageText = prototype.getUserMessageText;
347
+ }
348
+
349
+ if (!prototype.__piImageToolsOriginalAddMessageToChat) {
350
+ prototype.__piImageToolsOriginalAddMessageToChat = prototype.addMessageToChat;
351
+ }
352
+
353
+ if (
354
+ prototype.__piImageToolsPreviewPatched &&
355
+ prototype.__piImageToolsPreviewPatchVersion === INLINE_PREVIEW_PATCH_VERSION
356
+ ) {
357
+ return;
358
+ }
359
+
360
+ prototype.getUserMessageText = function getUserMessageTextWithImagePlaceholder(message: unknown): string {
361
+ const original = prototype.__piImageToolsOriginalGetUserMessageText;
362
+ const text = original ? original.call(this, message) as string : "";
363
+ if (text.trim().length > 0) {
364
+ return text;
365
+ }
366
+
367
+ const images = extractImagePayloads(message);
368
+ if (images.length === 0) {
369
+ return text;
370
+ }
371
+
372
+ return imagePlaceholderText(images.length);
373
+ };
374
+
375
+ prototype.addMessageToChat = function addMessageToChatWithImagePreview(message: unknown, options?: unknown): void {
376
+ const mode = this as unknown as InteractiveModeLike;
377
+ const beforeCount = Array.isArray(mode.chatContainer?.children)
378
+ ? mode.chatContainer?.children.length ?? 0
379
+ : 0;
380
+
381
+ const imagePayloads = extractImagePayloads(message);
382
+ const original = prototype.__piImageToolsOriginalAddMessageToChat;
383
+ if (!original) {
384
+ return;
385
+ }
386
+
387
+ original.call(this, message, options);
388
+
389
+ if (imagePayloads.length === 0) {
390
+ return;
391
+ }
392
+
393
+ const preparedPreviewItems = consumePreparedPreviewItems(imagePayloads);
394
+ if (preparedPreviewItems) {
395
+ if (addPreviewItemsAfterLatestUserMessage(mode, beforeCount, preparedPreviewItems)) {
396
+ requestInteractiveModeRender(mode);
397
+ }
398
+ return;
399
+ }
400
+
401
+ const previewItemsPromise = getInFlightPreviewItems(imagePayloads)
402
+ ?? buildPreviewItemsOnce(imagePayloads, { logger });
403
+
404
+ void previewItemsPromise
405
+ .then((previewItems) => {
406
+ if (previewItems.length === 0) {
407
+ return;
408
+ }
409
+
410
+ if (addPreviewItemsAfterLatestUserMessage(mode, beforeCount, previewItems)) {
411
+ requestInteractiveModeRender(mode);
412
+ }
413
+ })
414
+ .catch((error: unknown) => {
415
+ logPreviewHandlerError(logger, "inline-user-preview.build_preview_failed", error);
416
+ });
417
+ };
418
+
419
+ prototype.__piImageToolsPreviewPatched = true;
420
+ prototype.__piImageToolsPreviewPatchVersion = INLINE_PREVIEW_PATCH_VERSION;
421
+ }
422
+
423
+ export interface RegisterInlineUserImagePreviewOptions {
424
+ logger?: DebugLogger;
425
+ }
426
+
427
+ export function registerInlineUserImagePreview(
428
+ pi: ExtensionAPI,
429
+ options: RegisterInlineUserImagePreviewOptions = {},
430
+ ): void {
431
+ const applyPatch = (): void => {
432
+ try {
433
+ patchInteractiveMode(options.logger);
434
+ } catch (error) {
435
+ logPreviewHandlerError(options.logger, "inline-user-preview.patch_failed", error);
436
+ }
437
+ };
438
+
439
+ const runPatch = (delayMs: number): void => {
440
+ setTimeout(applyPatch, delayMs);
441
+ };
442
+
443
+ const schedulePatch = (): void => {
444
+ runPatch(0);
445
+ runPatch(25);
446
+ };
447
+
448
+ const handleSessionEvent = (eventName: string, cwd: string | undefined): void => {
449
+ try {
450
+ setActiveTerminalImageSettingsCwd(cwd);
451
+ applyPatch();
452
+ schedulePatch();
453
+ } catch (error) {
454
+ logPreviewHandlerError(options.logger, `inline-user-preview.${eventName}_failed`, error);
455
+ }
456
+ };
457
+
458
+ const prepareSubmittedImagePreview = async (
459
+ event: { images?: unknown },
460
+ cwd: string | undefined,
461
+ ): Promise<{ imagePayloads: ImagePayload[]; previewItems: ImagePreviewItem[] } | undefined> => {
462
+ try {
463
+ setActiveTerminalImageSettingsCwd(cwd);
464
+ applyPatch();
465
+ const imagePayloads = extractImagePayloadsFromContent(event.images);
466
+ if (imagePayloads.length === 0) {
467
+ return undefined;
468
+ }
469
+
470
+ const previewItemsPromise = buildPreviewItemsOnce(imagePayloads, { cwd, logger: options.logger });
471
+ const previewItems = await waitForPreviewItemsDeadline(previewItemsPromise);
472
+ if (!previewItems || previewItems.length === 0) {
473
+ return undefined;
474
+ }
475
+
476
+ return { imagePayloads, previewItems };
477
+ } catch (error) {
478
+ logPreviewHandlerError(options.logger, "inline-user-preview.prepare_submitted_preview_failed", error);
479
+ return undefined;
480
+ }
481
+ };
482
+
483
+ applyPatch();
484
+
485
+ pi.on("session_start", async (_event, ctx) => {
486
+ handleSessionEvent("session_start", ctx.cwd);
487
+ });
488
+
489
+ pi.on("before_agent_start", async (event, ctx) => {
490
+ handleSessionEvent("before_agent_start", ctx.cwd);
491
+ if (!ctx.hasUI) {
492
+ return undefined;
493
+ }
494
+
495
+ const preparedPreview = await prepareSubmittedImagePreview(event, ctx.cwd);
496
+ if (!preparedPreview) {
497
+ return undefined;
498
+ }
499
+
500
+ queuePreparedPreviewItems(preparedPreview.imagePayloads, preparedPreview.previewItems);
501
+ return undefined;
502
+ });
503
+
504
+ const onSessionSwitch = pi.on as unknown as (
505
+ event: "session_switch",
506
+ handler: (_event: unknown, ctx: { cwd?: string }) => Promise<void>,
507
+ ) => void;
508
+
509
+ onSessionSwitch("session_switch", async (_event, ctx) => {
510
+ handleSessionEvent("session_switch", ctx.cwd);
511
+ });
512
+ }