pi-image-tools 1.0.4 → 1.0.6
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 +42 -37
- package/README.md +244 -244
- package/package.json +61 -53
- package/src/clipboard.ts +385 -385
- package/src/commands.ts +79 -79
- package/src/index.ts +252 -252
- package/src/inline-user-preview.ts +354 -354
- package/src/keybindings.ts +21 -21
- package/src/recent-images.ts +508 -508
- package/src/temp-file.ts +82 -82
package/src/commands.ts
CHANGED
|
@@ -1,79 +1,79 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
-
|
|
3
|
-
import type { PasteImageCommandHandlers } from "./types.js";
|
|
4
|
-
|
|
5
|
-
const SUBCOMMAND_CLIPBOARD = "clipboard";
|
|
6
|
-
const SUBCOMMAND_RECENT = "recent";
|
|
7
|
-
|
|
8
|
-
const ARGUMENT_COMPLETIONS = [
|
|
9
|
-
{
|
|
10
|
-
value: SUBCOMMAND_CLIPBOARD,
|
|
11
|
-
label: SUBCOMMAND_CLIPBOARD,
|
|
12
|
-
description: "Attach image from clipboard",
|
|
13
|
-
},
|
|
14
|
-
{
|
|
15
|
-
value: SUBCOMMAND_RECENT,
|
|
16
|
-
label: SUBCOMMAND_RECENT,
|
|
17
|
-
description: "Open recent images picker and attach selected image",
|
|
18
|
-
},
|
|
19
|
-
{
|
|
20
|
-
value: "help",
|
|
21
|
-
label: "help",
|
|
22
|
-
description: "Show usage",
|
|
23
|
-
},
|
|
24
|
-
] as const;
|
|
25
|
-
|
|
26
|
-
function parseArgs(args: string): string[] {
|
|
27
|
-
return args
|
|
28
|
-
.trim()
|
|
29
|
-
.split(/\s+/)
|
|
30
|
-
.map((token) => token.trim().toLowerCase())
|
|
31
|
-
.filter((token) => token.length > 0);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
function usageMessage(): string {
|
|
35
|
-
return "Usage: /paste-image [clipboard|recent]";
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export function registerPasteImageCommand(
|
|
39
|
-
pi: ExtensionAPI,
|
|
40
|
-
handlers: PasteImageCommandHandlers,
|
|
41
|
-
): void {
|
|
42
|
-
pi.registerCommand("paste-image", {
|
|
43
|
-
description: "Attach an image from clipboard or use a recent-image picker",
|
|
44
|
-
getArgumentCompletions: (argumentPrefix) => {
|
|
45
|
-
const normalized = argumentPrefix.trim().toLowerCase();
|
|
46
|
-
if (!normalized) {
|
|
47
|
-
return [...ARGUMENT_COMPLETIONS];
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
const matches = ARGUMENT_COMPLETIONS.filter((item) => item.value.startsWith(normalized));
|
|
51
|
-
return matches.length > 0 ? matches.map((item) => ({ ...item })) : null;
|
|
52
|
-
},
|
|
53
|
-
handler: async (args, ctx) => {
|
|
54
|
-
const tokens = parseArgs(args);
|
|
55
|
-
|
|
56
|
-
if (tokens.length === 0 || tokens[0] === SUBCOMMAND_CLIPBOARD) {
|
|
57
|
-
await handlers.fromClipboard(ctx);
|
|
58
|
-
return;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
if (tokens[0] === SUBCOMMAND_RECENT) {
|
|
62
|
-
if (tokens.length > 1) {
|
|
63
|
-
ctx.ui.notify(usageMessage(), "warning");
|
|
64
|
-
return;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
await handlers.fromRecent(ctx);
|
|
68
|
-
return;
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
if (tokens[0] === "help") {
|
|
72
|
-
ctx.ui.notify(usageMessage(), "info");
|
|
73
|
-
return;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
ctx.ui.notify(usageMessage(), "warning");
|
|
77
|
-
},
|
|
78
|
-
});
|
|
79
|
-
}
|
|
1
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
import type { PasteImageCommandHandlers } from "./types.js";
|
|
4
|
+
|
|
5
|
+
const SUBCOMMAND_CLIPBOARD = "clipboard";
|
|
6
|
+
const SUBCOMMAND_RECENT = "recent";
|
|
7
|
+
|
|
8
|
+
const ARGUMENT_COMPLETIONS = [
|
|
9
|
+
{
|
|
10
|
+
value: SUBCOMMAND_CLIPBOARD,
|
|
11
|
+
label: SUBCOMMAND_CLIPBOARD,
|
|
12
|
+
description: "Attach image from clipboard",
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
value: SUBCOMMAND_RECENT,
|
|
16
|
+
label: SUBCOMMAND_RECENT,
|
|
17
|
+
description: "Open recent images picker and attach selected image",
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
value: "help",
|
|
21
|
+
label: "help",
|
|
22
|
+
description: "Show usage",
|
|
23
|
+
},
|
|
24
|
+
] as const;
|
|
25
|
+
|
|
26
|
+
function parseArgs(args: string): string[] {
|
|
27
|
+
return args
|
|
28
|
+
.trim()
|
|
29
|
+
.split(/\s+/)
|
|
30
|
+
.map((token) => token.trim().toLowerCase())
|
|
31
|
+
.filter((token) => token.length > 0);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function usageMessage(): string {
|
|
35
|
+
return "Usage: /paste-image [clipboard|recent]";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function registerPasteImageCommand(
|
|
39
|
+
pi: ExtensionAPI,
|
|
40
|
+
handlers: PasteImageCommandHandlers,
|
|
41
|
+
): void {
|
|
42
|
+
pi.registerCommand("paste-image", {
|
|
43
|
+
description: "Attach an image from clipboard or use a recent-image picker",
|
|
44
|
+
getArgumentCompletions: (argumentPrefix) => {
|
|
45
|
+
const normalized = argumentPrefix.trim().toLowerCase();
|
|
46
|
+
if (!normalized) {
|
|
47
|
+
return [...ARGUMENT_COMPLETIONS];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const matches = ARGUMENT_COMPLETIONS.filter((item) => item.value.startsWith(normalized));
|
|
51
|
+
return matches.length > 0 ? matches.map((item) => ({ ...item })) : null;
|
|
52
|
+
},
|
|
53
|
+
handler: async (args, ctx) => {
|
|
54
|
+
const tokens = parseArgs(args);
|
|
55
|
+
|
|
56
|
+
if (tokens.length === 0 || tokens[0] === SUBCOMMAND_CLIPBOARD) {
|
|
57
|
+
await handlers.fromClipboard(ctx);
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (tokens[0] === SUBCOMMAND_RECENT) {
|
|
62
|
+
if (tokens.length > 1) {
|
|
63
|
+
ctx.ui.notify(usageMessage(), "warning");
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
await handlers.fromRecent(ctx);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (tokens[0] === "help") {
|
|
72
|
+
ctx.ui.notify(usageMessage(), "info");
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
ctx.ui.notify(usageMessage(), "warning");
|
|
77
|
+
},
|
|
78
|
+
});
|
|
79
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -1,252 +1,252 @@
|
|
|
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 imageToBase64(image: ClipboardImage): string {
|
|
37
|
-
return Buffer.from(image.bytes).toString("base64");
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function countOccurrences(text: string, token: string): number {
|
|
41
|
-
if (token.length === 0) {
|
|
42
|
-
return 0;
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
let count = 0;
|
|
46
|
-
let cursor = 0;
|
|
47
|
-
while (cursor < text.length) {
|
|
48
|
-
const index = text.indexOf(token, cursor);
|
|
49
|
-
if (index === -1) {
|
|
50
|
-
break;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
count += 1;
|
|
54
|
-
cursor = index + token.length;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
return count;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function removeAttachmentIndicators(text: string): string {
|
|
61
|
-
const withoutMarkers = text.split(IMAGE_ATTACHMENT_INDICATOR).join("");
|
|
62
|
-
return withoutMarkers.replace(/\n{3,}/g, "\n\n").trim();
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function queueImageAttachment(
|
|
66
|
-
ctx: PasteContext,
|
|
67
|
-
pendingImages: PendingImage[],
|
|
68
|
-
image: ClipboardImage,
|
|
69
|
-
successMessage: string,
|
|
70
|
-
options: { cacheForRecentPicker?: boolean } = {},
|
|
71
|
-
): void {
|
|
72
|
-
pendingImages.push({
|
|
73
|
-
type: "image",
|
|
74
|
-
data: imageToBase64(image),
|
|
75
|
-
mimeType: image.mimeType,
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
if (options.cacheForRecentPicker) {
|
|
79
|
-
try {
|
|
80
|
-
persistImageToRecentCache(image);
|
|
81
|
-
} catch (error) {
|
|
82
|
-
if (ctx.hasUI) {
|
|
83
|
-
ctx.ui.notify(
|
|
84
|
-
`Image attached, but failed to cache for /paste-image recent: ${getErrorMessage(error)}`,
|
|
85
|
-
"warning",
|
|
86
|
-
);
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
if (!ctx.hasUI) {
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
ctx.ui.pasteToEditor(`${IMAGE_ATTACHMENT_INDICATOR} `);
|
|
96
|
-
ctx.ui.notify(successMessage, "info");
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
function buildRecentImageEmptyStateMessage(searchedDirectories: readonly string[]): string {
|
|
100
|
-
const searched =
|
|
101
|
-
searchedDirectories.length > 0
|
|
102
|
-
? searchedDirectories.join("; ")
|
|
103
|
-
: "No directories configured";
|
|
104
|
-
const cacheDirectory = getRecentImageCacheDirectory();
|
|
105
|
-
|
|
106
|
-
return [
|
|
107
|
-
`No recent images found. Searched: ${searched}`,
|
|
108
|
-
`Clipboard-pasted images are cached in: ${cacheDirectory}`,
|
|
109
|
-
`Set ${RECENT_IMAGE_ENV_VAR} to a semicolon-separated list of directories to override defaults.`,
|
|
110
|
-
`Set ${RECENT_IMAGE_CACHE_DIR_ENV_VAR} to customize the cache directory.`,
|
|
111
|
-
].join(" ");
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function showRecentSelectionPreview(
|
|
115
|
-
pi: ExtensionAPI,
|
|
116
|
-
image: ClipboardImage,
|
|
117
|
-
): void {
|
|
118
|
-
const previewItems = buildPreviewItems([
|
|
119
|
-
{
|
|
120
|
-
type: "image",
|
|
121
|
-
data: imageToBase64(image),
|
|
122
|
-
mimeType: image.mimeType,
|
|
123
|
-
},
|
|
124
|
-
]);
|
|
125
|
-
|
|
126
|
-
if (previewItems.length === 0) {
|
|
127
|
-
return;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
pi.sendMessage(
|
|
131
|
-
{
|
|
132
|
-
customType: IMAGE_PREVIEW_CUSTOM_TYPE,
|
|
133
|
-
content: "",
|
|
134
|
-
display: true,
|
|
135
|
-
details: { items: previewItems },
|
|
136
|
-
},
|
|
137
|
-
{ triggerTurn: false },
|
|
138
|
-
);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
export default function imageToolsExtension(pi: ExtensionAPI): void {
|
|
142
|
-
const pendingImages: PendingImage[] = [];
|
|
143
|
-
|
|
144
|
-
registerInlineUserImagePreview(pi);
|
|
145
|
-
registerImagePreviewDisplay(pi);
|
|
146
|
-
|
|
147
|
-
const pasteImageFromClipboard = async (ctx: PasteContext): Promise<void> => {
|
|
148
|
-
if (!ctx.hasUI) {
|
|
149
|
-
return;
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
try {
|
|
153
|
-
const image = await readClipboardImage();
|
|
154
|
-
if (!image) {
|
|
155
|
-
ctx.ui.notify("No image found in clipboard.", "warning");
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
queueImageAttachment(
|
|
160
|
-
ctx,
|
|
161
|
-
pendingImages,
|
|
162
|
-
image,
|
|
163
|
-
"Image attached from clipboard. Add your message, then send. A terminal preview will render after submit.",
|
|
164
|
-
{ cacheForRecentPicker: true },
|
|
165
|
-
);
|
|
166
|
-
} catch (error) {
|
|
167
|
-
ctx.ui.notify(`Image paste failed: ${getErrorMessage(error)}`, "warning");
|
|
168
|
-
}
|
|
169
|
-
};
|
|
170
|
-
|
|
171
|
-
const pasteImageFromRecent = async (ctx: PasteContext): Promise<void> => {
|
|
172
|
-
if (!ctx.hasUI) {
|
|
173
|
-
ctx.ui.notify("/paste-image recent requires interactive TUI mode.", "warning");
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
try {
|
|
178
|
-
const discovery = discoverRecentImages();
|
|
179
|
-
if (discovery.candidates.length === 0) {
|
|
180
|
-
ctx.ui.notify(buildRecentImageEmptyStateMessage(discovery.searchedDirectories), "warning");
|
|
181
|
-
return;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
const options = discovery.candidates.map((candidate, index) => {
|
|
185
|
-
const rank = String(index + 1).padStart(2, "0");
|
|
186
|
-
return `${rank}. ${formatRecentImageLabel(candidate)}`;
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
const selectedOption = await ctx.ui.select("Select a recent image", options);
|
|
190
|
-
if (!selectedOption) {
|
|
191
|
-
return;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
const selectedIndex = options.indexOf(selectedOption);
|
|
195
|
-
if (selectedIndex === -1) {
|
|
196
|
-
ctx.ui.notify("Selected image no longer exists in the recent list. Please retry.", "warning");
|
|
197
|
-
return;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
const selectedCandidate = discovery.candidates[selectedIndex];
|
|
201
|
-
const selectedImage = loadRecentImage(selectedCandidate);
|
|
202
|
-
|
|
203
|
-
try {
|
|
204
|
-
showRecentSelectionPreview(pi, selectedImage);
|
|
205
|
-
} catch (error) {
|
|
206
|
-
ctx.ui.notify(`Could not render recent image preview: ${getErrorMessage(error)}`, "warning");
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
queueImageAttachment(
|
|
210
|
-
ctx,
|
|
211
|
-
pendingImages,
|
|
212
|
-
selectedImage,
|
|
213
|
-
`Recent image attached: ${selectedCandidate.name}. Add your message, then send. A terminal preview will render after submit.`,
|
|
214
|
-
);
|
|
215
|
-
} catch (error) {
|
|
216
|
-
ctx.ui.notify(`Recent image picker failed: ${getErrorMessage(error)}`, "warning");
|
|
217
|
-
}
|
|
218
|
-
};
|
|
219
|
-
|
|
220
|
-
pi.on("input", async (event) => {
|
|
221
|
-
if (event.source === "extension") {
|
|
222
|
-
return { action: "continue" as const };
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
if (pendingImages.length === 0) {
|
|
226
|
-
return { action: "continue" as const };
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
const markerCount = countOccurrences(event.text, IMAGE_ATTACHMENT_INDICATOR);
|
|
230
|
-
if (markerCount === 0) {
|
|
231
|
-
pendingImages.length = 0;
|
|
232
|
-
return { action: "continue" as const };
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
const imagesToAttach = pendingImages.splice(0, markerCount);
|
|
236
|
-
if (imagesToAttach.length === 0) {
|
|
237
|
-
return { action: "continue" as const };
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
return {
|
|
241
|
-
action: "transform" as const,
|
|
242
|
-
text: removeAttachmentIndicators(event.text),
|
|
243
|
-
images: [...(event.images ?? []), ...imagesToAttach],
|
|
244
|
-
};
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
registerImagePasteKeybindings(pi, pasteImageFromClipboard);
|
|
248
|
-
registerPasteImageCommand(pi, {
|
|
249
|
-
fromClipboard: pasteImageFromClipboard,
|
|
250
|
-
fromRecent: pasteImageFromRecent,
|
|
251
|
-
});
|
|
252
|
-
}
|
|
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 imageToBase64(image: ClipboardImage): string {
|
|
37
|
+
return Buffer.from(image.bytes).toString("base64");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function countOccurrences(text: string, token: string): number {
|
|
41
|
+
if (token.length === 0) {
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let count = 0;
|
|
46
|
+
let cursor = 0;
|
|
47
|
+
while (cursor < text.length) {
|
|
48
|
+
const index = text.indexOf(token, cursor);
|
|
49
|
+
if (index === -1) {
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
count += 1;
|
|
54
|
+
cursor = index + token.length;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return count;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function removeAttachmentIndicators(text: string): string {
|
|
61
|
+
const withoutMarkers = text.split(IMAGE_ATTACHMENT_INDICATOR).join("");
|
|
62
|
+
return withoutMarkers.replace(/\n{3,}/g, "\n\n").trim();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function queueImageAttachment(
|
|
66
|
+
ctx: PasteContext,
|
|
67
|
+
pendingImages: PendingImage[],
|
|
68
|
+
image: ClipboardImage,
|
|
69
|
+
successMessage: string,
|
|
70
|
+
options: { cacheForRecentPicker?: boolean } = {},
|
|
71
|
+
): void {
|
|
72
|
+
pendingImages.push({
|
|
73
|
+
type: "image",
|
|
74
|
+
data: imageToBase64(image),
|
|
75
|
+
mimeType: image.mimeType,
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
if (options.cacheForRecentPicker) {
|
|
79
|
+
try {
|
|
80
|
+
persistImageToRecentCache(image);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
if (ctx.hasUI) {
|
|
83
|
+
ctx.ui.notify(
|
|
84
|
+
`Image attached, but failed to cache for /paste-image recent: ${getErrorMessage(error)}`,
|
|
85
|
+
"warning",
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!ctx.hasUI) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
ctx.ui.pasteToEditor(`${IMAGE_ATTACHMENT_INDICATOR} `);
|
|
96
|
+
ctx.ui.notify(successMessage, "info");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function buildRecentImageEmptyStateMessage(searchedDirectories: readonly string[]): string {
|
|
100
|
+
const searched =
|
|
101
|
+
searchedDirectories.length > 0
|
|
102
|
+
? searchedDirectories.join("; ")
|
|
103
|
+
: "No directories configured";
|
|
104
|
+
const cacheDirectory = getRecentImageCacheDirectory();
|
|
105
|
+
|
|
106
|
+
return [
|
|
107
|
+
`No recent images found. Searched: ${searched}`,
|
|
108
|
+
`Clipboard-pasted images are cached in: ${cacheDirectory}`,
|
|
109
|
+
`Set ${RECENT_IMAGE_ENV_VAR} to a semicolon-separated list of directories to override defaults.`,
|
|
110
|
+
`Set ${RECENT_IMAGE_CACHE_DIR_ENV_VAR} to customize the cache directory.`,
|
|
111
|
+
].join(" ");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function showRecentSelectionPreview(
|
|
115
|
+
pi: ExtensionAPI,
|
|
116
|
+
image: ClipboardImage,
|
|
117
|
+
): void {
|
|
118
|
+
const previewItems = buildPreviewItems([
|
|
119
|
+
{
|
|
120
|
+
type: "image",
|
|
121
|
+
data: imageToBase64(image),
|
|
122
|
+
mimeType: image.mimeType,
|
|
123
|
+
},
|
|
124
|
+
]);
|
|
125
|
+
|
|
126
|
+
if (previewItems.length === 0) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
pi.sendMessage(
|
|
131
|
+
{
|
|
132
|
+
customType: IMAGE_PREVIEW_CUSTOM_TYPE,
|
|
133
|
+
content: "",
|
|
134
|
+
display: true,
|
|
135
|
+
details: { items: previewItems },
|
|
136
|
+
},
|
|
137
|
+
{ triggerTurn: false },
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export default function imageToolsExtension(pi: ExtensionAPI): void {
|
|
142
|
+
const pendingImages: PendingImage[] = [];
|
|
143
|
+
|
|
144
|
+
registerInlineUserImagePreview(pi);
|
|
145
|
+
registerImagePreviewDisplay(pi);
|
|
146
|
+
|
|
147
|
+
const pasteImageFromClipboard = async (ctx: PasteContext): Promise<void> => {
|
|
148
|
+
if (!ctx.hasUI) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
const image = await readClipboardImage();
|
|
154
|
+
if (!image) {
|
|
155
|
+
ctx.ui.notify("No image found in clipboard.", "warning");
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
queueImageAttachment(
|
|
160
|
+
ctx,
|
|
161
|
+
pendingImages,
|
|
162
|
+
image,
|
|
163
|
+
"Image attached from clipboard. Add your message, then send. A terminal preview will render after submit.",
|
|
164
|
+
{ cacheForRecentPicker: true },
|
|
165
|
+
);
|
|
166
|
+
} catch (error) {
|
|
167
|
+
ctx.ui.notify(`Image paste failed: ${getErrorMessage(error)}`, "warning");
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const pasteImageFromRecent = async (ctx: PasteContext): Promise<void> => {
|
|
172
|
+
if (!ctx.hasUI) {
|
|
173
|
+
ctx.ui.notify("/paste-image recent requires interactive TUI mode.", "warning");
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
try {
|
|
178
|
+
const discovery = discoverRecentImages();
|
|
179
|
+
if (discovery.candidates.length === 0) {
|
|
180
|
+
ctx.ui.notify(buildRecentImageEmptyStateMessage(discovery.searchedDirectories), "warning");
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const options = discovery.candidates.map((candidate, index) => {
|
|
185
|
+
const rank = String(index + 1).padStart(2, "0");
|
|
186
|
+
return `${rank}. ${formatRecentImageLabel(candidate)}`;
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
const selectedOption = await ctx.ui.select("Select a recent image", options);
|
|
190
|
+
if (!selectedOption) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const selectedIndex = options.indexOf(selectedOption);
|
|
195
|
+
if (selectedIndex === -1) {
|
|
196
|
+
ctx.ui.notify("Selected image no longer exists in the recent list. Please retry.", "warning");
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const selectedCandidate = discovery.candidates[selectedIndex];
|
|
201
|
+
const selectedImage = loadRecentImage(selectedCandidate);
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
showRecentSelectionPreview(pi, selectedImage);
|
|
205
|
+
} catch (error) {
|
|
206
|
+
ctx.ui.notify(`Could not render recent image preview: ${getErrorMessage(error)}`, "warning");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
queueImageAttachment(
|
|
210
|
+
ctx,
|
|
211
|
+
pendingImages,
|
|
212
|
+
selectedImage,
|
|
213
|
+
`Recent image attached: ${selectedCandidate.name}. Add your message, then send. A terminal preview will render after submit.`,
|
|
214
|
+
);
|
|
215
|
+
} catch (error) {
|
|
216
|
+
ctx.ui.notify(`Recent image picker failed: ${getErrorMessage(error)}`, "warning");
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
pi.on("input", async (event) => {
|
|
221
|
+
if (event.source === "extension") {
|
|
222
|
+
return { action: "continue" as const };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (pendingImages.length === 0) {
|
|
226
|
+
return { action: "continue" as const };
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const markerCount = countOccurrences(event.text, IMAGE_ATTACHMENT_INDICATOR);
|
|
230
|
+
if (markerCount === 0) {
|
|
231
|
+
pendingImages.length = 0;
|
|
232
|
+
return { action: "continue" as const };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const imagesToAttach = pendingImages.splice(0, markerCount);
|
|
236
|
+
if (imagesToAttach.length === 0) {
|
|
237
|
+
return { action: "continue" as const };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
action: "transform" as const,
|
|
242
|
+
text: removeAttachmentIndicators(event.text),
|
|
243
|
+
images: [...(event.images ?? []), ...imagesToAttach],
|
|
244
|
+
};
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
registerImagePasteKeybindings(pi, pasteImageFromClipboard);
|
|
248
|
+
registerPasteImageCommand(pi, {
|
|
249
|
+
fromClipboard: pasteImageFromClipboard,
|
|
250
|
+
fromRecent: pasteImageFromRecent,
|
|
251
|
+
});
|
|
252
|
+
}
|