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 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 | Description |
111
- | --------------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------- |
112
- | `submittedPreviewStyle` | `"raw"` | How submitted image previews render in chat history. Use `"collapsible"` to wrap them in pi's ctrl+o expandable/collapsible message UI. |
113
- | `includeImagePathsInPrompt` | `true` | Appends placeholder-to-local-path mappings to the submitted prompt so the agent can manipulate the source image files when asked. |
114
- | `customEditor.enabled` | `true` | Replaces pi's input editor with paster's editor integration. Disable this to keep pi's default editor. |
115
- | `customEditor.showImagePreview` | `true` | Shows an image preview above the input when the cursor is inside an image placeholder. Requires `customEditor.enabled`. |
116
- | `customEditor.deletePlaceholderAsBlock` | `true` | Makes backspace/delete remove the whole placeholder when editing inside or adjacent to it. Requires `customEditor.enabled`. |
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.1",
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.1/docs/preview.png"
61
+ "image": "https://unpkg.com/pi-paster@0.2.2/docs/preview.png"
62
62
  }
63
63
  }
@@ -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 { CursorImagePreviewWidget, ImagePreviewMessage } from "./preview.ts";
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 { ImageAttachment, PasterPreviewDetails } from "./types.ts";
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
+ }