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 +10 -6
- package/dist/index.d.mts +27 -3
- package/dist/index.mjs +131 -22
- package/package.json +2 -2
- package/src/config.ts +14 -0
- package/src/editor.ts +105 -11
- package/src/image-utils.ts +8 -0
- package/src/index.ts +16 -6
- package/src/preview.ts +51 -3
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
|
-
| `
|
|
111
|
-
| `
|
|
112
|
-
| `customEditor.
|
|
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:
|
|
188
|
-
|
|
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 = {
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
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 {
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
826
|
-
|
|
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,
|
|
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, {
|
|
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.
|
|
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.
|
|
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
|
|
70
|
+
attachment?: ImageAttachment;
|
|
71
|
+
placeholder: string;
|
|
18
72
|
line: number;
|
|
19
73
|
start: number;
|
|
20
74
|
end: number;
|
|
@@ -32,16 +86,16 @@ function findPlaceholderAtCursor(
|
|
|
32
86
|
const start = match.index;
|
|
33
87
|
const end = start + placeholder.length;
|
|
34
88
|
const attachment = store.get(placeholder);
|
|
35
|
-
if (!attachment) continue;
|
|
89
|
+
if (!attachment && mode !== "hover") continue;
|
|
36
90
|
|
|
37
91
|
if (mode === "hover" && cursor.col >= start && cursor.col < end) {
|
|
38
|
-
return { attachment, line: cursor.line, start, end };
|
|
92
|
+
return { attachment, placeholder, line: cursor.line, start, end };
|
|
39
93
|
}
|
|
40
94
|
if (mode === "backspace" && cursor.col > start && cursor.col <= end) {
|
|
41
|
-
return { attachment, line: cursor.line, start, end };
|
|
95
|
+
return { attachment, placeholder, line: cursor.line, start, end };
|
|
42
96
|
}
|
|
43
97
|
if (mode === "delete" && cursor.col >= start && cursor.col < end) {
|
|
44
|
-
return { attachment, line: cursor.line, start, end };
|
|
98
|
+
return { attachment, placeholder, line: cursor.line, start, end };
|
|
45
99
|
}
|
|
46
100
|
}
|
|
47
101
|
return undefined;
|
|
@@ -76,6 +130,7 @@ export class PasterEditor extends CustomEditor {
|
|
|
76
130
|
},
|
|
77
131
|
) {
|
|
78
132
|
super(tui, theme, pasterKeybindings);
|
|
133
|
+
this.installAtomicImageSegmentation();
|
|
79
134
|
this.onPasteImage = () => {
|
|
80
135
|
void this.handlePasteClipboardImage();
|
|
81
136
|
};
|
|
@@ -89,6 +144,7 @@ export class PasterEditor extends CustomEditor {
|
|
|
89
144
|
|
|
90
145
|
override handleInput(data: string): void {
|
|
91
146
|
if (this.handleBracketedPaste(data)) return;
|
|
147
|
+
if (this.handleAtomicPlaceholderNavigation(data)) return;
|
|
92
148
|
if (this.pasterOptions.deletePlaceholderAsBlock && this.handleAtomicPlaceholderDelete(data))
|
|
93
149
|
return;
|
|
94
150
|
|
|
@@ -101,6 +157,16 @@ export class PasterEditor extends CustomEditor {
|
|
|
101
157
|
this.pasterOptions.setCursorPreview(undefined);
|
|
102
158
|
}
|
|
103
159
|
|
|
160
|
+
private installAtomicImageSegmentation(): void {
|
|
161
|
+
const editor = this as unknown as EditorSegmentationAccess;
|
|
162
|
+
editor.segment = (text: string) =>
|
|
163
|
+
segmentTextWithAtomicImages(
|
|
164
|
+
text,
|
|
165
|
+
this.pasterOptions.store,
|
|
166
|
+
new Set(editor.pastes?.keys() ?? []),
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
104
170
|
private async handlePasteClipboardImage(): Promise<void> {
|
|
105
171
|
const attachment = await this.pasterOptions.pasteClipboardImage?.();
|
|
106
172
|
if (!attachment) return;
|
|
@@ -149,6 +215,29 @@ export class PasterEditor extends CustomEditor {
|
|
|
149
215
|
return true;
|
|
150
216
|
}
|
|
151
217
|
|
|
218
|
+
private handleAtomicPlaceholderNavigation(data: string): boolean {
|
|
219
|
+
const isLeft = this.pasterKeybindings.matches(data, "tui.editor.cursorLeft");
|
|
220
|
+
const isRight = this.pasterKeybindings.matches(data, "tui.editor.cursorRight");
|
|
221
|
+
if (!isLeft && !isRight) return false;
|
|
222
|
+
|
|
223
|
+
const line = this.getLines()[this.getCursor().line] ?? "";
|
|
224
|
+
const cursor = this.getCursor();
|
|
225
|
+
const matches = [...line.matchAll(PLACEHOLDER_REGEX)];
|
|
226
|
+
const target = isRight
|
|
227
|
+
? matches.find(
|
|
228
|
+
(match) => cursor.col >= match.index && cursor.col < match.index + match[0].length,
|
|
229
|
+
)
|
|
230
|
+
: matches.find(
|
|
231
|
+
(match) => cursor.col > match.index && cursor.col <= match.index + match[0].length,
|
|
232
|
+
);
|
|
233
|
+
if (!target) return false;
|
|
234
|
+
|
|
235
|
+
this.setCursor(target.index + (isRight ? target[0].length : 0));
|
|
236
|
+
this.updateCursorPreview();
|
|
237
|
+
this.tui.requestRender();
|
|
238
|
+
return true;
|
|
239
|
+
}
|
|
240
|
+
|
|
152
241
|
private handleAtomicPlaceholderDelete(data: string): boolean {
|
|
153
242
|
const isBackspace = this.pasterKeybindings.matches(data, "tui.editor.deleteCharBackward");
|
|
154
243
|
const isDelete = this.pasterKeybindings.matches(data, "tui.editor.deleteCharForward");
|
|
@@ -168,17 +257,22 @@ export class PasterEditor extends CustomEditor {
|
|
|
168
257
|
return true;
|
|
169
258
|
}
|
|
170
259
|
|
|
260
|
+
private setCursor(col: number): void {
|
|
261
|
+
const editor = this as unknown as EditorStateAccess;
|
|
262
|
+
if (editor.setCursorCol) {
|
|
263
|
+
editor.setCursorCol(col);
|
|
264
|
+
} else {
|
|
265
|
+
editor.state.cursorCol = col;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
171
269
|
private deleteLineRange(lineIndex: number, start: number, end: number): void {
|
|
172
270
|
const editor = this as unknown as EditorStateAccess;
|
|
173
271
|
editor.pushUndoSnapshot?.();
|
|
174
272
|
const line = editor.state.lines[lineIndex] ?? "";
|
|
175
273
|
editor.state.lines[lineIndex] = line.slice(0, start) + line.slice(end);
|
|
176
274
|
editor.state.cursorLine = lineIndex;
|
|
177
|
-
|
|
178
|
-
editor.setCursorCol(start);
|
|
179
|
-
} else {
|
|
180
|
-
editor.state.cursorCol = start;
|
|
181
|
-
}
|
|
275
|
+
this.setCursor(start);
|
|
182
276
|
editor.lastAction = null;
|
|
183
277
|
editor.historyIndex = -1;
|
|
184
278
|
this.onChange?.(this.getText());
|
|
@@ -200,7 +294,7 @@ export class PasterEditor extends CustomEditor {
|
|
|
200
294
|
this.getCursor(),
|
|
201
295
|
"hover",
|
|
202
296
|
);
|
|
203
|
-
const nextPlaceholder = target?.attachment
|
|
297
|
+
const nextPlaceholder = target?.attachment?.placeholder;
|
|
204
298
|
if (nextPlaceholder === this.activePreviewPlaceholder) return;
|
|
205
299
|
this.activePreviewPlaceholder = nextPlaceholder;
|
|
206
300
|
this.pasterOptions.setCursorPreview(target?.attachment);
|
package/src/image-utils.ts
CHANGED
|
@@ -357,6 +357,14 @@ export function imagesForText(
|
|
|
357
357
|
];
|
|
358
358
|
}
|
|
359
359
|
|
|
360
|
+
export function appendImagePathContext(text: string, attachments: ImageAttachment[]): string {
|
|
361
|
+
if (attachments.length === 0) return text;
|
|
362
|
+
const lines = attachments.map(
|
|
363
|
+
(attachment) => `- ${attachment.placeholder}: ${attachment.originalPath}`,
|
|
364
|
+
);
|
|
365
|
+
return `${text}\n\nAttached image paths:\n${lines.join("\n")}`;
|
|
366
|
+
}
|
|
367
|
+
|
|
360
368
|
/**
|
|
361
369
|
* Async variant of imagesForText that runs each attachment through the
|
|
362
370
|
* Anthropic-aware image optimizer (resize to 8000px cap, JPEG ladder to stay
|
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,
|
|
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(
|
|
39
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
50
|
-
|
|
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
|
|