pi-paster 0.2.0 → 0.2.2
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/README.md +41 -6
- package/dist/index.d.mts +80 -2
- package/dist/index.mjs +372 -23
- package/package.json +2 -2
- package/src/compress.ts +329 -0
- package/src/config.ts +67 -0
- package/src/editor.ts +105 -11
- package/src/image-utils.ts +8 -0
- package/src/index.ts +46 -8
- package/src/preview.ts +93 -4
- package/src/types.ts +11 -0
package/src/compress.ts
ADDED
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
import type { ResolvedImageCompressionConfig } from "./config.ts";
|
|
6
|
+
import type { ImageCompressionReportDetails } from "./types.ts";
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_IMAGE_SUMMARY_PROMPT =
|
|
9
|
+
"Summarize this image in 2-4 concise sentences. Include important visible text, UI elements, errors, diagrams, and details that may matter for future coding or design work.";
|
|
10
|
+
|
|
11
|
+
interface TextBlock {
|
|
12
|
+
type: "text";
|
|
13
|
+
text: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface ImageBlock {
|
|
17
|
+
type: "image";
|
|
18
|
+
data: string;
|
|
19
|
+
mimeType: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
type ContentBlock = TextBlock | ImageBlock | Record<string, unknown>;
|
|
23
|
+
|
|
24
|
+
type SessionEntryLike = {
|
|
25
|
+
type: string;
|
|
26
|
+
thinkingLevel?: string;
|
|
27
|
+
provider?: string;
|
|
28
|
+
modelId?: string;
|
|
29
|
+
summary?: string;
|
|
30
|
+
firstKeptEntryId?: string;
|
|
31
|
+
tokensBefore?: number;
|
|
32
|
+
details?: unknown;
|
|
33
|
+
data?: unknown;
|
|
34
|
+
name?: string;
|
|
35
|
+
fromHook?: boolean;
|
|
36
|
+
customType?: string;
|
|
37
|
+
content?: string | ContentBlock[];
|
|
38
|
+
display?: boolean;
|
|
39
|
+
message?: {
|
|
40
|
+
role?: string;
|
|
41
|
+
content?: string | ContentBlock[];
|
|
42
|
+
[key: string]: unknown;
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
interface ImageOccurrence {
|
|
47
|
+
key: string;
|
|
48
|
+
image: ImageBlock;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isImageBlock(block: unknown): block is ImageBlock {
|
|
52
|
+
return (
|
|
53
|
+
!!block &&
|
|
54
|
+
typeof block === "object" &&
|
|
55
|
+
(block as { type?: unknown }).type === "image" &&
|
|
56
|
+
typeof (block as { data?: unknown }).data === "string" &&
|
|
57
|
+
typeof (block as { mimeType?: unknown }).mimeType === "string"
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function imageKey(image: ImageBlock): string {
|
|
62
|
+
return `${image.mimeType}:${image.data.length}:${image.data.slice(0, 48)}`;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function collectImages(entries: SessionEntryLike[]): ImageOccurrence[] {
|
|
66
|
+
const images: ImageOccurrence[] = [];
|
|
67
|
+
for (const entry of entries) {
|
|
68
|
+
const content = entry.type === "custom_message" ? entry.content : entry.message?.content;
|
|
69
|
+
if (!Array.isArray(content)) continue;
|
|
70
|
+
for (const block of content) {
|
|
71
|
+
if (!isImageBlock(block)) continue;
|
|
72
|
+
images.push({ key: imageKey(block), image: block });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return images;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function extensionForMime(mimeType: string): string {
|
|
79
|
+
switch (mimeType) {
|
|
80
|
+
case "image/jpeg":
|
|
81
|
+
return ".jpg";
|
|
82
|
+
case "image/webp":
|
|
83
|
+
return ".webp";
|
|
84
|
+
case "image/gif":
|
|
85
|
+
return ".gif";
|
|
86
|
+
case "image/png":
|
|
87
|
+
default:
|
|
88
|
+
return ".png";
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function normalizeSummary(stdout: string): string {
|
|
93
|
+
return stdout.trim().replace(/\n{3,}/g, "\n\n");
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function summarizeImage(
|
|
97
|
+
pi: ExtensionAPI,
|
|
98
|
+
ctx: ExtensionCommandContext,
|
|
99
|
+
image: ImageBlock,
|
|
100
|
+
config: ResolvedImageCompressionConfig,
|
|
101
|
+
): Promise<string> {
|
|
102
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-paster-image-compress-"));
|
|
103
|
+
try {
|
|
104
|
+
const imagePath = join(dir, `image${extensionForMime(image.mimeType)}`);
|
|
105
|
+
writeFileSync(imagePath, Buffer.from(image.data, "base64"));
|
|
106
|
+
|
|
107
|
+
const args = ["--no-session", "--no-extensions", "--no-tools", "--mode", "text", "-p"];
|
|
108
|
+
if (config.model) {
|
|
109
|
+
args.splice(0, 0, "--model", config.model);
|
|
110
|
+
}
|
|
111
|
+
args.push(`@${imagePath}`, config.prompt);
|
|
112
|
+
|
|
113
|
+
const result = await pi.exec(config.piCommand, args, {
|
|
114
|
+
cwd: ctx.cwd,
|
|
115
|
+
timeout: config.timeoutMs,
|
|
116
|
+
});
|
|
117
|
+
if (result.code !== 0) {
|
|
118
|
+
const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`;
|
|
119
|
+
throw new Error(`image summarization failed: ${detail}`);
|
|
120
|
+
}
|
|
121
|
+
const summary = normalizeSummary(result.stdout);
|
|
122
|
+
if (!summary) throw new Error("image summarization returned an empty response");
|
|
123
|
+
return summary;
|
|
124
|
+
} finally {
|
|
125
|
+
rmSync(dir, { recursive: true, force: true });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function summaryBlock(summary: string): TextBlock {
|
|
130
|
+
return { type: "text", text: `Image summary: ${summary}` };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function transformContent(
|
|
134
|
+
content: string | ContentBlock[] | undefined,
|
|
135
|
+
summaries: Map<string, string>,
|
|
136
|
+
): string | ContentBlock[] | undefined {
|
|
137
|
+
if (!Array.isArray(content)) return content;
|
|
138
|
+
return content.map((block) => {
|
|
139
|
+
if (!isImageBlock(block)) return block;
|
|
140
|
+
return summaryBlock(summaries.get(imageKey(block)) ?? "Image summary unavailable.");
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function buildReportDetails(
|
|
145
|
+
images: ImageOccurrence[],
|
|
146
|
+
summaries: Map<string, string>,
|
|
147
|
+
): ImageCompressionReportDetails {
|
|
148
|
+
const items = [...summaries.values()].map((summary, index) => ({ index: index + 1, summary }));
|
|
149
|
+
return { imageCount: images.length, summaryCount: summaries.size, items };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function appendTransformedEntry(
|
|
153
|
+
sessionManager: unknown,
|
|
154
|
+
entry: SessionEntryLike,
|
|
155
|
+
summaries: Map<string, string>,
|
|
156
|
+
) {
|
|
157
|
+
const manager = sessionManager as {
|
|
158
|
+
appendThinkingLevelChange(level: string): void;
|
|
159
|
+
appendModelChange(provider: string, modelId: string): void;
|
|
160
|
+
appendCustomEntry(customType: string, data?: unknown): void;
|
|
161
|
+
appendSessionInfo(name: string): void;
|
|
162
|
+
appendCustomMessageEntry(
|
|
163
|
+
customType: string,
|
|
164
|
+
content: string | ContentBlock[],
|
|
165
|
+
display: boolean,
|
|
166
|
+
details?: unknown,
|
|
167
|
+
): void;
|
|
168
|
+
appendMessage(message: Record<string, unknown>): void;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
switch (entry.type) {
|
|
172
|
+
case "thinking_level_change":
|
|
173
|
+
if (entry.thinkingLevel) manager.appendThinkingLevelChange(entry.thinkingLevel);
|
|
174
|
+
return;
|
|
175
|
+
case "model_change":
|
|
176
|
+
if (entry.provider && entry.modelId) manager.appendModelChange(entry.provider, entry.modelId);
|
|
177
|
+
return;
|
|
178
|
+
case "compaction":
|
|
179
|
+
if (entry.summary) {
|
|
180
|
+
manager.appendCustomMessageEntry(
|
|
181
|
+
"paster-image-compress-compaction-summary",
|
|
182
|
+
entry.summary,
|
|
183
|
+
false,
|
|
184
|
+
entry.details,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
return;
|
|
188
|
+
case "branch_summary":
|
|
189
|
+
if (entry.summary) {
|
|
190
|
+
manager.appendCustomMessageEntry(
|
|
191
|
+
"paster-image-compress-branch-summary",
|
|
192
|
+
entry.summary,
|
|
193
|
+
false,
|
|
194
|
+
entry.details,
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
return;
|
|
198
|
+
case "custom":
|
|
199
|
+
if (entry.customType) manager.appendCustomEntry(entry.customType, entry.data);
|
|
200
|
+
return;
|
|
201
|
+
case "session_info":
|
|
202
|
+
if (typeof entry.name === "string") {
|
|
203
|
+
manager.appendSessionInfo(entry.name);
|
|
204
|
+
}
|
|
205
|
+
return;
|
|
206
|
+
case "custom_message":
|
|
207
|
+
if (entry.customType === "paster-preview") return;
|
|
208
|
+
if (entry.customType && entry.content !== undefined) {
|
|
209
|
+
manager.appendCustomMessageEntry(
|
|
210
|
+
entry.customType,
|
|
211
|
+
transformContent(entry.content, summaries) ?? "",
|
|
212
|
+
entry.display ?? true,
|
|
213
|
+
entry.details,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
return;
|
|
217
|
+
case "message":
|
|
218
|
+
if (entry.message) {
|
|
219
|
+
manager.appendMessage({
|
|
220
|
+
...entry.message,
|
|
221
|
+
content: transformContent(entry.message.content, summaries),
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
return;
|
|
225
|
+
default:
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function resolveCommandOptions(args: string, config: ResolvedImageCompressionConfig) {
|
|
231
|
+
const model = args.trim() || config.model;
|
|
232
|
+
return { ...config, model };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function setCompressionProgress(
|
|
236
|
+
ctx: ExtensionCommandContext,
|
|
237
|
+
current: number,
|
|
238
|
+
total: number,
|
|
239
|
+
model: string,
|
|
240
|
+
): void {
|
|
241
|
+
if (!ctx.hasUI) return;
|
|
242
|
+
const progress = `Summarizing image ${current}/${total}`;
|
|
243
|
+
ctx.ui.setStatus("paster-image-compress", progress);
|
|
244
|
+
ctx.ui.setWidget(
|
|
245
|
+
"paster-image-compress-progress",
|
|
246
|
+
[`paster: ${progress}`, model ? `model: ${model}` : "model: pi default"],
|
|
247
|
+
{ placement: "aboveEditor" },
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function clearCompressionProgress(ctx: ExtensionCommandContext): void {
|
|
252
|
+
if (!ctx.hasUI) return;
|
|
253
|
+
ctx.ui.setStatus("paster-image-compress", undefined);
|
|
254
|
+
ctx.ui.setWidget("paster-image-compress-progress", undefined, { placement: "aboveEditor" });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function registerImageCompressionCommand(
|
|
258
|
+
pi: ExtensionAPI,
|
|
259
|
+
config: ResolvedImageCompressionConfig,
|
|
260
|
+
): void {
|
|
261
|
+
if (!config.enabled) return;
|
|
262
|
+
|
|
263
|
+
pi.registerCommand(config.command, {
|
|
264
|
+
description:
|
|
265
|
+
"Summarize images in the current branch and switch to a new session where image blocks are replaced with text summaries.",
|
|
266
|
+
handler: async (args, ctx): Promise<void> => {
|
|
267
|
+
await ctx.waitForIdle();
|
|
268
|
+
const commandConfig = resolveCommandOptions(args, config);
|
|
269
|
+
const branch = ctx.sessionManager.getBranch() as SessionEntryLike[];
|
|
270
|
+
const images = collectImages(branch);
|
|
271
|
+
if (images.length === 0) {
|
|
272
|
+
if (ctx.hasUI) ctx.ui.notify("No images found in the current branch", "warning");
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const uniqueImages = [...new Map(images.map((item) => [item.key, item.image])).entries()];
|
|
277
|
+
const summaries = new Map<string, string>();
|
|
278
|
+
|
|
279
|
+
if (ctx.hasUI) {
|
|
280
|
+
ctx.ui.notify(`Summarizing ${uniqueImages.length} image(s)...`, "info");
|
|
281
|
+
}
|
|
282
|
+
setCompressionProgress(ctx, 0, uniqueImages.length, commandConfig.model);
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
for (let index = 0; index < uniqueImages.length; index++) {
|
|
286
|
+
const [key, image] = uniqueImages[index]!;
|
|
287
|
+
setCompressionProgress(ctx, index + 1, uniqueImages.length, commandConfig.model);
|
|
288
|
+
summaries.set(key, await summarizeImage(pi, ctx, image, commandConfig));
|
|
289
|
+
}
|
|
290
|
+
} catch (error) {
|
|
291
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
292
|
+
const modelHint = commandConfig.model || "pi's default model";
|
|
293
|
+
const message = `Image compression failed while using ${modelHint}. ${reason}\n\nTo try another model once, run /${commandConfig.command} provider/model. To change the default, configure pi-paster's imageCompression.model option in your wrapper extension. See https://github.com/beowulf11/pi-paster#configuration.`;
|
|
294
|
+
if (ctx.hasUI) ctx.ui.notify(message, "error");
|
|
295
|
+
else console.error(message);
|
|
296
|
+
return;
|
|
297
|
+
} finally {
|
|
298
|
+
clearCompressionProgress(ctx);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const parentSession = ctx.sessionManager.getSessionFile();
|
|
302
|
+
const reportDetails = buildReportDetails(images, summaries);
|
|
303
|
+
await ctx.newSession({
|
|
304
|
+
parentSession,
|
|
305
|
+
setup: async (sessionManager) => {
|
|
306
|
+
for (const entry of branch) appendTransformedEntry(sessionManager, entry, summaries);
|
|
307
|
+
if (commandConfig.includeReport) {
|
|
308
|
+
sessionManager.appendCustomMessageEntry(
|
|
309
|
+
"paster-image-compress-report",
|
|
310
|
+
"",
|
|
311
|
+
true,
|
|
312
|
+
reportDetails,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
},
|
|
316
|
+
withSession: async (newCtx) => {
|
|
317
|
+
if (newCtx.hasUI) {
|
|
318
|
+
newCtx.ui.notify(
|
|
319
|
+
`Compressed ${images.length} image block(s) into text summaries`,
|
|
320
|
+
"info",
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
return;
|
|
327
|
+
},
|
|
328
|
+
});
|
|
329
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -1,4 +1,39 @@
|
|
|
1
|
+
export type SubmittedPreviewStyle = "raw" | "collapsible";
|
|
2
|
+
|
|
3
|
+
export interface ImageCompressionConfig {
|
|
4
|
+
/** Enable the image compression slash command. */
|
|
5
|
+
enabled?: boolean;
|
|
6
|
+
/** Slash command name without the leading slash. */
|
|
7
|
+
command?: string;
|
|
8
|
+
/** Model passed to pi for image summarization. Pass an empty string to use pi's default model. */
|
|
9
|
+
model?: string;
|
|
10
|
+
/** Prompt used to summarize each image. */
|
|
11
|
+
prompt?: string;
|
|
12
|
+
/** pi executable used for summarization subprocesses. */
|
|
13
|
+
piCommand?: string;
|
|
14
|
+
/** Per-image summarization timeout in milliseconds. */
|
|
15
|
+
timeoutMs?: number;
|
|
16
|
+
/** Add a visible, collapsible UI report after compression. */
|
|
17
|
+
includeReport?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface ResolvedImageCompressionConfig {
|
|
21
|
+
enabled: boolean;
|
|
22
|
+
command: string;
|
|
23
|
+
model: string;
|
|
24
|
+
prompt: string;
|
|
25
|
+
piCommand: string;
|
|
26
|
+
timeoutMs: number;
|
|
27
|
+
includeReport: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
1
30
|
export interface PasterConfig {
|
|
31
|
+
/** How submitted attachment previews render in chat history. */
|
|
32
|
+
submittedPreviewStyle?: SubmittedPreviewStyle;
|
|
33
|
+
/** Append local image paths to the submitted prompt so the agent can manipulate the source files. */
|
|
34
|
+
includeImagePathsInPrompt?: boolean;
|
|
35
|
+
/** Configure the /image-compress command. */
|
|
36
|
+
imageCompression?: ImageCompressionConfig;
|
|
2
37
|
customEditor?: {
|
|
3
38
|
/** Replace pi's input editor to enable inline image UX features. */
|
|
4
39
|
enabled?: boolean;
|
|
@@ -10,6 +45,9 @@ export interface PasterConfig {
|
|
|
10
45
|
}
|
|
11
46
|
|
|
12
47
|
export interface ResolvedPasterConfig {
|
|
48
|
+
submittedPreviewStyle: SubmittedPreviewStyle;
|
|
49
|
+
includeImagePathsInPrompt: boolean;
|
|
50
|
+
imageCompression: ResolvedImageCompressionConfig;
|
|
13
51
|
customEditor: {
|
|
14
52
|
enabled: boolean;
|
|
15
53
|
showImagePreview: boolean;
|
|
@@ -18,6 +56,18 @@ export interface ResolvedPasterConfig {
|
|
|
18
56
|
}
|
|
19
57
|
|
|
20
58
|
export const DEFAULT_PASTER_CONFIG: ResolvedPasterConfig = {
|
|
59
|
+
submittedPreviewStyle: "raw",
|
|
60
|
+
includeImagePathsInPrompt: true,
|
|
61
|
+
imageCompression: {
|
|
62
|
+
enabled: true,
|
|
63
|
+
command: "image-compress",
|
|
64
|
+
model: "openai-codex/gpt-5.4-mini",
|
|
65
|
+
prompt:
|
|
66
|
+
"Summarize this image in 2-4 concise sentences. Include important visible text, UI elements, errors, diagrams, and details that may matter for future coding or design work.",
|
|
67
|
+
piCommand: "pi",
|
|
68
|
+
timeoutMs: 120_000,
|
|
69
|
+
includeReport: true,
|
|
70
|
+
},
|
|
21
71
|
customEditor: {
|
|
22
72
|
enabled: true,
|
|
23
73
|
showImagePreview: true,
|
|
@@ -27,6 +77,23 @@ export const DEFAULT_PASTER_CONFIG: ResolvedPasterConfig = {
|
|
|
27
77
|
|
|
28
78
|
export function resolvePasterConfig(config: PasterConfig = {}): ResolvedPasterConfig {
|
|
29
79
|
return {
|
|
80
|
+
submittedPreviewStyle:
|
|
81
|
+
config.submittedPreviewStyle ?? DEFAULT_PASTER_CONFIG.submittedPreviewStyle,
|
|
82
|
+
includeImagePathsInPrompt:
|
|
83
|
+
config.includeImagePathsInPrompt ?? DEFAULT_PASTER_CONFIG.includeImagePathsInPrompt,
|
|
84
|
+
imageCompression: {
|
|
85
|
+
enabled: config.imageCompression?.enabled ?? DEFAULT_PASTER_CONFIG.imageCompression.enabled,
|
|
86
|
+
command: config.imageCompression?.command ?? DEFAULT_PASTER_CONFIG.imageCompression.command,
|
|
87
|
+
model: config.imageCompression?.model ?? DEFAULT_PASTER_CONFIG.imageCompression.model,
|
|
88
|
+
prompt: config.imageCompression?.prompt ?? DEFAULT_PASTER_CONFIG.imageCompression.prompt,
|
|
89
|
+
piCommand:
|
|
90
|
+
config.imageCompression?.piCommand ?? DEFAULT_PASTER_CONFIG.imageCompression.piCommand,
|
|
91
|
+
timeoutMs:
|
|
92
|
+
config.imageCompression?.timeoutMs ?? DEFAULT_PASTER_CONFIG.imageCompression.timeoutMs,
|
|
93
|
+
includeReport:
|
|
94
|
+
config.imageCompression?.includeReport ??
|
|
95
|
+
DEFAULT_PASTER_CONFIG.imageCompression.includeReport,
|
|
96
|
+
},
|
|
30
97
|
customEditor: {
|
|
31
98
|
enabled: config.customEditor?.enabled ?? DEFAULT_PASTER_CONFIG.customEditor.enabled,
|
|
32
99
|
showImagePreview:
|
package/src/editor.ts
CHANGED
|
@@ -7,6 +7,59 @@ import type { ImageAttachment } from "./types.ts";
|
|
|
7
7
|
export const PASTE_START = "\x1b[200~";
|
|
8
8
|
export const PASTE_END = "\x1b[201~";
|
|
9
9
|
const PLACEHOLDER_REGEX = /\[#image \d+\]/g;
|
|
10
|
+
const PASTE_MARKER_REGEX = /\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]/g;
|
|
11
|
+
const baseSegmenter = new Intl.Segmenter();
|
|
12
|
+
|
|
13
|
+
interface AtomicSpan {
|
|
14
|
+
start: number;
|
|
15
|
+
end: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface EditorSegmentationAccess {
|
|
19
|
+
segment?: (text: string) => Iterable<Intl.SegmentData>;
|
|
20
|
+
pastes?: Map<number, string>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function atomicSpansForText(text: string, validPasteIds: Set<number>): AtomicSpan[] {
|
|
24
|
+
const spans: AtomicSpan[] = [];
|
|
25
|
+
|
|
26
|
+
for (const match of text.matchAll(PASTE_MARKER_REGEX)) {
|
|
27
|
+
const id = Number.parseInt(match[1]!, 10);
|
|
28
|
+
if (!validPasteIds.has(id)) continue;
|
|
29
|
+
spans.push({ start: match.index, end: match.index + match[0].length });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
for (const match of text.matchAll(PLACEHOLDER_REGEX)) {
|
|
33
|
+
const placeholder = match[0];
|
|
34
|
+
spans.push({ start: match.index, end: match.index + placeholder.length });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return spans.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function segmentTextWithAtomicImages(
|
|
41
|
+
text: string,
|
|
42
|
+
store: AttachmentStore,
|
|
43
|
+
validPasteIds: Set<number> = new Set(),
|
|
44
|
+
): Intl.SegmentData[] {
|
|
45
|
+
const spans = atomicSpansForText(text, validPasteIds);
|
|
46
|
+
if (spans.length === 0) return [...baseSegmenter.segment(text)];
|
|
47
|
+
|
|
48
|
+
const result: Intl.SegmentData[] = [];
|
|
49
|
+
let spanIndex = 0;
|
|
50
|
+
for (const segment of baseSegmenter.segment(text)) {
|
|
51
|
+
while (spanIndex < spans.length && spans[spanIndex]!.end <= segment.index) spanIndex++;
|
|
52
|
+
const span = spans[spanIndex];
|
|
53
|
+
if (span && segment.index >= span.start && segment.index < span.end) {
|
|
54
|
+
if (segment.index === span.start) {
|
|
55
|
+
result.push({ segment: text.slice(span.start, span.end), index: span.start, input: text });
|
|
56
|
+
}
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
result.push(segment);
|
|
60
|
+
}
|
|
61
|
+
return result;
|
|
62
|
+
}
|
|
10
63
|
|
|
11
64
|
interface EditorCursor {
|
|
12
65
|
line: number;
|
|
@@ -14,7 +67,8 @@ interface EditorCursor {
|
|
|
14
67
|
}
|
|
15
68
|
|
|
16
69
|
interface PlaceholderAtCursor {
|
|
17
|
-
attachment
|
|
70
|
+
attachment?: ImageAttachment;
|
|
71
|
+
placeholder: string;
|
|
18
72
|
line: number;
|
|
19
73
|
start: number;
|
|
20
74
|
end: number;
|
|
@@ -32,16 +86,16 @@ function findPlaceholderAtCursor(
|
|
|
32
86
|
const start = match.index;
|
|
33
87
|
const end = start + placeholder.length;
|
|
34
88
|
const attachment = store.get(placeholder);
|
|
35
|
-
if (!attachment) continue;
|
|
89
|
+
if (!attachment && mode !== "hover") continue;
|
|
36
90
|
|
|
37
91
|
if (mode === "hover" && cursor.col >= start && cursor.col < end) {
|
|
38
|
-
return { attachment, line: cursor.line, start, end };
|
|
92
|
+
return { attachment, placeholder, line: cursor.line, start, end };
|
|
39
93
|
}
|
|
40
94
|
if (mode === "backspace" && cursor.col > start && cursor.col <= end) {
|
|
41
|
-
return { attachment, line: cursor.line, start, end };
|
|
95
|
+
return { attachment, placeholder, line: cursor.line, start, end };
|
|
42
96
|
}
|
|
43
97
|
if (mode === "delete" && cursor.col >= start && cursor.col < end) {
|
|
44
|
-
return { attachment, line: cursor.line, start, end };
|
|
98
|
+
return { attachment, placeholder, line: cursor.line, start, end };
|
|
45
99
|
}
|
|
46
100
|
}
|
|
47
101
|
return undefined;
|
|
@@ -76,6 +130,7 @@ export class PasterEditor extends CustomEditor {
|
|
|
76
130
|
},
|
|
77
131
|
) {
|
|
78
132
|
super(tui, theme, pasterKeybindings);
|
|
133
|
+
this.installAtomicImageSegmentation();
|
|
79
134
|
this.onPasteImage = () => {
|
|
80
135
|
void this.handlePasteClipboardImage();
|
|
81
136
|
};
|
|
@@ -89,6 +144,7 @@ export class PasterEditor extends CustomEditor {
|
|
|
89
144
|
|
|
90
145
|
override handleInput(data: string): void {
|
|
91
146
|
if (this.handleBracketedPaste(data)) return;
|
|
147
|
+
if (this.handleAtomicPlaceholderNavigation(data)) return;
|
|
92
148
|
if (this.pasterOptions.deletePlaceholderAsBlock && this.handleAtomicPlaceholderDelete(data))
|
|
93
149
|
return;
|
|
94
150
|
|
|
@@ -101,6 +157,16 @@ export class PasterEditor extends CustomEditor {
|
|
|
101
157
|
this.pasterOptions.setCursorPreview(undefined);
|
|
102
158
|
}
|
|
103
159
|
|
|
160
|
+
private installAtomicImageSegmentation(): void {
|
|
161
|
+
const editor = this as unknown as EditorSegmentationAccess;
|
|
162
|
+
editor.segment = (text: string) =>
|
|
163
|
+
segmentTextWithAtomicImages(
|
|
164
|
+
text,
|
|
165
|
+
this.pasterOptions.store,
|
|
166
|
+
new Set(editor.pastes?.keys() ?? []),
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
104
170
|
private async handlePasteClipboardImage(): Promise<void> {
|
|
105
171
|
const attachment = await this.pasterOptions.pasteClipboardImage?.();
|
|
106
172
|
if (!attachment) return;
|
|
@@ -149,6 +215,29 @@ export class PasterEditor extends CustomEditor {
|
|
|
149
215
|
return true;
|
|
150
216
|
}
|
|
151
217
|
|
|
218
|
+
private handleAtomicPlaceholderNavigation(data: string): boolean {
|
|
219
|
+
const isLeft = this.pasterKeybindings.matches(data, "tui.editor.cursorLeft");
|
|
220
|
+
const isRight = this.pasterKeybindings.matches(data, "tui.editor.cursorRight");
|
|
221
|
+
if (!isLeft && !isRight) return false;
|
|
222
|
+
|
|
223
|
+
const line = this.getLines()[this.getCursor().line] ?? "";
|
|
224
|
+
const cursor = this.getCursor();
|
|
225
|
+
const matches = [...line.matchAll(PLACEHOLDER_REGEX)];
|
|
226
|
+
const target = isRight
|
|
227
|
+
? matches.find(
|
|
228
|
+
(match) => cursor.col >= match.index && cursor.col < match.index + match[0].length,
|
|
229
|
+
)
|
|
230
|
+
: matches.find(
|
|
231
|
+
(match) => cursor.col > match.index && cursor.col <= match.index + match[0].length,
|
|
232
|
+
);
|
|
233
|
+
if (!target) return false;
|
|
234
|
+
|
|
235
|
+
this.setCursor(target.index + (isRight ? target[0].length : 0));
|
|
236
|
+
this.updateCursorPreview();
|
|
237
|
+
this.tui.requestRender();
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
|
|
152
241
|
private handleAtomicPlaceholderDelete(data: string): boolean {
|
|
153
242
|
const isBackspace = this.pasterKeybindings.matches(data, "tui.editor.deleteCharBackward");
|
|
154
243
|
const isDelete = this.pasterKeybindings.matches(data, "tui.editor.deleteCharForward");
|
|
@@ -168,17 +257,22 @@ export class PasterEditor extends CustomEditor {
|
|
|
168
257
|
return true;
|
|
169
258
|
}
|
|
170
259
|
|
|
260
|
+
private setCursor(col: number): void {
|
|
261
|
+
const editor = this as unknown as EditorStateAccess;
|
|
262
|
+
if (editor.setCursorCol) {
|
|
263
|
+
editor.setCursorCol(col);
|
|
264
|
+
} else {
|
|
265
|
+
editor.state.cursorCol = col;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
171
269
|
private deleteLineRange(lineIndex: number, start: number, end: number): void {
|
|
172
270
|
const editor = this as unknown as EditorStateAccess;
|
|
173
271
|
editor.pushUndoSnapshot?.();
|
|
174
272
|
const line = editor.state.lines[lineIndex] ?? "";
|
|
175
273
|
editor.state.lines[lineIndex] = line.slice(0, start) + line.slice(end);
|
|
176
274
|
editor.state.cursorLine = lineIndex;
|
|
177
|
-
|
|
178
|
-
editor.setCursorCol(start);
|
|
179
|
-
} else {
|
|
180
|
-
editor.state.cursorCol = start;
|
|
181
|
-
}
|
|
275
|
+
this.setCursor(start);
|
|
182
276
|
editor.lastAction = null;
|
|
183
277
|
editor.historyIndex = -1;
|
|
184
278
|
this.onChange?.(this.getText());
|
|
@@ -200,7 +294,7 @@ export class PasterEditor extends CustomEditor {
|
|
|
200
294
|
this.getCursor(),
|
|
201
295
|
"hover",
|
|
202
296
|
);
|
|
203
|
-
const nextPlaceholder = target?.attachment
|
|
297
|
+
const nextPlaceholder = target?.attachment?.placeholder;
|
|
204
298
|
if (nextPlaceholder === this.activePreviewPlaceholder) return;
|
|
205
299
|
this.activePreviewPlaceholder = nextPlaceholder;
|
|
206
300
|
this.pasterOptions.setCursorPreview(target?.attachment);
|
package/src/image-utils.ts
CHANGED
|
@@ -357,6 +357,14 @@ export function imagesForText(
|
|
|
357
357
|
];
|
|
358
358
|
}
|
|
359
359
|
|
|
360
|
+
export function appendImagePathContext(text: string, attachments: ImageAttachment[]): string {
|
|
361
|
+
if (attachments.length === 0) return text;
|
|
362
|
+
const lines = attachments.map(
|
|
363
|
+
(attachment) => `- ${attachment.placeholder}: ${attachment.originalPath}`,
|
|
364
|
+
);
|
|
365
|
+
return `${text}\n\nAttached image paths:\n${lines.join("\n")}`;
|
|
366
|
+
}
|
|
367
|
+
|
|
360
368
|
/**
|
|
361
369
|
* Async variant of imagesForText that runs each attachment through the
|
|
362
370
|
* Anthropic-aware image optimizer (resize to 8000px cap, JPEG ladder to stay
|