pi-paster 0.2.1 → 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 +38 -7
- package/dist/index.d.mts +55 -1
- package/dist/index.mjs +242 -2
- package/package.json +2 -2
- package/src/compress.ts +329 -0
- package/src/config.ts +53 -0
- 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;
|
|
@@ -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,
|
|
@@ -927,6 +1136,27 @@ var ImagePreviewMessage = class {
|
|
|
927
1136
|
return lines;
|
|
928
1137
|
}
|
|
929
1138
|
};
|
|
1139
|
+
var ImageCompressionReportMessage = class {
|
|
1140
|
+
constructor(details, theme, expanded = false) {
|
|
1141
|
+
this.details = details;
|
|
1142
|
+
this.theme = theme;
|
|
1143
|
+
this.expanded = expanded;
|
|
1144
|
+
}
|
|
1145
|
+
render(width) {
|
|
1146
|
+
const container = new Container();
|
|
1147
|
+
container.addChild(new Spacer(1));
|
|
1148
|
+
const box = new Box(1, 1, this.theme.background);
|
|
1149
|
+
container.addChild(box);
|
|
1150
|
+
const suffix = this.expanded ? " (ctrl+o to collapse)" : " (ctrl+o to expand)";
|
|
1151
|
+
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));
|
|
1152
|
+
if (this.expanded) for (const item of this.details.items) {
|
|
1153
|
+
box.addChild(new Text(this.theme.muted(`Image ${item.index}:`), 0, 0));
|
|
1154
|
+
box.addChild(new Text(truncateToWidth(item.summary, Math.max(1, width - 4), "…"), 0, 0));
|
|
1155
|
+
}
|
|
1156
|
+
return container.render(width);
|
|
1157
|
+
}
|
|
1158
|
+
invalidate() {}
|
|
1159
|
+
};
|
|
930
1160
|
var CursorImagePreviewWidget = class {
|
|
931
1161
|
image;
|
|
932
1162
|
constructor(attachment, theme) {
|
|
@@ -1035,9 +1265,19 @@ function createPaster(config = {}) {
|
|
|
1035
1265
|
function paster(pi, config = {}) {
|
|
1036
1266
|
const resolvedConfig = resolvePasterConfig(config);
|
|
1037
1267
|
const store = new AttachmentStore();
|
|
1268
|
+
registerImageCompressionCommand(pi, resolvedConfig.imageCompression);
|
|
1038
1269
|
let pendingPreview = [];
|
|
1039
1270
|
let activeEditor;
|
|
1040
1271
|
let unsubscribeTerminalInput;
|
|
1272
|
+
pi.registerMessageRenderer("paster-image-compress-report", (message, options, theme) => {
|
|
1273
|
+
const details = message.details;
|
|
1274
|
+
if (!details) return void 0;
|
|
1275
|
+
return new ImageCompressionReportMessage(details, {
|
|
1276
|
+
background: (text) => theme.bg("toolSuccessBg", text),
|
|
1277
|
+
title: (text) => theme.fg("toolTitle", theme.bold(text)),
|
|
1278
|
+
muted: (text) => theme.fg("muted", text)
|
|
1279
|
+
}, options.expanded);
|
|
1280
|
+
});
|
|
1041
1281
|
pi.registerMessageRenderer("paster-preview", (message, options, theme) => {
|
|
1042
1282
|
const placeholders = message.details?.placeholders ?? [];
|
|
1043
1283
|
const attachments = store.list().filter((attachment) => placeholders.includes(attachment.placeholder));
|
|
@@ -1138,4 +1378,4 @@ function paster(pi, config = {}) {
|
|
|
1138
1378
|
});
|
|
1139
1379
|
}
|
|
1140
1380
|
//#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 };
|
|
1381
|
+
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.2",
|
|
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.2/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/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
|
+
}
|