pi-image-tools 1.0.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 +7 -0
- package/LICENSE +21 -0
- package/README.md +207 -0
- package/config/config.example.json +3 -0
- package/index.ts +3 -0
- package/package.json +52 -0
- package/src/clipboard.ts +117 -0
- package/src/commands.ts +79 -0
- package/src/image-preview.ts +469 -0
- package/src/index.ts +260 -0
- package/src/inline-user-preview.ts +345 -0
- package/src/keybindings.ts +15 -0
- package/src/recent-images.ts +437 -0
- package/src/temp-file.ts +82 -0
- package/src/types.ts +20 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import { readClipboardImage } from "./clipboard.js";
|
|
4
|
+
import { registerPasteImageCommand } from "./commands.js";
|
|
5
|
+
import {
|
|
6
|
+
IMAGE_PREVIEW_CUSTOM_TYPE,
|
|
7
|
+
buildPreviewItems,
|
|
8
|
+
registerImagePreviewDisplay,
|
|
9
|
+
type ImagePayload,
|
|
10
|
+
} from "./image-preview.js";
|
|
11
|
+
import { registerInlineUserImagePreview } from "./inline-user-preview.js";
|
|
12
|
+
import { registerImagePasteKeybindings } from "./keybindings.js";
|
|
13
|
+
import {
|
|
14
|
+
RECENT_IMAGE_CACHE_DIR_ENV_VAR,
|
|
15
|
+
RECENT_IMAGE_ENV_VAR,
|
|
16
|
+
discoverRecentImages,
|
|
17
|
+
formatRecentImageLabel,
|
|
18
|
+
getRecentImageCacheDirectory,
|
|
19
|
+
loadRecentImage,
|
|
20
|
+
persistImageToRecentCache,
|
|
21
|
+
} from "./recent-images.js";
|
|
22
|
+
import type { ClipboardImage, PasteContext } from "./types.js";
|
|
23
|
+
|
|
24
|
+
const IMAGE_ATTACHMENT_INDICATOR = "[ Image Attached]";
|
|
25
|
+
|
|
26
|
+
interface PendingImage extends ImagePayload {}
|
|
27
|
+
|
|
28
|
+
function getErrorMessage(error: unknown): string {
|
|
29
|
+
if (error instanceof Error && error.message.trim().length > 0) {
|
|
30
|
+
return error.message;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return "Unknown error";
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function isWindowsPlatform(platform: NodeJS.Platform = process.platform): boolean {
|
|
37
|
+
return platform === "win32";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function imageToBase64(image: ClipboardImage): string {
|
|
41
|
+
return Buffer.from(image.bytes).toString("base64");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function countOccurrences(text: string, token: string): number {
|
|
45
|
+
if (token.length === 0) {
|
|
46
|
+
return 0;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let count = 0;
|
|
50
|
+
let cursor = 0;
|
|
51
|
+
while (cursor < text.length) {
|
|
52
|
+
const index = text.indexOf(token, cursor);
|
|
53
|
+
if (index === -1) {
|
|
54
|
+
break;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
count += 1;
|
|
58
|
+
cursor = index + token.length;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return count;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function removeAttachmentIndicators(text: string): string {
|
|
65
|
+
const withoutMarkers = text.split(IMAGE_ATTACHMENT_INDICATOR).join("");
|
|
66
|
+
return withoutMarkers.replace(/\n{3,}/g, "\n\n").trim();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function queueImageAttachment(
|
|
70
|
+
ctx: PasteContext,
|
|
71
|
+
pendingImages: PendingImage[],
|
|
72
|
+
image: ClipboardImage,
|
|
73
|
+
successMessage: string,
|
|
74
|
+
options: { cacheForRecentPicker?: boolean } = {},
|
|
75
|
+
): void {
|
|
76
|
+
pendingImages.push({
|
|
77
|
+
type: "image",
|
|
78
|
+
data: imageToBase64(image),
|
|
79
|
+
mimeType: image.mimeType,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
if (options.cacheForRecentPicker) {
|
|
83
|
+
try {
|
|
84
|
+
persistImageToRecentCache(image);
|
|
85
|
+
} catch (error) {
|
|
86
|
+
if (ctx.hasUI) {
|
|
87
|
+
ctx.ui.notify(
|
|
88
|
+
`Image attached, but failed to cache for /paste-image recent: ${getErrorMessage(error)}`,
|
|
89
|
+
"warning",
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!ctx.hasUI) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
ctx.ui.pasteToEditor(`${IMAGE_ATTACHMENT_INDICATOR} `);
|
|
100
|
+
ctx.ui.notify(successMessage, "info");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function buildRecentImageEmptyStateMessage(searchedDirectories: readonly string[]): string {
|
|
104
|
+
const searched =
|
|
105
|
+
searchedDirectories.length > 0
|
|
106
|
+
? searchedDirectories.join("; ")
|
|
107
|
+
: "No directories configured";
|
|
108
|
+
const cacheDirectory = getRecentImageCacheDirectory();
|
|
109
|
+
|
|
110
|
+
return [
|
|
111
|
+
`No recent images found. Searched: ${searched}`,
|
|
112
|
+
`Clipboard-pasted images are cached in: ${cacheDirectory}`,
|
|
113
|
+
`Set ${RECENT_IMAGE_ENV_VAR} to a semicolon-separated list of directories to override defaults.`,
|
|
114
|
+
`Set ${RECENT_IMAGE_CACHE_DIR_ENV_VAR} to customize the cache directory.`,
|
|
115
|
+
].join(" ");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function showRecentSelectionPreview(
|
|
119
|
+
pi: ExtensionAPI,
|
|
120
|
+
image: ClipboardImage,
|
|
121
|
+
): void {
|
|
122
|
+
const previewItems = buildPreviewItems([
|
|
123
|
+
{
|
|
124
|
+
type: "image",
|
|
125
|
+
data: imageToBase64(image),
|
|
126
|
+
mimeType: image.mimeType,
|
|
127
|
+
},
|
|
128
|
+
]);
|
|
129
|
+
|
|
130
|
+
if (previewItems.length === 0) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
pi.sendMessage(
|
|
135
|
+
{
|
|
136
|
+
customType: IMAGE_PREVIEW_CUSTOM_TYPE,
|
|
137
|
+
content: "",
|
|
138
|
+
display: true,
|
|
139
|
+
details: { items: previewItems },
|
|
140
|
+
},
|
|
141
|
+
{ triggerTurn: false },
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export default function imageToolsExtension(pi: ExtensionAPI): void {
|
|
146
|
+
if (!isWindowsPlatform()) {
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const pendingImages: PendingImage[] = [];
|
|
151
|
+
|
|
152
|
+
registerInlineUserImagePreview(pi);
|
|
153
|
+
registerImagePreviewDisplay(pi);
|
|
154
|
+
|
|
155
|
+
const pasteImageFromClipboard = async (ctx: PasteContext): Promise<void> => {
|
|
156
|
+
if (!ctx.hasUI) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
try {
|
|
161
|
+
const image = await readClipboardImage();
|
|
162
|
+
if (!image) {
|
|
163
|
+
ctx.ui.notify("No image found in clipboard.", "warning");
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
queueImageAttachment(
|
|
168
|
+
ctx,
|
|
169
|
+
pendingImages,
|
|
170
|
+
image,
|
|
171
|
+
"Image attached from clipboard. Add your message, then send. A terminal preview will render after submit.",
|
|
172
|
+
{ cacheForRecentPicker: true },
|
|
173
|
+
);
|
|
174
|
+
} catch (error) {
|
|
175
|
+
ctx.ui.notify(`Image paste failed: ${getErrorMessage(error)}`, "warning");
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const pasteImageFromRecent = async (ctx: PasteContext): Promise<void> => {
|
|
180
|
+
if (!ctx.hasUI) {
|
|
181
|
+
ctx.ui.notify("/paste-image recent requires interactive TUI mode.", "warning");
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
try {
|
|
186
|
+
const discovery = discoverRecentImages();
|
|
187
|
+
if (discovery.candidates.length === 0) {
|
|
188
|
+
ctx.ui.notify(buildRecentImageEmptyStateMessage(discovery.searchedDirectories), "warning");
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const options = discovery.candidates.map((candidate, index) => {
|
|
193
|
+
const rank = String(index + 1).padStart(2, "0");
|
|
194
|
+
return `${rank}. ${formatRecentImageLabel(candidate)}`;
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const selectedOption = await ctx.ui.select("Select a recent image", options);
|
|
198
|
+
if (!selectedOption) {
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const selectedIndex = options.indexOf(selectedOption);
|
|
203
|
+
if (selectedIndex === -1) {
|
|
204
|
+
ctx.ui.notify("Selected image no longer exists in the recent list. Please retry.", "warning");
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const selectedCandidate = discovery.candidates[selectedIndex];
|
|
209
|
+
const selectedImage = loadRecentImage(selectedCandidate);
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
showRecentSelectionPreview(pi, selectedImage);
|
|
213
|
+
} catch (error) {
|
|
214
|
+
ctx.ui.notify(`Could not render recent image preview: ${getErrorMessage(error)}`, "warning");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
queueImageAttachment(
|
|
218
|
+
ctx,
|
|
219
|
+
pendingImages,
|
|
220
|
+
selectedImage,
|
|
221
|
+
`Recent image attached: ${selectedCandidate.name}. Add your message, then send. A terminal preview will render after submit.`,
|
|
222
|
+
);
|
|
223
|
+
} catch (error) {
|
|
224
|
+
ctx.ui.notify(`Recent image picker failed: ${getErrorMessage(error)}`, "warning");
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
pi.on("input", async (event) => {
|
|
229
|
+
if (event.source === "extension") {
|
|
230
|
+
return { action: "continue" as const };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (pendingImages.length === 0) {
|
|
234
|
+
return { action: "continue" as const };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const markerCount = countOccurrences(event.text, IMAGE_ATTACHMENT_INDICATOR);
|
|
238
|
+
if (markerCount === 0) {
|
|
239
|
+
pendingImages.length = 0;
|
|
240
|
+
return { action: "continue" as const };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const imagesToAttach = pendingImages.splice(0, markerCount);
|
|
244
|
+
if (imagesToAttach.length === 0) {
|
|
245
|
+
return { action: "continue" as const };
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
return {
|
|
249
|
+
action: "transform" as const,
|
|
250
|
+
text: removeAttachmentIndicators(event.text),
|
|
251
|
+
images: [...(event.images ?? []), ...imagesToAttach],
|
|
252
|
+
};
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
registerImagePasteKeybindings(pi, pasteImageFromClipboard);
|
|
256
|
+
registerPasteImageCommand(pi, {
|
|
257
|
+
fromClipboard: pasteImageFromClipboard,
|
|
258
|
+
fromRecent: pasteImageFromRecent,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ExtensionAPI,
|
|
3
|
+
InteractiveMode,
|
|
4
|
+
UserMessageComponent,
|
|
5
|
+
} from "@mariozechner/pi-coding-agent";
|
|
6
|
+
import { Image, truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
|
|
7
|
+
|
|
8
|
+
import { buildPreviewItems, type ImagePayload, type ImagePreviewItem } from "./image-preview.js";
|
|
9
|
+
|
|
10
|
+
const SIXEL_IMAGE_LINE_MARKER = "\x1b_Gm=0;\x1b\\";
|
|
11
|
+
|
|
12
|
+
type UserMessageRenderFn = (width: number) => string[];
|
|
13
|
+
|
|
14
|
+
type UserMessagePrototype = {
|
|
15
|
+
render: UserMessageRenderFn;
|
|
16
|
+
__piImageToolsInlineOriginalRender?: UserMessageRenderFn;
|
|
17
|
+
__piImageToolsInlinePatched?: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type UserMessageInstance = {
|
|
21
|
+
__piImageToolsInlineAssigned?: boolean;
|
|
22
|
+
__piImageToolsInlineItems?: ImagePreviewItem[];
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type InteractiveModePrototype = {
|
|
26
|
+
addMessageToChat: (message: unknown, options?: unknown) => void;
|
|
27
|
+
getUserMessageText: (message: unknown) => string;
|
|
28
|
+
__piImageToolsOriginalAddMessageToChat?: (message: unknown, options?: unknown) => void;
|
|
29
|
+
__piImageToolsOriginalGetUserMessageText?: (message: unknown) => string;
|
|
30
|
+
__piImageToolsPreviewPatched?: boolean;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
interface UserImageContent {
|
|
34
|
+
type: "image";
|
|
35
|
+
data: string;
|
|
36
|
+
mimeType: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface UserMessageLike {
|
|
40
|
+
role?: unknown;
|
|
41
|
+
content?: unknown;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface InteractiveModeLike {
|
|
45
|
+
chatContainer?: {
|
|
46
|
+
children?: unknown[];
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function sanitizeRows(rows: number): number {
|
|
51
|
+
return Math.max(1, Math.min(Math.trunc(rows), 80));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function buildSixelLines(sequence: string, rows: number): string[] {
|
|
55
|
+
const safeRows = sanitizeRows(rows);
|
|
56
|
+
const lines = Array.from({ length: Math.max(0, safeRows - 1) }, () => "");
|
|
57
|
+
const moveUp = safeRows > 1 ? `\x1b[${safeRows - 1}A` : "";
|
|
58
|
+
return [...lines, `${SIXEL_IMAGE_LINE_MARKER}${moveUp}${sequence}`];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function buildNativeLines(item: ImagePreviewItem, width: number): string[] {
|
|
62
|
+
if (!item.data) {
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const image = new Image(
|
|
67
|
+
item.data,
|
|
68
|
+
item.mimeType,
|
|
69
|
+
{
|
|
70
|
+
fallbackColor: (text: string) => text,
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
maxWidthCells: item.maxWidthCells,
|
|
74
|
+
},
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
return image.render(Math.max(8, width));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function isInlineImageLine(line: string): boolean {
|
|
81
|
+
return line.startsWith(SIXEL_IMAGE_LINE_MARKER) || line.includes(SIXEL_IMAGE_LINE_MARKER);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function fitLineToWidth(line: string, width: number): string {
|
|
85
|
+
const safeWidth = Math.max(1, Math.floor(width));
|
|
86
|
+
if (isInlineImageLine(line)) {
|
|
87
|
+
return line;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (visibleWidth(line) <= safeWidth) {
|
|
91
|
+
return line;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return truncateToWidth(line, safeWidth, "", true);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function fitLinesToWidth(lines: readonly string[], width: number): string[] {
|
|
98
|
+
return lines.map((line) => fitLineToWidth(line, width));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function renderPreviewLines(items: readonly ImagePreviewItem[], width: number): string[] {
|
|
102
|
+
if (items.length === 0) {
|
|
103
|
+
return [];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const lines: string[] = ["", "↳ pasted image preview"];
|
|
107
|
+
|
|
108
|
+
for (const item of items) {
|
|
109
|
+
lines.push("");
|
|
110
|
+
|
|
111
|
+
if (item.protocol === "sixel" && item.sixelSequence) {
|
|
112
|
+
lines.push(...buildSixelLines(item.sixelSequence, item.rows));
|
|
113
|
+
} else {
|
|
114
|
+
lines.push(...buildNativeLines(item, width));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (item.warning) {
|
|
118
|
+
lines.push(...item.warning.split(/\r?\n/).filter((line) => line.length > 0));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return fitLinesToWidth(lines, width);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function toUserMessage(value: unknown): UserMessageLike {
|
|
126
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
127
|
+
return {};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return value as UserMessageLike;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function toImageContent(value: unknown): UserImageContent | null {
|
|
134
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const record = value as Record<string, unknown>;
|
|
139
|
+
if (record.type !== "image") {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (typeof record.data !== "string" || record.data.length === 0) {
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return {
|
|
148
|
+
type: "image",
|
|
149
|
+
data: record.data,
|
|
150
|
+
mimeType: typeof record.mimeType === "string" && record.mimeType.length > 0
|
|
151
|
+
? record.mimeType
|
|
152
|
+
: "image/png",
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function extractImagePayloads(message: unknown): ImagePayload[] {
|
|
157
|
+
const userMessage = toUserMessage(message);
|
|
158
|
+
if (userMessage.role !== "user") {
|
|
159
|
+
return [];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (!Array.isArray(userMessage.content)) {
|
|
163
|
+
return [];
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const payloads: ImagePayload[] = [];
|
|
167
|
+
for (const part of userMessage.content) {
|
|
168
|
+
const image = toImageContent(part);
|
|
169
|
+
if (!image) {
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
payloads.push({
|
|
174
|
+
type: "image",
|
|
175
|
+
data: image.data,
|
|
176
|
+
mimeType: image.mimeType,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return payloads;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function imagePlaceholderText(count: number): string {
|
|
184
|
+
if (count <= 1) {
|
|
185
|
+
return "[ 1 image attached]";
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return `[ ${count} images attached]`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function patchUserMessageRender(): void {
|
|
192
|
+
const prototype = (UserMessageComponent as unknown as { prototype: UserMessagePrototype }).prototype;
|
|
193
|
+
if (typeof prototype.render !== "function") {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (!prototype.__piImageToolsInlineOriginalRender) {
|
|
198
|
+
prototype.__piImageToolsInlineOriginalRender = prototype.render;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (prototype.__piImageToolsInlinePatched) {
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
prototype.render = function renderWithInlineImagePreview(width: number): string[] {
|
|
206
|
+
const originalRender = prototype.__piImageToolsInlineOriginalRender;
|
|
207
|
+
if (!originalRender) {
|
|
208
|
+
return [];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const instance = this as unknown as UserMessageInstance;
|
|
212
|
+
if (!instance.__piImageToolsInlineAssigned) {
|
|
213
|
+
instance.__piImageToolsInlineAssigned = true;
|
|
214
|
+
if (!Array.isArray(instance.__piImageToolsInlineItems)) {
|
|
215
|
+
instance.__piImageToolsInlineItems = [];
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const baseLines = originalRender.call(this, width);
|
|
220
|
+
const previewLines = renderPreviewLines(instance.__piImageToolsInlineItems ?? [], width);
|
|
221
|
+
if (previewLines.length === 0) {
|
|
222
|
+
return baseLines;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return [...baseLines, ...previewLines];
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
prototype.__piImageToolsInlinePatched = true;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function assignPreviewItemsToLatestUserMessage(
|
|
232
|
+
mode: InteractiveModeLike,
|
|
233
|
+
fromChildIndex: number,
|
|
234
|
+
previewItems: ImagePreviewItem[],
|
|
235
|
+
): void {
|
|
236
|
+
const children = mode.chatContainer?.children;
|
|
237
|
+
if (!Array.isArray(children) || children.length === 0) {
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const start = Math.max(0, fromChildIndex);
|
|
242
|
+
for (let index = children.length - 1; index >= start; index -= 1) {
|
|
243
|
+
const child = children[index];
|
|
244
|
+
if (!(child instanceof UserMessageComponent)) {
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const instance = child as unknown as UserMessageInstance;
|
|
249
|
+
instance.__piImageToolsInlineItems = previewItems;
|
|
250
|
+
instance.__piImageToolsInlineAssigned = true;
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function patchInteractiveMode(): void {
|
|
256
|
+
const prototype = (InteractiveMode as unknown as { prototype: InteractiveModePrototype }).prototype;
|
|
257
|
+
if (!prototype) {
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (!prototype.__piImageToolsOriginalGetUserMessageText) {
|
|
262
|
+
prototype.__piImageToolsOriginalGetUserMessageText = prototype.getUserMessageText;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (!prototype.__piImageToolsOriginalAddMessageToChat) {
|
|
266
|
+
prototype.__piImageToolsOriginalAddMessageToChat = prototype.addMessageToChat;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (prototype.__piImageToolsPreviewPatched) {
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
prototype.getUserMessageText = function getUserMessageTextWithImagePlaceholder(message: unknown): string {
|
|
274
|
+
const original = prototype.__piImageToolsOriginalGetUserMessageText;
|
|
275
|
+
const text = original ? original.call(this, message) : "";
|
|
276
|
+
if (text.trim().length > 0) {
|
|
277
|
+
return text;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
const images = extractImagePayloads(message);
|
|
281
|
+
if (images.length === 0) {
|
|
282
|
+
return text;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return imagePlaceholderText(images.length);
|
|
286
|
+
};
|
|
287
|
+
|
|
288
|
+
prototype.addMessageToChat = function addMessageToChatWithImagePreview(message: unknown, options?: unknown): void {
|
|
289
|
+
const mode = this as unknown as InteractiveModeLike;
|
|
290
|
+
const beforeCount = Array.isArray(mode.chatContainer?.children)
|
|
291
|
+
? mode.chatContainer?.children.length ?? 0
|
|
292
|
+
: 0;
|
|
293
|
+
|
|
294
|
+
const imagePayloads = extractImagePayloads(message);
|
|
295
|
+
let previewItems: ImagePreviewItem[] = [];
|
|
296
|
+
if (imagePayloads.length > 0) {
|
|
297
|
+
try {
|
|
298
|
+
previewItems = buildPreviewItems(imagePayloads);
|
|
299
|
+
} catch {
|
|
300
|
+
previewItems = [];
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const original = prototype.__piImageToolsOriginalAddMessageToChat;
|
|
305
|
+
if (!original) {
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
original.call(this, message, options);
|
|
310
|
+
|
|
311
|
+
if (previewItems.length === 0) {
|
|
312
|
+
return;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
assignPreviewItemsToLatestUserMessage(mode, beforeCount, previewItems);
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
prototype.__piImageToolsPreviewPatched = true;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export function registerInlineUserImagePreview(pi: ExtensionAPI): void {
|
|
322
|
+
const schedulePatch = (): void => {
|
|
323
|
+
setTimeout(() => {
|
|
324
|
+
patchInteractiveMode();
|
|
325
|
+
patchUserMessageRender();
|
|
326
|
+
}, 0);
|
|
327
|
+
|
|
328
|
+
setTimeout(() => {
|
|
329
|
+
patchInteractiveMode();
|
|
330
|
+
patchUserMessageRender();
|
|
331
|
+
}, 25);
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
pi.on("session_start", async () => {
|
|
335
|
+
schedulePatch();
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
pi.on("before_agent_start", async () => {
|
|
339
|
+
schedulePatch();
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
pi.on("session_switch", async () => {
|
|
343
|
+
schedulePatch();
|
|
344
|
+
});
|
|
345
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
import type { KeyId } from "@mariozechner/pi-tui";
|
|
3
|
+
|
|
4
|
+
import type { PasteImageHandler } from "./types.js";
|
|
5
|
+
|
|
6
|
+
const IMAGE_PASTE_SHORTCUTS: KeyId[] = ["alt+v", "ctrl+alt+v"];
|
|
7
|
+
|
|
8
|
+
export function registerImagePasteKeybindings(pi: ExtensionAPI, handler: PasteImageHandler): void {
|
|
9
|
+
for (const shortcut of IMAGE_PASTE_SHORTCUTS) {
|
|
10
|
+
pi.registerShortcut(shortcut, {
|
|
11
|
+
description: "Attach clipboard image to draft (send when ready)",
|
|
12
|
+
handler,
|
|
13
|
+
});
|
|
14
|
+
}
|
|
15
|
+
}
|