pi-paster 0.2.0 → 0.2.1

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
@@ -87,7 +87,7 @@ On macOS, pi exposes an image paste action through its keybinding system. In the
87
87
 
88
88
  ## Configuration
89
89
 
90
- By default all editor integrations are enabled.
90
+ By default all editor integrations are enabled, and submitted image previews render in the raw chat-history style.
91
91
 
92
92
  To customize behavior, load a small wrapper extension:
93
93
 
@@ -95,6 +95,8 @@ To customize behavior, load a small wrapper extension:
95
95
  import { createPaster } from "pi-paster";
96
96
 
97
97
  export default createPaster({
98
+ submittedPreviewStyle: "raw",
99
+ includeImagePathsInPrompt: true,
98
100
  customEditor: {
99
101
  enabled: true,
100
102
  showImagePreview: true,
@@ -105,11 +107,13 @@ export default createPaster({
105
107
 
106
108
  ### Options
107
109
 
108
- | Option | Default | Description |
109
- | --------------------------------------- | ------- | --------------------------------------------------------------------------------------------------------------------------- |
110
- | `customEditor.enabled` | `true` | Replaces pi's input editor with paster's editor integration. Disable this to keep pi's default editor. |
111
- | `customEditor.showImagePreview` | `true` | Shows an image preview above the input when the cursor is inside an image placeholder. Requires `customEditor.enabled`. |
112
- | `customEditor.deletePlaceholderAsBlock` | `true` | Makes backspace/delete remove the whole placeholder when editing inside or adjacent to it. Requires `customEditor.enabled`. |
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`. |
113
117
 
114
118
  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.
115
119
 
package/dist/index.d.mts CHANGED
@@ -3,7 +3,12 @@ import { Component, EditorTheme, ImageDimensions, ImageTheme, TUI } from "@earen
3
3
  import { CustomEditor, ExtensionAPI, KeybindingsManager } from "@earendil-works/pi-coding-agent";
4
4
 
5
5
  //#region src/config.d.ts
6
+ type SubmittedPreviewStyle = "raw" | "collapsible";
6
7
  interface PasterConfig {
8
+ /** How submitted attachment previews render in chat history. */
9
+ submittedPreviewStyle?: SubmittedPreviewStyle;
10
+ /** Append local image paths to the submitted prompt so the agent can manipulate the source files. */
11
+ includeImagePathsInPrompt?: boolean;
7
12
  customEditor?: {
8
13
  /** 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. */
9
14
  showImagePreview?: boolean; /** Treat image placeholders as atomic blocks for backspace/delete. */
@@ -11,6 +16,8 @@ interface PasterConfig {
11
16
  };
12
17
  }
13
18
  interface ResolvedPasterConfig {
19
+ submittedPreviewStyle: SubmittedPreviewStyle;
20
+ includeImagePathsInPrompt: boolean;
14
21
  customEditor: {
15
22
  enabled: boolean;
16
23
  showImagePreview: boolean;
@@ -109,6 +116,7 @@ declare class AttachmentStore {
109
116
  //#region src/editor.d.ts
110
117
  declare const PASTE_START = "\u001B[200~";
111
118
  declare const PASTE_END = "\u001B[201~";
119
+ declare function segmentTextWithAtomicImages(text: string, store: AttachmentStore, validPasteIds?: Set<number>): Intl.SegmentData[];
112
120
  declare class PasterEditor extends CustomEditor {
113
121
  private readonly pasterKeybindings;
114
122
  private readonly pasterOptions;
@@ -125,9 +133,12 @@ declare class PasterEditor extends CustomEditor {
125
133
  insertTextAtCursor(text: string): void;
126
134
  handleInput(data: string): void;
127
135
  clearCursorPreview(): void;
136
+ private installAtomicImageSegmentation;
128
137
  private handlePasteClipboardImage;
129
138
  private handleBracketedPaste;
139
+ private handleAtomicPlaceholderNavigation;
130
140
  private handleAtomicPlaceholderDelete;
141
+ private setCursor;
131
142
  private deleteLineRange;
132
143
  private transform;
133
144
  private updateCursorPreview;
@@ -165,6 +176,7 @@ declare function replaceImagePathsInText(text: string, options: {
165
176
  accepted: ImageAttachment[];
166
177
  };
167
178
  declare function imagesForText(store: AttachmentStore, text: string, existing?: PasterImageContent[]): PasterImageContent[];
179
+ declare function appendImagePathContext(text: string, attachments: ImageAttachment[]): string;
168
180
  /**
169
181
  * Async variant of imagesForText that runs each attachment through the
170
182
  * Anthropic-aware image optimizer (resize to 8000px cap, JPEG ladder to stay
@@ -180,13 +192,25 @@ declare function describeReject(result: Exclude<LoadImageResult, {
180
192
  }>, notify?: (message: string) => void): void;
181
193
  //#endregion
182
194
  //#region src/preview.d.ts
195
+ type ImagePreviewMessageStyle = "raw" | "collapsible";
196
+ interface ImagePreviewMessageTheme extends ImageTheme {
197
+ background?: (text: string) => string;
198
+ title?: (text: string) => string;
199
+ muted?: (text: string) => string;
200
+ }
183
201
  declare class ImagePreviewMessage implements Component {
184
202
  private readonly attachments;
185
203
  private readonly theme;
204
+ private readonly options;
186
205
  private readonly images;
187
- constructor(attachments: ImageAttachment[], theme: ImageTheme);
188
- render(width: number): string[];
206
+ constructor(attachments: ImageAttachment[], theme: ImagePreviewMessageTheme, options?: {
207
+ expanded?: boolean;
208
+ style?: ImagePreviewMessageStyle;
209
+ });
189
210
  invalidate(): void;
211
+ render(width: number): string[];
212
+ private renderRaw;
213
+ private renderCollapsible;
190
214
  }
191
215
  interface CursorPreviewTheme {
192
216
  title: (text: string) => string;
@@ -222,4 +246,4 @@ declare function createImagePasteTerminalInputHandler(options: {
222
246
  declare function createPaster(config?: PasterConfig): (pi: ExtensionAPI) => void;
223
247
  declare function paster(pi: ExtensionAPI, config?: PasterConfig): void;
224
248
  //#endregion
225
- export { ANTHROPIC_MAX_DIMENSION, ANTHROPIC_MAX_IMAGE_BYTES, AttachmentStore, ClipboardImageResult, CursorImagePreviewWidget, DEFAULT_PASTER_CONFIG, EXTENSION_NAME, ImageAttachment, ImagePreviewMessage, LoadImageResult, LoadedImage, MAX_IMAGE_BYTES, OptimizeResult, PASTE_END, PASTE_START, PasterConfig, PasterEditor, PasterImageContent, PasterPreviewDetails, ResolvedPasterConfig, SupportedImageMimeType, TerminalInputResult, createImagePasteTerminalInputHandler, createPaster, paster as default, describeReject, detectImageMimeType, dimensionsForImage, imagesForText, imagesForTextOptimized, isWindowsDrivePath, isWindowsLikePath, isWindowsUncPath, isWsl, loadImageFromPath, optimizeImageBytes, readClipboardImage, replaceImagePathsInText, resolveImagePath, resolvePasterConfig, shellUnescape, tokenizePathLikeText, windowsToWslPath };
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 };
package/dist/index.mjs CHANGED
@@ -4,7 +4,7 @@ import { existsSync, readFileSync, statSync, unlinkSync } 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";
7
- import { Image, getCellDimensions, getImageDimensions, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
7
+ import { Box, Container, Image, Spacer, Text, getCellDimensions, getImageDimensions, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
8
8
  import { CustomEditor } from "@earendil-works/pi-coding-agent";
9
9
  //#region src/types.ts
10
10
  const EXTENSION_NAME = "paster";
@@ -447,6 +447,10 @@ function imagesForText(store, text, existing = []) {
447
447
  data: attachment.data
448
448
  }))];
449
449
  }
450
+ function appendImagePathContext(text, attachments) {
451
+ if (attachments.length === 0) return text;
452
+ return `${text}\n\nAttached image paths:\n${attachments.map((attachment) => `- ${attachment.placeholder}: ${attachment.originalPath}`).join("\n")}`;
453
+ }
450
454
  /**
451
455
  * Async variant of imagesForText that runs each attachment through the
452
456
  * Anthropic-aware image optimizer (resize to 8000px cap, JPEG ladder to stay
@@ -644,23 +648,72 @@ function readMacOSClipboardImage(maxBytes) {
644
648
  }
645
649
  //#endregion
646
650
  //#region src/config.ts
647
- const DEFAULT_PASTER_CONFIG = { customEditor: {
648
- enabled: true,
649
- showImagePreview: true,
650
- deletePlaceholderAsBlock: true
651
- } };
651
+ const DEFAULT_PASTER_CONFIG = {
652
+ submittedPreviewStyle: "raw",
653
+ includeImagePathsInPrompt: true,
654
+ customEditor: {
655
+ enabled: true,
656
+ showImagePreview: true,
657
+ deletePlaceholderAsBlock: true
658
+ }
659
+ };
652
660
  function resolvePasterConfig(config = {}) {
653
- return { customEditor: {
654
- enabled: config.customEditor?.enabled ?? DEFAULT_PASTER_CONFIG.customEditor.enabled,
655
- showImagePreview: config.customEditor?.showImagePreview ?? DEFAULT_PASTER_CONFIG.customEditor.showImagePreview,
656
- deletePlaceholderAsBlock: config.customEditor?.deletePlaceholderAsBlock ?? DEFAULT_PASTER_CONFIG.customEditor.deletePlaceholderAsBlock
657
- } };
661
+ return {
662
+ submittedPreviewStyle: config.submittedPreviewStyle ?? DEFAULT_PASTER_CONFIG.submittedPreviewStyle,
663
+ includeImagePathsInPrompt: config.includeImagePathsInPrompt ?? DEFAULT_PASTER_CONFIG.includeImagePathsInPrompt,
664
+ customEditor: {
665
+ enabled: config.customEditor?.enabled ?? DEFAULT_PASTER_CONFIG.customEditor.enabled,
666
+ showImagePreview: config.customEditor?.showImagePreview ?? DEFAULT_PASTER_CONFIG.customEditor.showImagePreview,
667
+ deletePlaceholderAsBlock: config.customEditor?.deletePlaceholderAsBlock ?? DEFAULT_PASTER_CONFIG.customEditor.deletePlaceholderAsBlock
668
+ }
669
+ };
658
670
  }
659
671
  //#endregion
660
672
  //#region src/editor.ts
661
673
  const PASTE_START = "\x1B[200~";
662
674
  const PASTE_END = "\x1B[201~";
663
675
  const PLACEHOLDER_REGEX = /\[#image \d+\]/g;
676
+ const PASTE_MARKER_REGEX = /\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]/g;
677
+ const baseSegmenter = new Intl.Segmenter();
678
+ function atomicSpansForText(text, validPasteIds) {
679
+ const spans = [];
680
+ for (const match of text.matchAll(PASTE_MARKER_REGEX)) {
681
+ const id = Number.parseInt(match[1], 10);
682
+ if (!validPasteIds.has(id)) continue;
683
+ spans.push({
684
+ start: match.index,
685
+ end: match.index + match[0].length
686
+ });
687
+ }
688
+ for (const match of text.matchAll(PLACEHOLDER_REGEX)) {
689
+ const placeholder = match[0];
690
+ spans.push({
691
+ start: match.index,
692
+ end: match.index + placeholder.length
693
+ });
694
+ }
695
+ return spans.sort((a, b) => a.start - b.start || a.end - b.end);
696
+ }
697
+ function segmentTextWithAtomicImages(text, store, validPasteIds = /* @__PURE__ */ new Set()) {
698
+ const spans = atomicSpansForText(text, validPasteIds);
699
+ if (spans.length === 0) return [...baseSegmenter.segment(text)];
700
+ const result = [];
701
+ let spanIndex = 0;
702
+ for (const segment of baseSegmenter.segment(text)) {
703
+ while (spanIndex < spans.length && spans[spanIndex].end <= segment.index) spanIndex++;
704
+ const span = spans[spanIndex];
705
+ if (span && segment.index >= span.start && segment.index < span.end) {
706
+ if (segment.index === span.start) result.push({
707
+ segment: text.slice(span.start, span.end),
708
+ index: span.start,
709
+ input: text
710
+ });
711
+ continue;
712
+ }
713
+ result.push(segment);
714
+ }
715
+ return result;
716
+ }
664
717
  function findPlaceholderAtCursor(store, lines, cursor, mode) {
665
718
  const line = lines[cursor.line] ?? "";
666
719
  for (const match of line.matchAll(PLACEHOLDER_REGEX)) {
@@ -668,21 +721,24 @@ function findPlaceholderAtCursor(store, lines, cursor, mode) {
668
721
  const start = match.index;
669
722
  const end = start + placeholder.length;
670
723
  const attachment = store.get(placeholder);
671
- if (!attachment) continue;
724
+ if (!attachment && mode !== "hover") continue;
672
725
  if (mode === "hover" && cursor.col >= start && cursor.col < end) return {
673
726
  attachment,
727
+ placeholder,
674
728
  line: cursor.line,
675
729
  start,
676
730
  end
677
731
  };
678
732
  if (mode === "backspace" && cursor.col > start && cursor.col <= end) return {
679
733
  attachment,
734
+ placeholder,
680
735
  line: cursor.line,
681
736
  start,
682
737
  end
683
738
  };
684
739
  if (mode === "delete" && cursor.col >= start && cursor.col < end) return {
685
740
  attachment,
741
+ placeholder,
686
742
  line: cursor.line,
687
743
  start,
688
744
  end
@@ -696,6 +752,7 @@ var PasterEditor = class extends CustomEditor {
696
752
  super(tui, theme, pasterKeybindings);
697
753
  this.pasterKeybindings = pasterKeybindings;
698
754
  this.pasterOptions = pasterOptions;
755
+ this.installAtomicImageSegmentation();
699
756
  this.onPasteImage = () => {
700
757
  this.handlePasteClipboardImage();
701
758
  };
@@ -707,6 +764,7 @@ var PasterEditor = class extends CustomEditor {
707
764
  }
708
765
  handleInput(data) {
709
766
  if (this.handleBracketedPaste(data)) return;
767
+ if (this.handleAtomicPlaceholderNavigation(data)) return;
710
768
  if (this.pasterOptions.deletePlaceholderAsBlock && this.handleAtomicPlaceholderDelete(data)) return;
711
769
  super.handleInput(data);
712
770
  this.updateCursorPreview();
@@ -715,6 +773,10 @@ var PasterEditor = class extends CustomEditor {
715
773
  this.activePreviewPlaceholder = void 0;
716
774
  this.pasterOptions.setCursorPreview(void 0);
717
775
  }
776
+ installAtomicImageSegmentation() {
777
+ const editor = this;
778
+ editor.segment = (text) => segmentTextWithAtomicImages(text, this.pasterOptions.store, new Set(editor.pastes?.keys() ?? []));
779
+ }
718
780
  async handlePasteClipboardImage() {
719
781
  const attachment = await this.pasterOptions.pasteClipboardImage?.();
720
782
  if (!attachment) return;
@@ -755,6 +817,20 @@ var PasterEditor = class extends CustomEditor {
755
817
  this.updateCursorPreview();
756
818
  return true;
757
819
  }
820
+ handleAtomicPlaceholderNavigation(data) {
821
+ const isLeft = this.pasterKeybindings.matches(data, "tui.editor.cursorLeft");
822
+ const isRight = this.pasterKeybindings.matches(data, "tui.editor.cursorRight");
823
+ if (!isLeft && !isRight) return false;
824
+ const line = this.getLines()[this.getCursor().line] ?? "";
825
+ const cursor = this.getCursor();
826
+ const matches = [...line.matchAll(PLACEHOLDER_REGEX)];
827
+ const target = isRight ? matches.find((match) => cursor.col >= match.index && cursor.col < match.index + match[0].length) : matches.find((match) => cursor.col > match.index && cursor.col <= match.index + match[0].length);
828
+ if (!target) return false;
829
+ this.setCursor(target.index + (isRight ? target[0].length : 0));
830
+ this.updateCursorPreview();
831
+ this.tui.requestRender();
832
+ return true;
833
+ }
758
834
  handleAtomicPlaceholderDelete(data) {
759
835
  const isBackspace = this.pasterKeybindings.matches(data, "tui.editor.deleteCharBackward");
760
836
  const isDelete = this.pasterKeybindings.matches(data, "tui.editor.deleteCharForward");
@@ -766,14 +842,18 @@ var PasterEditor = class extends CustomEditor {
766
842
  this.updateCursorPreview();
767
843
  return true;
768
844
  }
845
+ setCursor(col) {
846
+ const editor = this;
847
+ if (editor.setCursorCol) editor.setCursorCol(col);
848
+ else editor.state.cursorCol = col;
849
+ }
769
850
  deleteLineRange(lineIndex, start, end) {
770
851
  const editor = this;
771
852
  editor.pushUndoSnapshot?.();
772
853
  const line = editor.state.lines[lineIndex] ?? "";
773
854
  editor.state.lines[lineIndex] = line.slice(0, start) + line.slice(end);
774
855
  editor.state.cursorLine = lineIndex;
775
- if (editor.setCursorCol) editor.setCursorCol(start);
776
- else editor.state.cursorCol = start;
856
+ this.setCursor(start);
777
857
  editor.lastAction = null;
778
858
  editor.historyIndex = -1;
779
859
  this.onChange?.(this.getText());
@@ -788,7 +868,7 @@ var PasterEditor = class extends CustomEditor {
788
868
  }
789
869
  updateCursorPreview() {
790
870
  const target = findPlaceholderAtCursor(this.pasterOptions.store, this.getLines(), this.getCursor(), "hover");
791
- const nextPlaceholder = target?.attachment.placeholder;
871
+ const nextPlaceholder = target?.attachment?.placeholder;
792
872
  if (nextPlaceholder === this.activePreviewPlaceholder) return;
793
873
  this.activePreviewPlaceholder = nextPlaceholder;
794
874
  this.pasterOptions.setCursorPreview(target?.attachment);
@@ -803,16 +883,23 @@ function formatAttachmentLine(attachment, width, style) {
803
883
  }
804
884
  var ImagePreviewMessage = class {
805
885
  images;
806
- constructor(attachments, theme) {
886
+ constructor(attachments, theme, options = {}) {
807
887
  this.attachments = attachments;
808
888
  this.theme = theme;
889
+ this.options = options;
809
890
  this.images = attachments.map((attachment) => new Image(attachment.data, attachment.mimeType, theme, {
810
891
  maxWidthCells: 60,
811
892
  maxHeightCells: 16,
812
893
  filename: attachment.placeholder
813
894
  }));
814
895
  }
896
+ invalidate() {
897
+ for (const image of this.images) image.invalidate();
898
+ }
815
899
  render(width) {
900
+ return this.options.style === "collapsible" ? this.renderCollapsible(width) : this.renderRaw(width);
901
+ }
902
+ renderRaw(width) {
816
903
  const lines = [];
817
904
  const safeWidth = Math.max(1, width);
818
905
  for (let index = 0; index < this.attachments.length; index++) {
@@ -822,8 +909,22 @@ var ImagePreviewMessage = class {
822
909
  }
823
910
  return lines;
824
911
  }
825
- invalidate() {
826
- for (const image of this.images) image.invalidate();
912
+ renderCollapsible(width) {
913
+ const container = new Container();
914
+ container.addChild(new Spacer(1));
915
+ const box = new Box(1, 1, this.theme.background);
916
+ container.addChild(box);
917
+ const title = this.theme.title ?? this.theme.fallbackColor;
918
+ const muted = this.theme.muted ?? this.theme.fallbackColor;
919
+ const summary = this.attachments.length === 1 ? `Attached ${this.attachments[0].placeholder}` : `Attached ${this.attachments.length} images`;
920
+ const suffix = this.options.expanded ? " (ctrl+o to collapse)" : " (ctrl+o to expand)";
921
+ box.addChild(new Text(`${title(summary)}${muted(suffix)}`, 0, 0));
922
+ for (const attachment of this.attachments) box.addChild(new Text(formatAttachmentLine(attachment, width, muted), 0, 0));
923
+ const lines = container.render(width);
924
+ if (!this.options.expanded) return lines;
925
+ const safeWidth = Math.max(1, width);
926
+ for (let index = 0; index < this.attachments.length; index++) lines.push(...this.images[index].render(safeWidth));
927
+ return lines;
827
928
  }
828
929
  };
829
930
  var CursorImagePreviewWidget = class {
@@ -937,11 +1038,19 @@ function paster(pi, config = {}) {
937
1038
  let pendingPreview = [];
938
1039
  let activeEditor;
939
1040
  let unsubscribeTerminalInput;
940
- pi.registerMessageRenderer("paster-preview", (message, _options, theme) => {
1041
+ pi.registerMessageRenderer("paster-preview", (message, options, theme) => {
941
1042
  const placeholders = message.details?.placeholders ?? [];
942
1043
  const attachments = store.list().filter((attachment) => placeholders.includes(attachment.placeholder));
943
1044
  if (attachments.length === 0) return void 0;
944
- return new ImagePreviewMessage(attachments, { fallbackColor: (text) => theme.fg("muted", text) });
1045
+ return new ImagePreviewMessage(attachments, {
1046
+ fallbackColor: (text) => theme.fg("muted", text),
1047
+ background: (text) => theme.bg("toolSuccessBg", text),
1048
+ title: (text) => theme.fg("toolTitle", theme.bold(text)),
1049
+ muted: (text) => theme.fg("muted", text)
1050
+ }, {
1051
+ expanded: options.expanded,
1052
+ style: resolvedConfig.submittedPreviewStyle
1053
+ });
945
1054
  });
946
1055
  pi.on("session_start", (_event, ctx) => {
947
1056
  store.clear();
@@ -1017,7 +1126,7 @@ function paster(pi, config = {}) {
1017
1126
  const images = await imagesForTextOptimized(store, event.text, event.images);
1018
1127
  return {
1019
1128
  action: "transform",
1020
- text: event.text,
1129
+ text: resolvedConfig.includeImagePathsInPrompt ? appendImagePathContext(event.text, attachments) : event.text,
1021
1130
  images
1022
1131
  };
1023
1132
  });
@@ -1029,4 +1138,4 @@ function paster(pi, config = {}) {
1029
1138
  });
1030
1139
  }
1031
1140
  //#endregion
1032
- export { ANTHROPIC_MAX_DIMENSION, ANTHROPIC_MAX_IMAGE_BYTES, AttachmentStore, CursorImagePreviewWidget, DEFAULT_PASTER_CONFIG, EXTENSION_NAME, ImagePreviewMessage, MAX_IMAGE_BYTES, PASTE_END, PASTE_START, PasterEditor, createImagePasteTerminalInputHandler, createPaster, paster as default, describeReject, detectImageMimeType, dimensionsForImage, imagesForText, imagesForTextOptimized, isWindowsDrivePath, isWindowsLikePath, isWindowsUncPath, isWsl, loadImageFromPath, optimizeImageBytes, readClipboardImage, replaceImagePathsInText, resolveImagePath, resolvePasterConfig, shellUnescape, tokenizePathLikeText, windowsToWslPath };
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 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-paster",
3
- "version": "0.2.0",
3
+ "version": "0.2.1",
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.0/docs/preview.png"
61
+ "image": "https://unpkg.com/pi-paster@0.2.1/docs/preview.png"
62
62
  }
63
63
  }
package/src/config.ts CHANGED
@@ -1,4 +1,10 @@
1
+ export type SubmittedPreviewStyle = "raw" | "collapsible";
2
+
1
3
  export interface PasterConfig {
4
+ /** How submitted attachment previews render in chat history. */
5
+ submittedPreviewStyle?: SubmittedPreviewStyle;
6
+ /** Append local image paths to the submitted prompt so the agent can manipulate the source files. */
7
+ includeImagePathsInPrompt?: boolean;
2
8
  customEditor?: {
3
9
  /** Replace pi's input editor to enable inline image UX features. */
4
10
  enabled?: boolean;
@@ -10,6 +16,8 @@ export interface PasterConfig {
10
16
  }
11
17
 
12
18
  export interface ResolvedPasterConfig {
19
+ submittedPreviewStyle: SubmittedPreviewStyle;
20
+ includeImagePathsInPrompt: boolean;
13
21
  customEditor: {
14
22
  enabled: boolean;
15
23
  showImagePreview: boolean;
@@ -18,6 +26,8 @@ export interface ResolvedPasterConfig {
18
26
  }
19
27
 
20
28
  export const DEFAULT_PASTER_CONFIG: ResolvedPasterConfig = {
29
+ submittedPreviewStyle: "raw",
30
+ includeImagePathsInPrompt: true,
21
31
  customEditor: {
22
32
  enabled: true,
23
33
  showImagePreview: true,
@@ -27,6 +37,10 @@ export const DEFAULT_PASTER_CONFIG: ResolvedPasterConfig = {
27
37
 
28
38
  export function resolvePasterConfig(config: PasterConfig = {}): ResolvedPasterConfig {
29
39
  return {
40
+ submittedPreviewStyle:
41
+ config.submittedPreviewStyle ?? DEFAULT_PASTER_CONFIG.submittedPreviewStyle,
42
+ includeImagePathsInPrompt:
43
+ config.includeImagePathsInPrompt ?? DEFAULT_PASTER_CONFIG.includeImagePathsInPrompt,
30
44
  customEditor: {
31
45
  enabled: config.customEditor?.enabled ?? DEFAULT_PASTER_CONFIG.customEditor.enabled,
32
46
  showImagePreview:
package/src/editor.ts CHANGED
@@ -7,6 +7,59 @@ import type { ImageAttachment } from "./types.ts";
7
7
  export const PASTE_START = "\x1b[200~";
8
8
  export const PASTE_END = "\x1b[201~";
9
9
  const PLACEHOLDER_REGEX = /\[#image \d+\]/g;
10
+ const PASTE_MARKER_REGEX = /\[paste #(\d+)( (\+\d+ lines|\d+ chars))?\]/g;
11
+ const baseSegmenter = new Intl.Segmenter();
12
+
13
+ interface AtomicSpan {
14
+ start: number;
15
+ end: number;
16
+ }
17
+
18
+ interface EditorSegmentationAccess {
19
+ segment?: (text: string) => Iterable<Intl.SegmentData>;
20
+ pastes?: Map<number, string>;
21
+ }
22
+
23
+ function atomicSpansForText(text: string, validPasteIds: Set<number>): AtomicSpan[] {
24
+ const spans: AtomicSpan[] = [];
25
+
26
+ for (const match of text.matchAll(PASTE_MARKER_REGEX)) {
27
+ const id = Number.parseInt(match[1]!, 10);
28
+ if (!validPasteIds.has(id)) continue;
29
+ spans.push({ start: match.index, end: match.index + match[0].length });
30
+ }
31
+
32
+ for (const match of text.matchAll(PLACEHOLDER_REGEX)) {
33
+ const placeholder = match[0];
34
+ spans.push({ start: match.index, end: match.index + placeholder.length });
35
+ }
36
+
37
+ return spans.sort((a, b) => a.start - b.start || a.end - b.end);
38
+ }
39
+
40
+ export function segmentTextWithAtomicImages(
41
+ text: string,
42
+ store: AttachmentStore,
43
+ validPasteIds: Set<number> = new Set(),
44
+ ): Intl.SegmentData[] {
45
+ const spans = atomicSpansForText(text, validPasteIds);
46
+ if (spans.length === 0) return [...baseSegmenter.segment(text)];
47
+
48
+ const result: Intl.SegmentData[] = [];
49
+ let spanIndex = 0;
50
+ for (const segment of baseSegmenter.segment(text)) {
51
+ while (spanIndex < spans.length && spans[spanIndex]!.end <= segment.index) spanIndex++;
52
+ const span = spans[spanIndex];
53
+ if (span && segment.index >= span.start && segment.index < span.end) {
54
+ if (segment.index === span.start) {
55
+ result.push({ segment: text.slice(span.start, span.end), index: span.start, input: text });
56
+ }
57
+ continue;
58
+ }
59
+ result.push(segment);
60
+ }
61
+ return result;
62
+ }
10
63
 
11
64
  interface EditorCursor {
12
65
  line: number;
@@ -14,7 +67,8 @@ interface EditorCursor {
14
67
  }
15
68
 
16
69
  interface PlaceholderAtCursor {
17
- attachment: ImageAttachment;
70
+ attachment?: ImageAttachment;
71
+ placeholder: string;
18
72
  line: number;
19
73
  start: number;
20
74
  end: number;
@@ -32,16 +86,16 @@ function findPlaceholderAtCursor(
32
86
  const start = match.index;
33
87
  const end = start + placeholder.length;
34
88
  const attachment = store.get(placeholder);
35
- if (!attachment) continue;
89
+ if (!attachment && mode !== "hover") continue;
36
90
 
37
91
  if (mode === "hover" && cursor.col >= start && cursor.col < end) {
38
- return { attachment, line: cursor.line, start, end };
92
+ return { attachment, placeholder, line: cursor.line, start, end };
39
93
  }
40
94
  if (mode === "backspace" && cursor.col > start && cursor.col <= end) {
41
- return { attachment, line: cursor.line, start, end };
95
+ return { attachment, placeholder, line: cursor.line, start, end };
42
96
  }
43
97
  if (mode === "delete" && cursor.col >= start && cursor.col < end) {
44
- return { attachment, line: cursor.line, start, end };
98
+ return { attachment, placeholder, line: cursor.line, start, end };
45
99
  }
46
100
  }
47
101
  return undefined;
@@ -76,6 +130,7 @@ export class PasterEditor extends CustomEditor {
76
130
  },
77
131
  ) {
78
132
  super(tui, theme, pasterKeybindings);
133
+ this.installAtomicImageSegmentation();
79
134
  this.onPasteImage = () => {
80
135
  void this.handlePasteClipboardImage();
81
136
  };
@@ -89,6 +144,7 @@ export class PasterEditor extends CustomEditor {
89
144
 
90
145
  override handleInput(data: string): void {
91
146
  if (this.handleBracketedPaste(data)) return;
147
+ if (this.handleAtomicPlaceholderNavigation(data)) return;
92
148
  if (this.pasterOptions.deletePlaceholderAsBlock && this.handleAtomicPlaceholderDelete(data))
93
149
  return;
94
150
 
@@ -101,6 +157,16 @@ export class PasterEditor extends CustomEditor {
101
157
  this.pasterOptions.setCursorPreview(undefined);
102
158
  }
103
159
 
160
+ private installAtomicImageSegmentation(): void {
161
+ const editor = this as unknown as EditorSegmentationAccess;
162
+ editor.segment = (text: string) =>
163
+ segmentTextWithAtomicImages(
164
+ text,
165
+ this.pasterOptions.store,
166
+ new Set(editor.pastes?.keys() ?? []),
167
+ );
168
+ }
169
+
104
170
  private async handlePasteClipboardImage(): Promise<void> {
105
171
  const attachment = await this.pasterOptions.pasteClipboardImage?.();
106
172
  if (!attachment) return;
@@ -149,6 +215,29 @@ export class PasterEditor extends CustomEditor {
149
215
  return true;
150
216
  }
151
217
 
218
+ private handleAtomicPlaceholderNavigation(data: string): boolean {
219
+ const isLeft = this.pasterKeybindings.matches(data, "tui.editor.cursorLeft");
220
+ const isRight = this.pasterKeybindings.matches(data, "tui.editor.cursorRight");
221
+ if (!isLeft && !isRight) return false;
222
+
223
+ const line = this.getLines()[this.getCursor().line] ?? "";
224
+ const cursor = this.getCursor();
225
+ const matches = [...line.matchAll(PLACEHOLDER_REGEX)];
226
+ const target = isRight
227
+ ? matches.find(
228
+ (match) => cursor.col >= match.index && cursor.col < match.index + match[0].length,
229
+ )
230
+ : matches.find(
231
+ (match) => cursor.col > match.index && cursor.col <= match.index + match[0].length,
232
+ );
233
+ if (!target) return false;
234
+
235
+ this.setCursor(target.index + (isRight ? target[0].length : 0));
236
+ this.updateCursorPreview();
237
+ this.tui.requestRender();
238
+ return true;
239
+ }
240
+
152
241
  private handleAtomicPlaceholderDelete(data: string): boolean {
153
242
  const isBackspace = this.pasterKeybindings.matches(data, "tui.editor.deleteCharBackward");
154
243
  const isDelete = this.pasterKeybindings.matches(data, "tui.editor.deleteCharForward");
@@ -168,17 +257,22 @@ export class PasterEditor extends CustomEditor {
168
257
  return true;
169
258
  }
170
259
 
260
+ private setCursor(col: number): void {
261
+ const editor = this as unknown as EditorStateAccess;
262
+ if (editor.setCursorCol) {
263
+ editor.setCursorCol(col);
264
+ } else {
265
+ editor.state.cursorCol = col;
266
+ }
267
+ }
268
+
171
269
  private deleteLineRange(lineIndex: number, start: number, end: number): void {
172
270
  const editor = this as unknown as EditorStateAccess;
173
271
  editor.pushUndoSnapshot?.();
174
272
  const line = editor.state.lines[lineIndex] ?? "";
175
273
  editor.state.lines[lineIndex] = line.slice(0, start) + line.slice(end);
176
274
  editor.state.cursorLine = lineIndex;
177
- if (editor.setCursorCol) {
178
- editor.setCursorCol(start);
179
- } else {
180
- editor.state.cursorCol = start;
181
- }
275
+ this.setCursor(start);
182
276
  editor.lastAction = null;
183
277
  editor.historyIndex = -1;
184
278
  this.onChange?.(this.getText());
@@ -200,7 +294,7 @@ export class PasterEditor extends CustomEditor {
200
294
  this.getCursor(),
201
295
  "hover",
202
296
  );
203
- const nextPlaceholder = target?.attachment.placeholder;
297
+ const nextPlaceholder = target?.attachment?.placeholder;
204
298
  if (nextPlaceholder === this.activePreviewPlaceholder) return;
205
299
  this.activePreviewPlaceholder = nextPlaceholder;
206
300
  this.pasterOptions.setCursorPreview(target?.attachment);
@@ -357,6 +357,14 @@ export function imagesForText(
357
357
  ];
358
358
  }
359
359
 
360
+ export function appendImagePathContext(text: string, attachments: ImageAttachment[]): string {
361
+ if (attachments.length === 0) return text;
362
+ const lines = attachments.map(
363
+ (attachment) => `- ${attachment.placeholder}: ${attachment.originalPath}`,
364
+ );
365
+ return `${text}\n\nAttached image paths:\n${lines.join("\n")}`;
366
+ }
367
+
360
368
  /**
361
369
  * Async variant of imagesForText that runs each attachment through the
362
370
  * Anthropic-aware image optimizer (resize to 8000px cap, JPEG ladder to stay
package/src/index.ts CHANGED
@@ -2,7 +2,7 @@ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
2
  import { readClipboardImage } from "./clipboard.ts";
3
3
  import { type PasterConfig, resolvePasterConfig } from "./config.ts";
4
4
  import { PasterEditor } from "./editor.ts";
5
- import { imagesForTextOptimized } from "./image-utils.ts";
5
+ import { appendImagePathContext, imagesForTextOptimized } from "./image-utils.ts";
6
6
  import { CursorImagePreviewWidget, ImagePreviewMessage } from "./preview.ts";
7
7
  import { AttachmentStore } from "./store.ts";
8
8
  import { createImagePasteTerminalInputHandler } from "./terminal-input.ts";
@@ -29,15 +29,22 @@ export default function paster(pi: ExtensionAPI, config: PasterConfig = {}): voi
29
29
  let activeEditor: PasterEditor | undefined;
30
30
  let unsubscribeTerminalInput: (() => void) | undefined;
31
31
 
32
- pi.registerMessageRenderer<PasterPreviewDetails>("paster-preview", (message, _options, theme) => {
32
+ pi.registerMessageRenderer<PasterPreviewDetails>("paster-preview", (message, options, theme) => {
33
33
  const placeholders = message.details?.placeholders ?? [];
34
34
  const attachments = store
35
35
  .list()
36
36
  .filter((attachment) => placeholders.includes(attachment.placeholder));
37
37
  if (attachments.length === 0) return undefined;
38
- return new ImagePreviewMessage(attachments, {
39
- fallbackColor: (text) => theme.fg("muted", text),
40
- });
38
+ return new ImagePreviewMessage(
39
+ attachments,
40
+ {
41
+ fallbackColor: (text) => theme.fg("muted", text),
42
+ background: (text) => theme.bg("toolSuccessBg", text),
43
+ title: (text) => theme.fg("toolTitle", theme.bold(text)),
44
+ muted: (text) => theme.fg("muted", text),
45
+ },
46
+ { expanded: options.expanded, style: resolvedConfig.submittedPreviewStyle },
47
+ );
41
48
  });
42
49
 
43
50
  pi.on("session_start", (_event, ctx) => {
@@ -142,10 +149,13 @@ export default function paster(pi: ExtensionAPI, config: PasterConfig = {}): voi
142
149
  // 32 MB/request caps. Per-attachment caching means each image is only
143
150
  // resized/recompressed once across the whole session.
144
151
  const images = await imagesForTextOptimized(store, event.text, event.images);
152
+ const text = resolvedConfig.includeImagePathsInPrompt
153
+ ? appendImagePathContext(event.text, attachments)
154
+ : event.text;
145
155
 
146
156
  return {
147
157
  action: "transform" as const,
148
- text: event.text,
158
+ text,
149
159
  images,
150
160
  };
151
161
  });
package/src/preview.ts CHANGED
@@ -1,8 +1,12 @@
1
1
  import {
2
+ Box,
3
+ Container,
2
4
  getCellDimensions,
3
5
  Image,
4
6
  type Component,
5
7
  type ImageTheme,
8
+ Spacer,
9
+ Text,
6
10
  truncateToWidth,
7
11
  visibleWidth,
8
12
  } from "@earendil-works/pi-tui";
@@ -18,12 +22,21 @@ function formatAttachmentLine(
18
22
  return visibleWidth(line) > maxWidth ? truncateToWidth(line, maxWidth, "") : line;
19
23
  }
20
24
 
25
+ export type ImagePreviewMessageStyle = "raw" | "collapsible";
26
+
27
+ interface ImagePreviewMessageTheme extends ImageTheme {
28
+ background?: (text: string) => string;
29
+ title?: (text: string) => string;
30
+ muted?: (text: string) => string;
31
+ }
32
+
21
33
  export class ImagePreviewMessage implements Component {
22
34
  private readonly images: Image[];
23
35
 
24
36
  constructor(
25
37
  private readonly attachments: ImageAttachment[],
26
- private readonly theme: ImageTheme,
38
+ private readonly theme: ImagePreviewMessageTheme,
39
+ private readonly options: { expanded?: boolean; style?: ImagePreviewMessageStyle } = {},
27
40
  ) {
28
41
  this.images = attachments.map(
29
42
  (attachment) =>
@@ -35,7 +48,17 @@ export class ImagePreviewMessage implements Component {
35
48
  );
36
49
  }
37
50
 
51
+ invalidate(): void {
52
+ for (const image of this.images) image.invalidate();
53
+ }
54
+
38
55
  render(width: number): string[] {
56
+ return this.options.style === "collapsible"
57
+ ? this.renderCollapsible(width)
58
+ : this.renderRaw(width);
59
+ }
60
+
61
+ private renderRaw(width: number): string[] {
39
62
  const lines: string[] = [];
40
63
  const safeWidth = Math.max(1, width);
41
64
  for (let index = 0; index < this.attachments.length; index++) {
@@ -46,8 +69,33 @@ export class ImagePreviewMessage implements Component {
46
69
  return lines;
47
70
  }
48
71
 
49
- invalidate(): void {
50
- for (const image of this.images) image.invalidate();
72
+ private renderCollapsible(width: number): string[] {
73
+ const container = new Container();
74
+ container.addChild(new Spacer(1));
75
+ const box = new Box(1, 1, this.theme.background);
76
+ container.addChild(box);
77
+
78
+ const title = this.theme.title ?? this.theme.fallbackColor;
79
+ const muted = this.theme.muted ?? this.theme.fallbackColor;
80
+ const summary =
81
+ this.attachments.length === 1
82
+ ? `Attached ${this.attachments[0]!.placeholder}`
83
+ : `Attached ${this.attachments.length} images`;
84
+ const suffix = this.options.expanded ? " (ctrl+o to collapse)" : " (ctrl+o to expand)";
85
+ box.addChild(new Text(`${title(summary)}${muted(suffix)}`, 0, 0));
86
+
87
+ for (const attachment of this.attachments) {
88
+ box.addChild(new Text(formatAttachmentLine(attachment, width, muted), 0, 0));
89
+ }
90
+
91
+ const lines = container.render(width);
92
+ if (!this.options.expanded) return lines;
93
+
94
+ const safeWidth = Math.max(1, width);
95
+ for (let index = 0; index < this.attachments.length; index++) {
96
+ lines.push(...this.images[index]!.render(safeWidth));
97
+ }
98
+ return lines;
51
99
  }
52
100
  }
53
101