pi-paster 0.2.1 → 0.2.3
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 +38 -7
- package/dist/index.d.mts +56 -2
- package/dist/index.mjs +248 -7
- package/package.json +2 -2
- package/src/compress.ts +329 -0
- package/src/config.ts +53 -0
- package/src/editor.ts +8 -5
- package/src/index.ts +30 -2
- package/src/preview.ts +42 -1
- package/src/types.ts +11 -0
package/README.md
CHANGED
|
@@ -30,6 +30,7 @@ Terminal image workflows are awkward: dragging a screenshot into a terminal usua
|
|
|
30
30
|
- Attaches only placeholders still present in the submitted prompt.
|
|
31
31
|
- Preserves attachment order by first placeholder occurrence.
|
|
32
32
|
- Shows submitted image previews in chat history.
|
|
33
|
+
- Provides `/image-compress` to fork the current branch into a new session where image blocks are replaced with text summaries.
|
|
33
34
|
- Optional custom editor integration:
|
|
34
35
|
- cursor-based image preview above the input
|
|
35
36
|
- atomic deletion of whole image placeholders
|
|
@@ -79,6 +80,22 @@ What is wrong in this screenshot? [#image 1]
|
|
|
79
80
|
|
|
80
81
|
On submit, the text and matching image attachment are sent together.
|
|
81
82
|
|
|
83
|
+
## Image compression command
|
|
84
|
+
|
|
85
|
+
Run `/image-compress` to summarize every image block in the current conversation branch and switch to a new session where those image blocks are replaced by text summaries.
|
|
86
|
+
|
|
87
|
+
This is intentionally different from summarizing or compacting the whole session. Sometimes the text/tool context is still useful as-is, but screenshots are making the context heavy. Image compression preserves the same branch shape and surrounding conversation while pruning expensive image blocks into concise descriptions, so the agent keeps the relevant context without carrying every original image.
|
|
88
|
+
|
|
89
|
+
The original session is not modified. Paster copies the active branch into a new session, replaces image blocks during the copy, and links the new session back to the original as its parent. By default, paster also adds a visible collapsible compression report for the user; the report details are stored outside model context.
|
|
90
|
+
|
|
91
|
+
By default the command uses a pi subprocess with `openai-codex/gpt-5.4-mini` and a short 2-4 sentence summarization prompt. If that model is not configured or lacks credentials, paster shows a setup hint and you can configure a different summarization model. You can pass a one-off model after the command:
|
|
92
|
+
|
|
93
|
+
```text
|
|
94
|
+
/image-compress openrouter/google/gemini-2.5-flash
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
No extra runtime dependencies are used; the command shells out to the installed `pi` executable.
|
|
98
|
+
|
|
82
99
|
## Clipboard image paste
|
|
83
100
|
|
|
84
101
|
On macOS, pi exposes an image paste action through its keybinding system. In the default pi keybindings this is `Ctrl+V`.
|
|
@@ -97,6 +114,13 @@ import { createPaster } from "pi-paster";
|
|
|
97
114
|
export default createPaster({
|
|
98
115
|
submittedPreviewStyle: "raw",
|
|
99
116
|
includeImagePathsInPrompt: true,
|
|
117
|
+
imageCompression: {
|
|
118
|
+
enabled: true,
|
|
119
|
+
command: "image-compress",
|
|
120
|
+
model: "openai-codex/gpt-5.4-mini",
|
|
121
|
+
prompt: "Summarize this image in 2-4 concise sentences.",
|
|
122
|
+
includeReport: true,
|
|
123
|
+
},
|
|
100
124
|
customEditor: {
|
|
101
125
|
enabled: true,
|
|
102
126
|
showImagePreview: true,
|
|
@@ -107,13 +131,20 @@ export default createPaster({
|
|
|
107
131
|
|
|
108
132
|
### Options
|
|
109
133
|
|
|
110
|
-
| Option | Default
|
|
111
|
-
| --------------------------------------- |
|
|
112
|
-
| `submittedPreviewStyle` | `"raw"`
|
|
113
|
-
| `includeImagePathsInPrompt` | `true`
|
|
114
|
-
| `
|
|
115
|
-
| `
|
|
116
|
-
| `
|
|
134
|
+
| Option | Default | Description |
|
|
135
|
+
| --------------------------------------- | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
|
|
136
|
+
| `submittedPreviewStyle` | `"raw"` | How submitted image previews render in chat history. Use `"collapsible"` to wrap them in pi's ctrl+o expandable/collapsible message UI. |
|
|
137
|
+
| `includeImagePathsInPrompt` | `true` | Appends placeholder-to-local-path mappings to the submitted prompt so the agent can manipulate the source image files when asked. |
|
|
138
|
+
| `imageCompression.enabled` | `true` | Registers the image compression command. |
|
|
139
|
+
| `imageCompression.command` | `"image-compress"` | Slash command name without the leading slash. |
|
|
140
|
+
| `imageCompression.model` | `"openai-codex/gpt-5.4-mini"` | Model passed to pi for per-image summarization. Set to `""` to use pi's default model. |
|
|
141
|
+
| `imageCompression.prompt` | built-in | Prompt used to summarize each image. |
|
|
142
|
+
| `imageCompression.piCommand` | `"pi"` | pi executable used for summarization subprocesses. |
|
|
143
|
+
| `imageCompression.timeoutMs` | `120000` | Per-image summarization timeout in milliseconds. |
|
|
144
|
+
| `imageCompression.includeReport` | `true` | Adds a visible collapsible compression report after the copied compressed branch. Report details are not sent to the agent. |
|
|
145
|
+
| `customEditor.enabled` | `true` | Replaces pi's input editor with paster's editor integration. Disable this to keep pi's default editor. |
|
|
146
|
+
| `customEditor.showImagePreview` | `true` | Shows an image preview above the input when the cursor is inside an image placeholder. Requires `customEditor.enabled`. |
|
|
147
|
+
| `customEditor.deletePlaceholderAsBlock` | `true` | Makes backspace/delete remove the whole placeholder when editing inside or adjacent to it. Requires `customEditor.enabled`. |
|
|
117
148
|
|
|
118
149
|
When `customEditor.enabled` is `false`, paster still handles bracketed terminal paste/drop image paths, but cursor previews, atomic placeholder deletion, and paster's clipboard-image handler are disabled.
|
|
119
150
|
|
package/dist/index.d.mts
CHANGED
|
@@ -4,11 +4,38 @@ import { CustomEditor, ExtensionAPI, KeybindingsManager } from "@earendil-works/
|
|
|
4
4
|
|
|
5
5
|
//#region src/config.d.ts
|
|
6
6
|
type SubmittedPreviewStyle = "raw" | "collapsible";
|
|
7
|
+
interface ImageCompressionConfig {
|
|
8
|
+
/** Enable the image compression slash command. */
|
|
9
|
+
enabled?: boolean;
|
|
10
|
+
/** Slash command name without the leading slash. */
|
|
11
|
+
command?: string;
|
|
12
|
+
/** Model passed to pi for image summarization. Pass an empty string to use pi's default model. */
|
|
13
|
+
model?: string;
|
|
14
|
+
/** Prompt used to summarize each image. */
|
|
15
|
+
prompt?: string;
|
|
16
|
+
/** pi executable used for summarization subprocesses. */
|
|
17
|
+
piCommand?: string;
|
|
18
|
+
/** Per-image summarization timeout in milliseconds. */
|
|
19
|
+
timeoutMs?: number;
|
|
20
|
+
/** Add a visible, collapsible UI report after compression. */
|
|
21
|
+
includeReport?: boolean;
|
|
22
|
+
}
|
|
23
|
+
interface ResolvedImageCompressionConfig {
|
|
24
|
+
enabled: boolean;
|
|
25
|
+
command: string;
|
|
26
|
+
model: string;
|
|
27
|
+
prompt: string;
|
|
28
|
+
piCommand: string;
|
|
29
|
+
timeoutMs: number;
|
|
30
|
+
includeReport: boolean;
|
|
31
|
+
}
|
|
7
32
|
interface PasterConfig {
|
|
8
33
|
/** How submitted attachment previews render in chat history. */
|
|
9
34
|
submittedPreviewStyle?: SubmittedPreviewStyle;
|
|
10
35
|
/** Append local image paths to the submitted prompt so the agent can manipulate the source files. */
|
|
11
36
|
includeImagePathsInPrompt?: boolean;
|
|
37
|
+
/** Configure the /image-compress command. */
|
|
38
|
+
imageCompression?: ImageCompressionConfig;
|
|
12
39
|
customEditor?: {
|
|
13
40
|
/** Replace pi's input editor to enable inline image UX features. */enabled?: boolean; /** Show an image preview above the input while the cursor is inside an image placeholder. */
|
|
14
41
|
showImagePreview?: boolean; /** Treat image placeholders as atomic blocks for backspace/delete. */
|
|
@@ -18,6 +45,7 @@ interface PasterConfig {
|
|
|
18
45
|
interface ResolvedPasterConfig {
|
|
19
46
|
submittedPreviewStyle: SubmittedPreviewStyle;
|
|
20
47
|
includeImagePathsInPrompt: boolean;
|
|
48
|
+
imageCompression: ResolvedImageCompressionConfig;
|
|
21
49
|
customEditor: {
|
|
22
50
|
enabled: boolean;
|
|
23
51
|
showImagePreview: boolean;
|
|
@@ -72,6 +100,15 @@ type LoadImageResult = {
|
|
|
72
100
|
interface PasterPreviewDetails {
|
|
73
101
|
placeholders: string[];
|
|
74
102
|
}
|
|
103
|
+
interface ImageCompressionReportItem {
|
|
104
|
+
index: number;
|
|
105
|
+
summary: string;
|
|
106
|
+
}
|
|
107
|
+
interface ImageCompressionReportDetails {
|
|
108
|
+
imageCount: number;
|
|
109
|
+
summaryCount: number;
|
|
110
|
+
items: ImageCompressionReportItem[];
|
|
111
|
+
}
|
|
75
112
|
//#endregion
|
|
76
113
|
//#region src/clipboard.d.ts
|
|
77
114
|
type ClipboardImageResult = {
|
|
@@ -83,6 +120,10 @@ type ClipboardImageResult = {
|
|
|
83
120
|
};
|
|
84
121
|
declare function readClipboardImage(maxBytes?: number): ClipboardImageResult;
|
|
85
122
|
//#endregion
|
|
123
|
+
//#region src/compress.d.ts
|
|
124
|
+
declare const DEFAULT_IMAGE_SUMMARY_PROMPT = "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.";
|
|
125
|
+
declare function registerImageCompressionCommand(pi: ExtensionAPI, config: ResolvedImageCompressionConfig): void;
|
|
126
|
+
//#endregion
|
|
86
127
|
//#region src/optimize-image.d.ts
|
|
87
128
|
interface OptimizeResult {
|
|
88
129
|
data: string;
|
|
@@ -116,7 +157,7 @@ declare class AttachmentStore {
|
|
|
116
157
|
//#region src/editor.d.ts
|
|
117
158
|
declare const PASTE_START = "\u001B[200~";
|
|
118
159
|
declare const PASTE_END = "\u001B[201~";
|
|
119
|
-
declare function segmentTextWithAtomicImages(text: string, store: AttachmentStore, validPasteIds?: Set<number
|
|
160
|
+
declare function segmentTextWithAtomicImages(text: string, store: AttachmentStore, validPasteIds?: Set<number>, segmenter?: Intl.Segmenter): Intl.SegmentData[];
|
|
120
161
|
declare class PasterEditor extends CustomEditor {
|
|
121
162
|
private readonly pasterKeybindings;
|
|
122
163
|
private readonly pasterOptions;
|
|
@@ -212,6 +253,19 @@ declare class ImagePreviewMessage implements Component {
|
|
|
212
253
|
private renderRaw;
|
|
213
254
|
private renderCollapsible;
|
|
214
255
|
}
|
|
256
|
+
interface CompressionReportTheme {
|
|
257
|
+
background?: (text: string) => string;
|
|
258
|
+
title: (text: string) => string;
|
|
259
|
+
muted: (text: string) => string;
|
|
260
|
+
}
|
|
261
|
+
declare class ImageCompressionReportMessage implements Component {
|
|
262
|
+
private readonly details;
|
|
263
|
+
private readonly theme;
|
|
264
|
+
private readonly expanded;
|
|
265
|
+
constructor(details: ImageCompressionReportDetails, theme: CompressionReportTheme, expanded?: boolean);
|
|
266
|
+
render(width: number): string[];
|
|
267
|
+
invalidate(): void;
|
|
268
|
+
}
|
|
215
269
|
interface CursorPreviewTheme {
|
|
216
270
|
title: (text: string) => string;
|
|
217
271
|
muted: (text: string) => string;
|
|
@@ -246,4 +300,4 @@ declare function createImagePasteTerminalInputHandler(options: {
|
|
|
246
300
|
declare function createPaster(config?: PasterConfig): (pi: ExtensionAPI) => void;
|
|
247
301
|
declare function paster(pi: ExtensionAPI, config?: PasterConfig): void;
|
|
248
302
|
//#endregion
|
|
249
|
-
export { ANTHROPIC_MAX_DIMENSION, ANTHROPIC_MAX_IMAGE_BYTES, AttachmentStore, ClipboardImageResult, CursorImagePreviewWidget, DEFAULT_PASTER_CONFIG, EXTENSION_NAME, ImageAttachment, ImagePreviewMessage, ImagePreviewMessageStyle, LoadImageResult, LoadedImage, MAX_IMAGE_BYTES, OptimizeResult, PASTE_END, PASTE_START, PasterConfig, PasterEditor, PasterImageContent, PasterPreviewDetails, ResolvedPasterConfig, SubmittedPreviewStyle, SupportedImageMimeType, TerminalInputResult, appendImagePathContext, createImagePasteTerminalInputHandler, createPaster, paster as default, describeReject, detectImageMimeType, dimensionsForImage, imagesForText, imagesForTextOptimized, isWindowsDrivePath, isWindowsLikePath, isWindowsUncPath, isWsl, loadImageFromPath, optimizeImageBytes, readClipboardImage, replaceImagePathsInText, resolveImagePath, resolvePasterConfig, segmentTextWithAtomicImages, shellUnescape, tokenizePathLikeText, windowsToWslPath };
|
|
303
|
+
export { ANTHROPIC_MAX_DIMENSION, ANTHROPIC_MAX_IMAGE_BYTES, AttachmentStore, ClipboardImageResult, CursorImagePreviewWidget, DEFAULT_IMAGE_SUMMARY_PROMPT, DEFAULT_PASTER_CONFIG, EXTENSION_NAME, ImageAttachment, ImageCompressionConfig, ImageCompressionReportDetails, ImageCompressionReportItem, ImageCompressionReportMessage, ImagePreviewMessage, ImagePreviewMessageStyle, LoadImageResult, LoadedImage, MAX_IMAGE_BYTES, OptimizeResult, PASTE_END, PASTE_START, PasterConfig, PasterEditor, PasterImageContent, PasterPreviewDetails, ResolvedImageCompressionConfig, ResolvedPasterConfig, SubmittedPreviewStyle, SupportedImageMimeType, TerminalInputResult, appendImagePathContext, createImagePasteTerminalInputHandler, createPaster, paster as default, describeReject, detectImageMimeType, dimensionsForImage, imagesForText, imagesForTextOptimized, isWindowsDrivePath, isWindowsLikePath, isWindowsUncPath, isWsl, loadImageFromPath, optimizeImageBytes, readClipboardImage, registerImageCompressionCommand, replaceImagePathsInText, resolveImagePath, resolvePasterConfig, segmentTextWithAtomicImages, shellUnescape, tokenizePathLikeText, windowsToWslPath };
|
package/dist/index.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { existsSync, readFileSync, statSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
4
4
|
import { homedir, tmpdir } from "node:os";
|
|
5
5
|
import { isAbsolute, join, resolve } from "node:path";
|
|
6
6
|
import { platform } from "node:process";
|
|
@@ -647,10 +647,210 @@ function readMacOSClipboardImage(maxBytes) {
|
|
|
647
647
|
};
|
|
648
648
|
}
|
|
649
649
|
//#endregion
|
|
650
|
+
//#region src/compress.ts
|
|
651
|
+
const DEFAULT_IMAGE_SUMMARY_PROMPT = "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.";
|
|
652
|
+
function isImageBlock(block) {
|
|
653
|
+
return !!block && typeof block === "object" && block.type === "image" && typeof block.data === "string" && typeof block.mimeType === "string";
|
|
654
|
+
}
|
|
655
|
+
function imageKey(image) {
|
|
656
|
+
return `${image.mimeType}:${image.data.length}:${image.data.slice(0, 48)}`;
|
|
657
|
+
}
|
|
658
|
+
function collectImages(entries) {
|
|
659
|
+
const images = [];
|
|
660
|
+
for (const entry of entries) {
|
|
661
|
+
const content = entry.type === "custom_message" ? entry.content : entry.message?.content;
|
|
662
|
+
if (!Array.isArray(content)) continue;
|
|
663
|
+
for (const block of content) {
|
|
664
|
+
if (!isImageBlock(block)) continue;
|
|
665
|
+
images.push({
|
|
666
|
+
key: imageKey(block),
|
|
667
|
+
image: block
|
|
668
|
+
});
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
return images;
|
|
672
|
+
}
|
|
673
|
+
function extensionForMime(mimeType) {
|
|
674
|
+
switch (mimeType) {
|
|
675
|
+
case "image/jpeg": return ".jpg";
|
|
676
|
+
case "image/webp": return ".webp";
|
|
677
|
+
case "image/gif": return ".gif";
|
|
678
|
+
default: return ".png";
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
function normalizeSummary(stdout) {
|
|
682
|
+
return stdout.trim().replace(/\n{3,}/g, "\n\n");
|
|
683
|
+
}
|
|
684
|
+
async function summarizeImage(pi, ctx, image, config) {
|
|
685
|
+
const dir = mkdtempSync(join(tmpdir(), "pi-paster-image-compress-"));
|
|
686
|
+
try {
|
|
687
|
+
const imagePath = join(dir, `image${extensionForMime(image.mimeType)}`);
|
|
688
|
+
writeFileSync(imagePath, Buffer.from(image.data, "base64"));
|
|
689
|
+
const args = [
|
|
690
|
+
"--no-session",
|
|
691
|
+
"--no-extensions",
|
|
692
|
+
"--no-tools",
|
|
693
|
+
"--mode",
|
|
694
|
+
"text",
|
|
695
|
+
"-p"
|
|
696
|
+
];
|
|
697
|
+
if (config.model) args.splice(0, 0, "--model", config.model);
|
|
698
|
+
args.push(`@${imagePath}`, config.prompt);
|
|
699
|
+
const result = await pi.exec(config.piCommand, args, {
|
|
700
|
+
cwd: ctx.cwd,
|
|
701
|
+
timeout: config.timeoutMs
|
|
702
|
+
});
|
|
703
|
+
if (result.code !== 0) {
|
|
704
|
+
const detail = result.stderr.trim() || result.stdout.trim() || `exit code ${result.code}`;
|
|
705
|
+
throw new Error(`image summarization failed: ${detail}`);
|
|
706
|
+
}
|
|
707
|
+
const summary = normalizeSummary(result.stdout);
|
|
708
|
+
if (!summary) throw new Error("image summarization returned an empty response");
|
|
709
|
+
return summary;
|
|
710
|
+
} finally {
|
|
711
|
+
rmSync(dir, {
|
|
712
|
+
recursive: true,
|
|
713
|
+
force: true
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
function summaryBlock(summary) {
|
|
718
|
+
return {
|
|
719
|
+
type: "text",
|
|
720
|
+
text: `Image summary: ${summary}`
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
function transformContent(content, summaries) {
|
|
724
|
+
if (!Array.isArray(content)) return content;
|
|
725
|
+
return content.map((block) => {
|
|
726
|
+
if (!isImageBlock(block)) return block;
|
|
727
|
+
return summaryBlock(summaries.get(imageKey(block)) ?? "Image summary unavailable.");
|
|
728
|
+
});
|
|
729
|
+
}
|
|
730
|
+
function buildReportDetails(images, summaries) {
|
|
731
|
+
const items = [...summaries.values()].map((summary, index) => ({
|
|
732
|
+
index: index + 1,
|
|
733
|
+
summary
|
|
734
|
+
}));
|
|
735
|
+
return {
|
|
736
|
+
imageCount: images.length,
|
|
737
|
+
summaryCount: summaries.size,
|
|
738
|
+
items
|
|
739
|
+
};
|
|
740
|
+
}
|
|
741
|
+
function appendTransformedEntry(sessionManager, entry, summaries) {
|
|
742
|
+
const manager = sessionManager;
|
|
743
|
+
switch (entry.type) {
|
|
744
|
+
case "thinking_level_change":
|
|
745
|
+
if (entry.thinkingLevel) manager.appendThinkingLevelChange(entry.thinkingLevel);
|
|
746
|
+
return;
|
|
747
|
+
case "model_change":
|
|
748
|
+
if (entry.provider && entry.modelId) manager.appendModelChange(entry.provider, entry.modelId);
|
|
749
|
+
return;
|
|
750
|
+
case "compaction":
|
|
751
|
+
if (entry.summary) manager.appendCustomMessageEntry("paster-image-compress-compaction-summary", entry.summary, false, entry.details);
|
|
752
|
+
return;
|
|
753
|
+
case "branch_summary":
|
|
754
|
+
if (entry.summary) manager.appendCustomMessageEntry("paster-image-compress-branch-summary", entry.summary, false, entry.details);
|
|
755
|
+
return;
|
|
756
|
+
case "custom":
|
|
757
|
+
if (entry.customType) manager.appendCustomEntry(entry.customType, entry.data);
|
|
758
|
+
return;
|
|
759
|
+
case "session_info":
|
|
760
|
+
if (typeof entry.name === "string") manager.appendSessionInfo(entry.name);
|
|
761
|
+
return;
|
|
762
|
+
case "custom_message":
|
|
763
|
+
if (entry.customType === "paster-preview") return;
|
|
764
|
+
if (entry.customType && entry.content !== void 0) manager.appendCustomMessageEntry(entry.customType, transformContent(entry.content, summaries) ?? "", entry.display ?? true, entry.details);
|
|
765
|
+
return;
|
|
766
|
+
case "message":
|
|
767
|
+
if (entry.message) manager.appendMessage({
|
|
768
|
+
...entry.message,
|
|
769
|
+
content: transformContent(entry.message.content, summaries)
|
|
770
|
+
});
|
|
771
|
+
return;
|
|
772
|
+
default: return;
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
function resolveCommandOptions(args, config) {
|
|
776
|
+
const model = args.trim() || config.model;
|
|
777
|
+
return {
|
|
778
|
+
...config,
|
|
779
|
+
model
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
function setCompressionProgress(ctx, current, total, model) {
|
|
783
|
+
if (!ctx.hasUI) return;
|
|
784
|
+
const progress = `Summarizing image ${current}/${total}`;
|
|
785
|
+
ctx.ui.setStatus("paster-image-compress", progress);
|
|
786
|
+
ctx.ui.setWidget("paster-image-compress-progress", [`paster: ${progress}`, model ? `model: ${model}` : "model: pi default"], { placement: "aboveEditor" });
|
|
787
|
+
}
|
|
788
|
+
function clearCompressionProgress(ctx) {
|
|
789
|
+
if (!ctx.hasUI) return;
|
|
790
|
+
ctx.ui.setStatus("paster-image-compress", void 0);
|
|
791
|
+
ctx.ui.setWidget("paster-image-compress-progress", void 0, { placement: "aboveEditor" });
|
|
792
|
+
}
|
|
793
|
+
function registerImageCompressionCommand(pi, config) {
|
|
794
|
+
if (!config.enabled) return;
|
|
795
|
+
pi.registerCommand(config.command, {
|
|
796
|
+
description: "Summarize images in the current branch and switch to a new session where image blocks are replaced with text summaries.",
|
|
797
|
+
handler: async (args, ctx) => {
|
|
798
|
+
await ctx.waitForIdle();
|
|
799
|
+
const commandConfig = resolveCommandOptions(args, config);
|
|
800
|
+
const branch = ctx.sessionManager.getBranch();
|
|
801
|
+
const images = collectImages(branch);
|
|
802
|
+
if (images.length === 0) {
|
|
803
|
+
if (ctx.hasUI) ctx.ui.notify("No images found in the current branch", "warning");
|
|
804
|
+
return;
|
|
805
|
+
}
|
|
806
|
+
const uniqueImages = [...new Map(images.map((item) => [item.key, item.image])).entries()];
|
|
807
|
+
const summaries = /* @__PURE__ */ new Map();
|
|
808
|
+
if (ctx.hasUI) ctx.ui.notify(`Summarizing ${uniqueImages.length} image(s)...`, "info");
|
|
809
|
+
setCompressionProgress(ctx, 0, uniqueImages.length, commandConfig.model);
|
|
810
|
+
try {
|
|
811
|
+
for (let index = 0; index < uniqueImages.length; index++) {
|
|
812
|
+
const [key, image] = uniqueImages[index];
|
|
813
|
+
setCompressionProgress(ctx, index + 1, uniqueImages.length, commandConfig.model);
|
|
814
|
+
summaries.set(key, await summarizeImage(pi, ctx, image, commandConfig));
|
|
815
|
+
}
|
|
816
|
+
} catch (error) {
|
|
817
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
818
|
+
const message = `Image compression failed while using ${commandConfig.model || "pi's default model"}. ${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.`;
|
|
819
|
+
if (ctx.hasUI) ctx.ui.notify(message, "error");
|
|
820
|
+
else console.error(message);
|
|
821
|
+
return;
|
|
822
|
+
} finally {
|
|
823
|
+
clearCompressionProgress(ctx);
|
|
824
|
+
}
|
|
825
|
+
const parentSession = ctx.sessionManager.getSessionFile();
|
|
826
|
+
const reportDetails = buildReportDetails(images, summaries);
|
|
827
|
+
await ctx.newSession({
|
|
828
|
+
parentSession,
|
|
829
|
+
setup: async (sessionManager) => {
|
|
830
|
+
for (const entry of branch) appendTransformedEntry(sessionManager, entry, summaries);
|
|
831
|
+
if (commandConfig.includeReport) sessionManager.appendCustomMessageEntry("paster-image-compress-report", "", true, reportDetails);
|
|
832
|
+
},
|
|
833
|
+
withSession: async (newCtx) => {
|
|
834
|
+
if (newCtx.hasUI) newCtx.ui.notify(`Compressed ${images.length} image block(s) into text summaries`, "info");
|
|
835
|
+
}
|
|
836
|
+
});
|
|
837
|
+
}
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
//#endregion
|
|
650
841
|
//#region src/config.ts
|
|
651
842
|
const DEFAULT_PASTER_CONFIG = {
|
|
652
843
|
submittedPreviewStyle: "raw",
|
|
653
844
|
includeImagePathsInPrompt: true,
|
|
845
|
+
imageCompression: {
|
|
846
|
+
enabled: true,
|
|
847
|
+
command: "image-compress",
|
|
848
|
+
model: "openai-codex/gpt-5.4-mini",
|
|
849
|
+
prompt: "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.",
|
|
850
|
+
piCommand: "pi",
|
|
851
|
+
timeoutMs: 12e4,
|
|
852
|
+
includeReport: true
|
|
853
|
+
},
|
|
654
854
|
customEditor: {
|
|
655
855
|
enabled: true,
|
|
656
856
|
showImagePreview: true,
|
|
@@ -661,6 +861,15 @@ function resolvePasterConfig(config = {}) {
|
|
|
661
861
|
return {
|
|
662
862
|
submittedPreviewStyle: config.submittedPreviewStyle ?? DEFAULT_PASTER_CONFIG.submittedPreviewStyle,
|
|
663
863
|
includeImagePathsInPrompt: config.includeImagePathsInPrompt ?? DEFAULT_PASTER_CONFIG.includeImagePathsInPrompt,
|
|
864
|
+
imageCompression: {
|
|
865
|
+
enabled: config.imageCompression?.enabled ?? DEFAULT_PASTER_CONFIG.imageCompression.enabled,
|
|
866
|
+
command: config.imageCompression?.command ?? DEFAULT_PASTER_CONFIG.imageCompression.command,
|
|
867
|
+
model: config.imageCompression?.model ?? DEFAULT_PASTER_CONFIG.imageCompression.model,
|
|
868
|
+
prompt: config.imageCompression?.prompt ?? DEFAULT_PASTER_CONFIG.imageCompression.prompt,
|
|
869
|
+
piCommand: config.imageCompression?.piCommand ?? DEFAULT_PASTER_CONFIG.imageCompression.piCommand,
|
|
870
|
+
timeoutMs: config.imageCompression?.timeoutMs ?? DEFAULT_PASTER_CONFIG.imageCompression.timeoutMs,
|
|
871
|
+
includeReport: config.imageCompression?.includeReport ?? DEFAULT_PASTER_CONFIG.imageCompression.includeReport
|
|
872
|
+
},
|
|
664
873
|
customEditor: {
|
|
665
874
|
enabled: config.customEditor?.enabled ?? DEFAULT_PASTER_CONFIG.customEditor.enabled,
|
|
666
875
|
showImagePreview: config.customEditor?.showImagePreview ?? DEFAULT_PASTER_CONFIG.customEditor.showImagePreview,
|
|
@@ -674,7 +883,8 @@ const PASTE_START = "\x1B[200~";
|
|
|
674
883
|
const PASTE_END = "\x1B[201~";
|
|
675
884
|
const PLACEHOLDER_REGEX = /\[#image \d+\]/g;
|
|
676
885
|
const PASTE_MARKER_REGEX = /\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]/g;
|
|
677
|
-
const
|
|
886
|
+
const graphemeSegmenter = new Intl.Segmenter(void 0, { granularity: "grapheme" });
|
|
887
|
+
const wordSegmenter = new Intl.Segmenter(void 0, { granularity: "word" });
|
|
678
888
|
function atomicSpansForText(text, validPasteIds) {
|
|
679
889
|
const spans = [];
|
|
680
890
|
for (const match of text.matchAll(PASTE_MARKER_REGEX)) {
|
|
@@ -694,12 +904,12 @@ function atomicSpansForText(text, validPasteIds) {
|
|
|
694
904
|
}
|
|
695
905
|
return spans.sort((a, b) => a.start - b.start || a.end - b.end);
|
|
696
906
|
}
|
|
697
|
-
function segmentTextWithAtomicImages(text, store, validPasteIds = /* @__PURE__ */ new Set()) {
|
|
907
|
+
function segmentTextWithAtomicImages(text, store, validPasteIds = /* @__PURE__ */ new Set(), segmenter = graphemeSegmenter) {
|
|
698
908
|
const spans = atomicSpansForText(text, validPasteIds);
|
|
699
|
-
if (spans.length === 0) return [...
|
|
909
|
+
if (spans.length === 0) return [...segmenter.segment(text)];
|
|
700
910
|
const result = [];
|
|
701
911
|
let spanIndex = 0;
|
|
702
|
-
for (const segment of
|
|
912
|
+
for (const segment of segmenter.segment(text)) {
|
|
703
913
|
while (spanIndex < spans.length && spans[spanIndex].end <= segment.index) spanIndex++;
|
|
704
914
|
const span = spans[spanIndex];
|
|
705
915
|
if (span && segment.index >= span.start && segment.index < span.end) {
|
|
@@ -775,7 +985,7 @@ var PasterEditor = class extends CustomEditor {
|
|
|
775
985
|
}
|
|
776
986
|
installAtomicImageSegmentation() {
|
|
777
987
|
const editor = this;
|
|
778
|
-
editor.segment = (text) => segmentTextWithAtomicImages(text, this.pasterOptions.store, new Set(editor.pastes?.keys() ?? []));
|
|
988
|
+
editor.segment = (text, mode = "grapheme") => segmentTextWithAtomicImages(text, this.pasterOptions.store, new Set(editor.pastes?.keys() ?? []), mode === "word" ? wordSegmenter : graphemeSegmenter);
|
|
779
989
|
}
|
|
780
990
|
async handlePasteClipboardImage() {
|
|
781
991
|
const attachment = await this.pasterOptions.pasteClipboardImage?.();
|
|
@@ -927,6 +1137,27 @@ var ImagePreviewMessage = class {
|
|
|
927
1137
|
return lines;
|
|
928
1138
|
}
|
|
929
1139
|
};
|
|
1140
|
+
var ImageCompressionReportMessage = class {
|
|
1141
|
+
constructor(details, theme, expanded = false) {
|
|
1142
|
+
this.details = details;
|
|
1143
|
+
this.theme = theme;
|
|
1144
|
+
this.expanded = expanded;
|
|
1145
|
+
}
|
|
1146
|
+
render(width) {
|
|
1147
|
+
const container = new Container();
|
|
1148
|
+
container.addChild(new Spacer(1));
|
|
1149
|
+
const box = new Box(1, 1, this.theme.background);
|
|
1150
|
+
container.addChild(box);
|
|
1151
|
+
const suffix = this.expanded ? " (ctrl+o to collapse)" : " (ctrl+o to expand)";
|
|
1152
|
+
box.addChild(new Text(`${this.theme.title(`Compressed ${this.details.imageCount} image block(s) into ${this.details.summaryCount} summary/summaries`)}${this.theme.muted(suffix)}`, 0, 0));
|
|
1153
|
+
if (this.expanded) for (const item of this.details.items) {
|
|
1154
|
+
box.addChild(new Text(this.theme.muted(`Image ${item.index}:`), 0, 0));
|
|
1155
|
+
box.addChild(new Text(truncateToWidth(item.summary, Math.max(1, width - 4), "…"), 0, 0));
|
|
1156
|
+
}
|
|
1157
|
+
return container.render(width);
|
|
1158
|
+
}
|
|
1159
|
+
invalidate() {}
|
|
1160
|
+
};
|
|
930
1161
|
var CursorImagePreviewWidget = class {
|
|
931
1162
|
image;
|
|
932
1163
|
constructor(attachment, theme) {
|
|
@@ -1035,9 +1266,19 @@ function createPaster(config = {}) {
|
|
|
1035
1266
|
function paster(pi, config = {}) {
|
|
1036
1267
|
const resolvedConfig = resolvePasterConfig(config);
|
|
1037
1268
|
const store = new AttachmentStore();
|
|
1269
|
+
registerImageCompressionCommand(pi, resolvedConfig.imageCompression);
|
|
1038
1270
|
let pendingPreview = [];
|
|
1039
1271
|
let activeEditor;
|
|
1040
1272
|
let unsubscribeTerminalInput;
|
|
1273
|
+
pi.registerMessageRenderer("paster-image-compress-report", (message, options, theme) => {
|
|
1274
|
+
const details = message.details;
|
|
1275
|
+
if (!details) return void 0;
|
|
1276
|
+
return new ImageCompressionReportMessage(details, {
|
|
1277
|
+
background: (text) => theme.bg("toolSuccessBg", text),
|
|
1278
|
+
title: (text) => theme.fg("toolTitle", theme.bold(text)),
|
|
1279
|
+
muted: (text) => theme.fg("muted", text)
|
|
1280
|
+
}, options.expanded);
|
|
1281
|
+
});
|
|
1041
1282
|
pi.registerMessageRenderer("paster-preview", (message, options, theme) => {
|
|
1042
1283
|
const placeholders = message.details?.placeholders ?? [];
|
|
1043
1284
|
const attachments = store.list().filter((attachment) => placeholders.includes(attachment.placeholder));
|
|
@@ -1138,4 +1379,4 @@ function paster(pi, config = {}) {
|
|
|
1138
1379
|
});
|
|
1139
1380
|
}
|
|
1140
1381
|
//#endregion
|
|
1141
|
-
export { ANTHROPIC_MAX_DIMENSION, ANTHROPIC_MAX_IMAGE_BYTES, AttachmentStore, CursorImagePreviewWidget, DEFAULT_PASTER_CONFIG, EXTENSION_NAME, ImagePreviewMessage, MAX_IMAGE_BYTES, PASTE_END, PASTE_START, PasterEditor, appendImagePathContext, createImagePasteTerminalInputHandler, createPaster, paster as default, describeReject, detectImageMimeType, dimensionsForImage, imagesForText, imagesForTextOptimized, isWindowsDrivePath, isWindowsLikePath, isWindowsUncPath, isWsl, loadImageFromPath, optimizeImageBytes, readClipboardImage, replaceImagePathsInText, resolveImagePath, resolvePasterConfig, segmentTextWithAtomicImages, shellUnescape, tokenizePathLikeText, windowsToWslPath };
|
|
1382
|
+
export { ANTHROPIC_MAX_DIMENSION, ANTHROPIC_MAX_IMAGE_BYTES, AttachmentStore, CursorImagePreviewWidget, DEFAULT_IMAGE_SUMMARY_PROMPT, DEFAULT_PASTER_CONFIG, EXTENSION_NAME, ImageCompressionReportMessage, ImagePreviewMessage, MAX_IMAGE_BYTES, PASTE_END, PASTE_START, PasterEditor, appendImagePathContext, createImagePasteTerminalInputHandler, createPaster, paster as default, describeReject, detectImageMimeType, dimensionsForImage, imagesForText, imagesForTextOptimized, isWindowsDrivePath, isWindowsLikePath, isWindowsUncPath, isWsl, loadImageFromPath, optimizeImageBytes, readClipboardImage, registerImageCompressionCommand, replaceImagePathsInText, resolveImagePath, resolvePasterConfig, segmentTextWithAtomicImages, shellUnescape, tokenizePathLikeText, windowsToWslPath };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-paster",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "Pi extension that turns pasted image paths into first-class image attachments.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"image-attachments",
|
|
@@ -58,6 +58,6 @@
|
|
|
58
58
|
"extensions": [
|
|
59
59
|
"./src/index.ts"
|
|
60
60
|
],
|
|
61
|
-
"image": "https://unpkg.com/pi-paster@0.2.
|
|
61
|
+
"image": "https://unpkg.com/pi-paster@0.2.3/docs/preview.png"
|
|
62
62
|
}
|
|
63
63
|
}
|
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,10 +1,39 @@
|
|
|
1
1
|
export type SubmittedPreviewStyle = "raw" | "collapsible";
|
|
2
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
|
+
|
|
3
30
|
export interface PasterConfig {
|
|
4
31
|
/** How submitted attachment previews render in chat history. */
|
|
5
32
|
submittedPreviewStyle?: SubmittedPreviewStyle;
|
|
6
33
|
/** Append local image paths to the submitted prompt so the agent can manipulate the source files. */
|
|
7
34
|
includeImagePathsInPrompt?: boolean;
|
|
35
|
+
/** Configure the /image-compress command. */
|
|
36
|
+
imageCompression?: ImageCompressionConfig;
|
|
8
37
|
customEditor?: {
|
|
9
38
|
/** Replace pi's input editor to enable inline image UX features. */
|
|
10
39
|
enabled?: boolean;
|
|
@@ -18,6 +47,7 @@ export interface PasterConfig {
|
|
|
18
47
|
export interface ResolvedPasterConfig {
|
|
19
48
|
submittedPreviewStyle: SubmittedPreviewStyle;
|
|
20
49
|
includeImagePathsInPrompt: boolean;
|
|
50
|
+
imageCompression: ResolvedImageCompressionConfig;
|
|
21
51
|
customEditor: {
|
|
22
52
|
enabled: boolean;
|
|
23
53
|
showImagePreview: boolean;
|
|
@@ -28,6 +58,16 @@ export interface ResolvedPasterConfig {
|
|
|
28
58
|
export const DEFAULT_PASTER_CONFIG: ResolvedPasterConfig = {
|
|
29
59
|
submittedPreviewStyle: "raw",
|
|
30
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
|
+
},
|
|
31
71
|
customEditor: {
|
|
32
72
|
enabled: true,
|
|
33
73
|
showImagePreview: true,
|
|
@@ -41,6 +81,19 @@ export function resolvePasterConfig(config: PasterConfig = {}): ResolvedPasterCo
|
|
|
41
81
|
config.submittedPreviewStyle ?? DEFAULT_PASTER_CONFIG.submittedPreviewStyle,
|
|
42
82
|
includeImagePathsInPrompt:
|
|
43
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
|
+
},
|
|
44
97
|
customEditor: {
|
|
45
98
|
enabled: config.customEditor?.enabled ?? DEFAULT_PASTER_CONFIG.customEditor.enabled,
|
|
46
99
|
showImagePreview:
|
package/src/editor.ts
CHANGED
|
@@ -8,7 +8,8 @@ export const PASTE_START = "\x1b[200~";
|
|
|
8
8
|
export const PASTE_END = "\x1b[201~";
|
|
9
9
|
const PLACEHOLDER_REGEX = /\[#image \d+\]/g;
|
|
10
10
|
const PASTE_MARKER_REGEX = /\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]/g;
|
|
11
|
-
const
|
|
11
|
+
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
12
|
+
const wordSegmenter = new Intl.Segmenter(undefined, { granularity: "word" });
|
|
12
13
|
|
|
13
14
|
interface AtomicSpan {
|
|
14
15
|
start: number;
|
|
@@ -16,7 +17,7 @@ interface AtomicSpan {
|
|
|
16
17
|
}
|
|
17
18
|
|
|
18
19
|
interface EditorSegmentationAccess {
|
|
19
|
-
segment?: (text: string) => Iterable<Intl.SegmentData>;
|
|
20
|
+
segment?: (text: string, mode?: "word" | "grapheme") => Iterable<Intl.SegmentData>;
|
|
20
21
|
pastes?: Map<number, string>;
|
|
21
22
|
}
|
|
22
23
|
|
|
@@ -41,13 +42,14 @@ export function segmentTextWithAtomicImages(
|
|
|
41
42
|
text: string,
|
|
42
43
|
store: AttachmentStore,
|
|
43
44
|
validPasteIds: Set<number> = new Set(),
|
|
45
|
+
segmenter: Intl.Segmenter = graphemeSegmenter,
|
|
44
46
|
): Intl.SegmentData[] {
|
|
45
47
|
const spans = atomicSpansForText(text, validPasteIds);
|
|
46
|
-
if (spans.length === 0) return [...
|
|
48
|
+
if (spans.length === 0) return [...segmenter.segment(text)];
|
|
47
49
|
|
|
48
50
|
const result: Intl.SegmentData[] = [];
|
|
49
51
|
let spanIndex = 0;
|
|
50
|
-
for (const segment of
|
|
52
|
+
for (const segment of segmenter.segment(text)) {
|
|
51
53
|
while (spanIndex < spans.length && spans[spanIndex]!.end <= segment.index) spanIndex++;
|
|
52
54
|
const span = spans[spanIndex];
|
|
53
55
|
if (span && segment.index >= span.start && segment.index < span.end) {
|
|
@@ -159,11 +161,12 @@ export class PasterEditor extends CustomEditor {
|
|
|
159
161
|
|
|
160
162
|
private installAtomicImageSegmentation(): void {
|
|
161
163
|
const editor = this as unknown as EditorSegmentationAccess;
|
|
162
|
-
editor.segment = (text: string) =>
|
|
164
|
+
editor.segment = (text: string, mode: "word" | "grapheme" = "grapheme") =>
|
|
163
165
|
segmentTextWithAtomicImages(
|
|
164
166
|
text,
|
|
165
167
|
this.pasterOptions.store,
|
|
166
168
|
new Set(editor.pastes?.keys() ?? []),
|
|
169
|
+
mode === "word" ? wordSegmenter : graphemeSegmenter,
|
|
167
170
|
);
|
|
168
171
|
}
|
|
169
172
|
|
package/src/index.ts
CHANGED
|
@@ -1,14 +1,24 @@
|
|
|
1
1
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { readClipboardImage } from "./clipboard.ts";
|
|
3
|
+
import { registerImageCompressionCommand } from "./compress.ts";
|
|
3
4
|
import { type PasterConfig, resolvePasterConfig } from "./config.ts";
|
|
4
5
|
import { PasterEditor } from "./editor.ts";
|
|
5
6
|
import { appendImagePathContext, imagesForTextOptimized } from "./image-utils.ts";
|
|
6
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
CursorImagePreviewWidget,
|
|
9
|
+
ImageCompressionReportMessage,
|
|
10
|
+
ImagePreviewMessage,
|
|
11
|
+
} from "./preview.ts";
|
|
7
12
|
import { AttachmentStore } from "./store.ts";
|
|
8
13
|
import { createImagePasteTerminalInputHandler } from "./terminal-input.ts";
|
|
9
|
-
import type {
|
|
14
|
+
import type {
|
|
15
|
+
ImageAttachment,
|
|
16
|
+
ImageCompressionReportDetails,
|
|
17
|
+
PasterPreviewDetails,
|
|
18
|
+
} from "./types.ts";
|
|
10
19
|
|
|
11
20
|
export * from "./clipboard.ts";
|
|
21
|
+
export * from "./compress.ts";
|
|
12
22
|
export * from "./optimize-image.ts";
|
|
13
23
|
export * from "./config.ts";
|
|
14
24
|
export * from "./editor.ts";
|
|
@@ -25,10 +35,28 @@ export function createPaster(config: PasterConfig = {}): (pi: ExtensionAPI) => v
|
|
|
25
35
|
export default function paster(pi: ExtensionAPI, config: PasterConfig = {}): void {
|
|
26
36
|
const resolvedConfig = resolvePasterConfig(config);
|
|
27
37
|
const store = new AttachmentStore();
|
|
38
|
+
registerImageCompressionCommand(pi, resolvedConfig.imageCompression);
|
|
28
39
|
let pendingPreview: ImageAttachment[] = [];
|
|
29
40
|
let activeEditor: PasterEditor | undefined;
|
|
30
41
|
let unsubscribeTerminalInput: (() => void) | undefined;
|
|
31
42
|
|
|
43
|
+
pi.registerMessageRenderer<ImageCompressionReportDetails>(
|
|
44
|
+
"paster-image-compress-report",
|
|
45
|
+
(message, options, theme) => {
|
|
46
|
+
const details = message.details;
|
|
47
|
+
if (!details) return undefined;
|
|
48
|
+
return new ImageCompressionReportMessage(
|
|
49
|
+
details,
|
|
50
|
+
{
|
|
51
|
+
background: (text) => theme.bg("toolSuccessBg", text),
|
|
52
|
+
title: (text) => theme.fg("toolTitle", theme.bold(text)),
|
|
53
|
+
muted: (text) => theme.fg("muted", text),
|
|
54
|
+
},
|
|
55
|
+
options.expanded,
|
|
56
|
+
);
|
|
57
|
+
},
|
|
58
|
+
);
|
|
59
|
+
|
|
32
60
|
pi.registerMessageRenderer<PasterPreviewDetails>("paster-preview", (message, options, theme) => {
|
|
33
61
|
const placeholders = message.details?.placeholders ?? [];
|
|
34
62
|
const attachments = store
|
package/src/preview.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
truncateToWidth,
|
|
11
11
|
visibleWidth,
|
|
12
12
|
} from "@earendil-works/pi-tui";
|
|
13
|
-
import type { ImageAttachment } from "./types.ts";
|
|
13
|
+
import type { ImageAttachment, ImageCompressionReportDetails } from "./types.ts";
|
|
14
14
|
|
|
15
15
|
function formatAttachmentLine(
|
|
16
16
|
attachment: ImageAttachment,
|
|
@@ -99,6 +99,47 @@ export class ImagePreviewMessage implements Component {
|
|
|
99
99
|
}
|
|
100
100
|
}
|
|
101
101
|
|
|
102
|
+
interface CompressionReportTheme {
|
|
103
|
+
background?: (text: string) => string;
|
|
104
|
+
title: (text: string) => string;
|
|
105
|
+
muted: (text: string) => string;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export class ImageCompressionReportMessage implements Component {
|
|
109
|
+
constructor(
|
|
110
|
+
private readonly details: ImageCompressionReportDetails,
|
|
111
|
+
private readonly theme: CompressionReportTheme,
|
|
112
|
+
private readonly expanded = false,
|
|
113
|
+
) {}
|
|
114
|
+
|
|
115
|
+
render(width: number): string[] {
|
|
116
|
+
const container = new Container();
|
|
117
|
+
container.addChild(new Spacer(1));
|
|
118
|
+
const box = new Box(1, 1, this.theme.background);
|
|
119
|
+
container.addChild(box);
|
|
120
|
+
|
|
121
|
+
const suffix = this.expanded ? " (ctrl+o to collapse)" : " (ctrl+o to expand)";
|
|
122
|
+
box.addChild(
|
|
123
|
+
new Text(
|
|
124
|
+
`${this.theme.title(`Compressed ${this.details.imageCount} image block(s) into ${this.details.summaryCount} summary/summaries`)}${this.theme.muted(suffix)}`,
|
|
125
|
+
0,
|
|
126
|
+
0,
|
|
127
|
+
),
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
if (this.expanded) {
|
|
131
|
+
for (const item of this.details.items) {
|
|
132
|
+
box.addChild(new Text(this.theme.muted(`Image ${item.index}:`), 0, 0));
|
|
133
|
+
box.addChild(new Text(truncateToWidth(item.summary, Math.max(1, width - 4), "…"), 0, 0));
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
return container.render(width);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
invalidate(): void {}
|
|
141
|
+
}
|
|
142
|
+
|
|
102
143
|
interface CursorPreviewTheme {
|
|
103
144
|
title: (text: string) => string;
|
|
104
145
|
muted: (text: string) => string;
|
package/src/types.ts
CHANGED
|
@@ -57,3 +57,14 @@ export type LoadImageResult =
|
|
|
57
57
|
export interface PasterPreviewDetails {
|
|
58
58
|
placeholders: string[];
|
|
59
59
|
}
|
|
60
|
+
|
|
61
|
+
export interface ImageCompressionReportItem {
|
|
62
|
+
index: number;
|
|
63
|
+
summary: string;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface ImageCompressionReportDetails {
|
|
67
|
+
imageCount: number;
|
|
68
|
+
summaryCount: number;
|
|
69
|
+
items: ImageCompressionReportItem[];
|
|
70
|
+
}
|