pi-image-tools 1.3.0 → 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.
- package/CHANGELOG.md +135 -115
- package/README.md +16 -8
- package/package.json +13 -4
- package/src/clipboard.ts +99 -89
- package/src/config.ts +5 -2
- package/src/debug-logger.ts +4 -2
- package/src/image-preview.ts +25 -32
- package/src/image-size.ts +62 -63
- package/src/image-transcode.ts +206 -0
- package/src/index.ts +23 -27
- package/src/inline-user-preview.ts +512 -523
- package/src/keybindings.ts +52 -50
- package/src/powershell.ts +82 -56
- package/src/preview-logging.ts +28 -0
- package/src/providers/command-runner.ts +86 -68
- package/src/providers/mac-osascript-pngf.ts +17 -56
- package/src/providers/mac-osascript-publicpng.ts +16 -55
- package/src/providers/mac-pngpaste.ts +16 -55
- package/src/providers/native-module.ts +84 -84
- package/src/providers/powershell-forms.ts +87 -95
- package/src/providers/provider-helpers.ts +196 -0
- package/src/providers/registry.ts +88 -88
- package/src/providers/types.ts +24 -24
- package/src/providers/wl-paste.ts +21 -56
- package/src/providers/xclip.ts +21 -57
- package/src/recent-images.ts +55 -83
- package/src/shell-environment.ts +75 -75
|
@@ -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 {
|
|
11
|
-
import {
|
|
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
|
|
304
|
-
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
const
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
const
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
const
|
|
394
|
-
if (
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
+
}
|