pi-paster 0.1.0

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.
@@ -0,0 +1,236 @@
1
+ import { existsSync, readFileSync, statSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { isAbsolute, resolve } from "node:path";
4
+ import { getImageDimensions } from "@earendil-works/pi-tui";
5
+ import type { AttachmentStore } from "./store.ts";
6
+ import {
7
+ MAX_IMAGE_BYTES,
8
+ type ImageAttachment,
9
+ type LoadImageResult,
10
+ type PasterImageContent,
11
+ type SupportedImageMimeType,
12
+ } from "./types.ts";
13
+
14
+ interface PathToken {
15
+ raw: string;
16
+ value: string;
17
+ start: number;
18
+ end: number;
19
+ }
20
+
21
+ export function detectImageMimeType(bytes: Uint8Array): SupportedImageMimeType | undefined {
22
+ if (
23
+ bytes.length >= 8 &&
24
+ bytes[0] === 0x89 &&
25
+ bytes[1] === 0x50 &&
26
+ bytes[2] === 0x4e &&
27
+ bytes[3] === 0x47 &&
28
+ bytes[4] === 0x0d &&
29
+ bytes[5] === 0x0a &&
30
+ bytes[6] === 0x1a &&
31
+ bytes[7] === 0x0a
32
+ ) {
33
+ return "image/png";
34
+ }
35
+ if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) {
36
+ return "image/jpeg";
37
+ }
38
+ if (
39
+ bytes.length >= 6 &&
40
+ bytes[0] === 0x47 &&
41
+ bytes[1] === 0x49 &&
42
+ bytes[2] === 0x46 &&
43
+ bytes[3] === 0x38 &&
44
+ (bytes[4] === 0x37 || bytes[4] === 0x39) &&
45
+ bytes[5] === 0x61
46
+ ) {
47
+ return "image/gif";
48
+ }
49
+ if (
50
+ bytes.length >= 12 &&
51
+ bytes[0] === 0x52 &&
52
+ bytes[1] === 0x49 &&
53
+ bytes[2] === 0x46 &&
54
+ bytes[3] === 0x46 &&
55
+ bytes[8] === 0x57 &&
56
+ bytes[9] === 0x45 &&
57
+ bytes[10] === 0x42 &&
58
+ bytes[11] === 0x50
59
+ ) {
60
+ return "image/webp";
61
+ }
62
+ return undefined;
63
+ }
64
+
65
+ export function resolveImagePath(input: string, cwd: string): string {
66
+ if (input === "~") return homedir();
67
+ if (input.startsWith("~/")) return resolve(homedir(), input.slice(2));
68
+ if (isAbsolute(input)) return input;
69
+ return resolve(cwd, input);
70
+ }
71
+
72
+ export function shellUnescape(input: string): string {
73
+ let result = "";
74
+ for (let i = 0; i < input.length; i++) {
75
+ const char = input[i]!;
76
+ if (char === "\\" && i + 1 < input.length) {
77
+ result += input[++i]!;
78
+ } else {
79
+ result += char;
80
+ }
81
+ }
82
+ return result;
83
+ }
84
+
85
+ function isPathLike(value: string): boolean {
86
+ return (
87
+ value.startsWith("/") ||
88
+ value.startsWith("~/") ||
89
+ value === "~" ||
90
+ value.startsWith("./") ||
91
+ value.startsWith("../")
92
+ );
93
+ }
94
+
95
+ export function tokenizePathLikeText(text: string): PathToken[] {
96
+ const tokens: PathToken[] = [];
97
+ let index = 0;
98
+
99
+ while (index < text.length) {
100
+ const char = text[index]!;
101
+ if (/\s/.test(char)) {
102
+ index++;
103
+ continue;
104
+ }
105
+
106
+ const start = index;
107
+ if (char === "'" || char === '"') {
108
+ const quote = char;
109
+ index++;
110
+ let value = "";
111
+ let closed = false;
112
+ while (index < text.length) {
113
+ const current = text[index]!;
114
+ if (current === "\\" && quote === '"' && index + 1 < text.length) {
115
+ value += text[index + 1]!;
116
+ index += 2;
117
+ continue;
118
+ }
119
+ if (current === quote) {
120
+ index++;
121
+ closed = true;
122
+ break;
123
+ }
124
+ value += current;
125
+ index++;
126
+ }
127
+ if (closed && isPathLike(value))
128
+ tokens.push({ raw: text.slice(start, index), value, start, end: index });
129
+ continue;
130
+ }
131
+
132
+ let rawValue = "";
133
+ while (index < text.length) {
134
+ const current = text[index]!;
135
+ if (/\s/.test(current)) break;
136
+ if (current === "\\" && index + 1 < text.length) {
137
+ rawValue += current + text[index + 1]!;
138
+ index += 2;
139
+ continue;
140
+ }
141
+ rawValue += current;
142
+ index++;
143
+ }
144
+ const value = shellUnescape(rawValue);
145
+ if (isPathLike(value)) tokens.push({ raw: rawValue, value, start, end: index });
146
+ }
147
+
148
+ return tokens;
149
+ }
150
+
151
+ export function dimensionsForImage(data: string, mimeType: SupportedImageMimeType) {
152
+ return getImageDimensions(data, mimeType) ?? undefined;
153
+ }
154
+
155
+ export function loadImageFromPath(
156
+ inputPath: string,
157
+ cwd: string,
158
+ maxBytes = MAX_IMAGE_BYTES,
159
+ ): LoadImageResult {
160
+ const path = resolveImagePath(inputPath, cwd);
161
+ try {
162
+ if (!existsSync(path)) return { ok: false, reason: "missing", path };
163
+ const stat = statSync(path);
164
+ if (!stat.isFile()) return { ok: false, reason: "not-file", path };
165
+ if (stat.size > maxBytes) return { ok: false, reason: "too-large", path };
166
+
167
+ const data = readFileSync(path);
168
+ const mimeType = detectImageMimeType(data);
169
+ if (!mimeType) return { ok: false, reason: "unsupported", path };
170
+
171
+ const base64Data = data.toString("base64");
172
+ return {
173
+ ok: true,
174
+ image: {
175
+ originalPath: path,
176
+ mimeType,
177
+ data: base64Data,
178
+ dimensions: dimensionsForImage(base64Data, mimeType),
179
+ },
180
+ };
181
+ } catch {
182
+ return { ok: false, reason: "read-error", path };
183
+ }
184
+ }
185
+
186
+ export function replaceImagePathsInText(
187
+ text: string,
188
+ options: {
189
+ cwd: string;
190
+ store: AttachmentStore;
191
+ loadImage?: (path: string, cwd: string) => LoadImageResult;
192
+ onReject?: (result: Exclude<LoadImageResult, { ok: true }>) => void;
193
+ },
194
+ ): { text: string; replaced: number; accepted: ImageAttachment[] } {
195
+ const tokens = tokenizePathLikeText(text);
196
+ if (tokens.length === 0) return { text, replaced: 0, accepted: [] };
197
+
198
+ let output = "";
199
+ let cursor = 0;
200
+ let replaced = 0;
201
+ const accepted: ImageAttachment[] = [];
202
+ const loadImage = options.loadImage ?? loadImageFromPath;
203
+
204
+ for (const token of tokens) {
205
+ const result = loadImage(token.value, options.cwd);
206
+ if (!result.ok) {
207
+ options.onReject?.(result);
208
+ continue;
209
+ }
210
+
211
+ const attachment = options.store.add(result.image);
212
+ accepted.push(attachment);
213
+ output += text.slice(cursor, token.start) + attachment.placeholder;
214
+ cursor = token.end;
215
+ replaced++;
216
+ }
217
+
218
+ if (replaced === 0) return { text, replaced: 0, accepted: [] };
219
+ output += text.slice(cursor);
220
+ return { text: output, replaced, accepted };
221
+ }
222
+
223
+ export function imagesForText(
224
+ store: AttachmentStore,
225
+ text: string,
226
+ existing: PasterImageContent[] = [],
227
+ ): PasterImageContent[] {
228
+ return [
229
+ ...existing,
230
+ ...store.matchingPlaceholders(text).map((attachment) => ({
231
+ type: "image" as const,
232
+ mimeType: attachment.mimeType,
233
+ data: attachment.data,
234
+ })),
235
+ ];
236
+ }
package/src/index.ts ADDED
@@ -0,0 +1,143 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+ import { readClipboardImage } from "./clipboard.ts";
3
+ import { type PasterConfig, resolvePasterConfig } from "./config.ts";
4
+ import { PasterEditor } from "./editor.ts";
5
+ import { imagesForText } from "./image-utils.ts";
6
+ import { CursorImagePreviewWidget, ImagePreviewMessage } from "./preview.ts";
7
+ import { AttachmentStore } from "./store.ts";
8
+ import { createImagePasteTerminalInputHandler } from "./terminal-input.ts";
9
+ import type { ImageAttachment, PasterPreviewDetails } from "./types.ts";
10
+
11
+ export * from "./clipboard.ts";
12
+ export * from "./config.ts";
13
+ export * from "./editor.ts";
14
+ export * from "./image-utils.ts";
15
+ export * from "./preview.ts";
16
+ export * from "./store.ts";
17
+ export * from "./terminal-input.ts";
18
+ export * from "./types.ts";
19
+
20
+ export function createPaster(config: PasterConfig = {}): (pi: ExtensionAPI) => void {
21
+ return (pi) => paster(pi, config);
22
+ }
23
+
24
+ export default function paster(pi: ExtensionAPI, config: PasterConfig = {}): void {
25
+ const resolvedConfig = resolvePasterConfig(config);
26
+ const store = new AttachmentStore();
27
+ let pendingPreview: ImageAttachment[] = [];
28
+ let activeEditor: PasterEditor | undefined;
29
+ let unsubscribeTerminalInput: (() => void) | undefined;
30
+
31
+ pi.registerMessageRenderer<PasterPreviewDetails>("paster-preview", (message, _options, theme) => {
32
+ const placeholders = message.details?.placeholders ?? [];
33
+ const attachments = store
34
+ .list()
35
+ .filter((attachment) => placeholders.includes(attachment.placeholder));
36
+ if (attachments.length === 0) return undefined;
37
+ return new ImagePreviewMessage(attachments, {
38
+ fallbackColor: (text) => theme.fg("muted", text),
39
+ });
40
+ });
41
+
42
+ pi.on("session_start", (_event, ctx) => {
43
+ store.clear();
44
+ pendingPreview = [];
45
+ if (!ctx.hasUI) return;
46
+
47
+ unsubscribeTerminalInput?.();
48
+ unsubscribeTerminalInput = undefined;
49
+ activeEditor?.clearCursorPreview();
50
+ activeEditor = undefined;
51
+ ctx.ui.setWidget("paster-cursor-preview", undefined, { placement: "aboveEditor" });
52
+
53
+ if (!resolvedConfig.customEditor.enabled) {
54
+ unsubscribeTerminalInput = ctx.ui.onTerminalInput(
55
+ createImagePasteTerminalInputHandler({
56
+ cwd: ctx.cwd,
57
+ store,
58
+ notify: (message) => ctx.ui.notify(message, "warning"),
59
+ }),
60
+ );
61
+ return;
62
+ }
63
+
64
+ ctx.ui.setEditorComponent((tui, theme, keybindings) => {
65
+ activeEditor = new PasterEditor(tui, theme, keybindings, {
66
+ cwd: ctx.cwd,
67
+ store,
68
+ notify: (message) => ctx.ui.notify(message, "warning"),
69
+ deletePlaceholderAsBlock: resolvedConfig.customEditor.deletePlaceholderAsBlock,
70
+ pasteClipboardImage: () => {
71
+ const result = readClipboardImage();
72
+ if (!result.ok) {
73
+ if (result.reason !== "empty" && result.reason !== "unsupported-platform") {
74
+ ctx.ui.notify("paster: clipboard image could not be attached", "warning");
75
+ }
76
+ return undefined;
77
+ }
78
+ return store.add(result.image);
79
+ },
80
+ setCursorPreview: (attachment) => {
81
+ if (!resolvedConfig.customEditor.showImagePreview) return;
82
+ ctx.ui.setWidget(
83
+ "paster-cursor-preview",
84
+ attachment
85
+ ? (_tui, widgetTheme) =>
86
+ new CursorImagePreviewWidget(attachment, {
87
+ title: (text) => widgetTheme.fg("accent", text),
88
+ muted: (text) => widgetTheme.fg("muted", text),
89
+ accent: (text) => widgetTheme.fg("accent", text),
90
+ })
91
+ : undefined,
92
+ { placement: "aboveEditor" },
93
+ );
94
+ },
95
+ });
96
+ return activeEditor;
97
+ });
98
+ });
99
+
100
+ pi.on("session_shutdown", (_event, ctx) => {
101
+ pendingPreview = [];
102
+ if (ctx.hasUI) {
103
+ unsubscribeTerminalInput?.();
104
+ unsubscribeTerminalInput = undefined;
105
+ activeEditor?.clearCursorPreview();
106
+ activeEditor = undefined;
107
+ ctx.ui.setWidget("paster-cursor-preview", undefined, { placement: "aboveEditor" });
108
+ ctx.ui.setEditorComponent(undefined);
109
+ }
110
+ store.clear();
111
+ });
112
+
113
+ pi.on("input", (event, ctx) => {
114
+ if (event.source === "extension") return { action: "continue" as const };
115
+ if (ctx.hasUI) {
116
+ activeEditor?.clearCursorPreview();
117
+ }
118
+
119
+ const attachments = store.matchingPlaceholders(event.text);
120
+ if (attachments.length === 0) return { action: "continue" as const };
121
+ pendingPreview = attachments;
122
+
123
+ return {
124
+ action: "transform" as const,
125
+ text: event.text,
126
+ images: imagesForText(store, event.text, event.images),
127
+ };
128
+ });
129
+
130
+ pi.on("before_agent_start", () => {
131
+ if (pendingPreview.length === 0) return;
132
+ const placeholders = pendingPreview.map((attachment) => attachment.placeholder);
133
+ pendingPreview = [];
134
+ return {
135
+ message: {
136
+ customType: "paster-preview",
137
+ content: "",
138
+ display: true,
139
+ details: { placeholders },
140
+ },
141
+ };
142
+ });
143
+ }
package/src/preview.ts ADDED
@@ -0,0 +1,102 @@
1
+ import { basename } from "node:path";
2
+ import {
3
+ getCellDimensions,
4
+ Image,
5
+ type Component,
6
+ type ImageTheme,
7
+ truncateToWidth,
8
+ } from "@earendil-works/pi-tui";
9
+ import type { ImageAttachment } from "./types.ts";
10
+
11
+ export class ImagePreviewMessage implements Component {
12
+ private readonly images: Image[];
13
+
14
+ constructor(
15
+ private readonly attachments: ImageAttachment[],
16
+ private readonly theme: ImageTheme,
17
+ ) {
18
+ this.images = attachments.map(
19
+ (attachment) =>
20
+ new Image(attachment.data, attachment.mimeType, theme, {
21
+ maxWidthCells: 60,
22
+ maxHeightCells: 16,
23
+ filename: attachment.placeholder,
24
+ }),
25
+ );
26
+ }
27
+
28
+ render(width: number): string[] {
29
+ const lines: string[] = [];
30
+ for (let index = 0; index < this.attachments.length; index++) {
31
+ lines.push(
32
+ this.theme.fallbackColor(
33
+ `Attached ${this.attachments[index]!.placeholder} (${this.attachments[index]!.mimeType})`,
34
+ ),
35
+ );
36
+ lines.push(...this.images[index]!.render(width));
37
+ }
38
+ return lines;
39
+ }
40
+
41
+ invalidate(): void {
42
+ for (const image of this.images) image.invalidate();
43
+ }
44
+ }
45
+
46
+ interface CursorPreviewTheme {
47
+ title: (text: string) => string;
48
+ muted: (text: string) => string;
49
+ accent: (text: string) => string;
50
+ }
51
+
52
+ export class CursorImagePreviewWidget implements Component {
53
+ private image: Image;
54
+
55
+ constructor(
56
+ private attachment: ImageAttachment,
57
+ private readonly theme: CursorPreviewTheme,
58
+ ) {
59
+ this.image = this.createImage(attachment);
60
+ }
61
+
62
+ render(width: number): string[] {
63
+ const imageWidth = this.constrainedImageWidth(width);
64
+ this.image = this.createImage(this.attachment, imageWidth);
65
+ return [this.headerLine(width), ...this.image.render(imageWidth + 2)];
66
+ }
67
+
68
+ invalidate(): void {
69
+ this.image.invalidate();
70
+ }
71
+
72
+ private headerLine(width: number): string {
73
+ const title = `${this.attachment.placeholder} ${basename(this.attachment.originalPath)}`;
74
+ return this.theme.title(truncateToWidth(title, Math.max(1, width), ""));
75
+ }
76
+
77
+ private createImage(attachment: ImageAttachment, maxWidthCells = 60): Image {
78
+ return new Image(
79
+ attachment.data,
80
+ attachment.mimeType,
81
+ { fallbackColor: this.theme.accent },
82
+ {
83
+ maxWidthCells,
84
+ filename: attachment.placeholder,
85
+ },
86
+ attachment.dimensions,
87
+ );
88
+ }
89
+
90
+ private constrainedImageWidth(width: number): number {
91
+ const maxWidth = Math.max(1, Math.min(60, width - 2));
92
+ const maxRows = 14;
93
+ const dimensions = this.attachment.dimensions;
94
+ if (!dimensions || dimensions.widthPx <= 0 || dimensions.heightPx <= 0) return maxWidth;
95
+
96
+ const cell = getCellDimensions();
97
+ const widthForMaxRows = Math.floor(
98
+ (maxRows * cell.heightPx * dimensions.widthPx) / (dimensions.heightPx * cell.widthPx),
99
+ );
100
+ return Math.max(1, Math.min(maxWidth, widthForMaxRows));
101
+ }
102
+ }
package/src/store.ts ADDED
@@ -0,0 +1,40 @@
1
+ import type { ImageAttachment } from "./types.ts";
2
+
3
+ export class AttachmentStore {
4
+ private nextId = 1;
5
+ private readonly attachments = new Map<string, ImageAttachment>();
6
+
7
+ clear(): void {
8
+ this.nextId = 1;
9
+ this.attachments.clear();
10
+ }
11
+
12
+ list(): ImageAttachment[] {
13
+ return [...this.attachments.values()].sort((a, b) => a.id - b.id);
14
+ }
15
+
16
+ add(input: Omit<ImageAttachment, "id" | "placeholder" | "createdAt">): ImageAttachment {
17
+ const id = this.nextId++;
18
+ const attachment: ImageAttachment = {
19
+ ...input,
20
+ id,
21
+ placeholder: `[#image ${id}]`,
22
+ createdAt: Date.now(),
23
+ };
24
+ this.attachments.set(attachment.placeholder, attachment);
25
+ return attachment;
26
+ }
27
+
28
+ get(placeholder: string): ImageAttachment | undefined {
29
+ return this.attachments.get(placeholder);
30
+ }
31
+
32
+ matchingPlaceholders(text: string): ImageAttachment[] {
33
+ const matches = this.list()
34
+ .map((attachment) => ({ attachment, index: text.indexOf(attachment.placeholder) }))
35
+ .filter((match) => match.index >= 0)
36
+ .sort((a, b) => a.index - b.index);
37
+
38
+ return matches.map((match) => match.attachment);
39
+ }
40
+ }
@@ -0,0 +1,58 @@
1
+ import { PASTE_END, PASTE_START } from "./editor.ts";
2
+ import { replaceImagePathsInText } from "./image-utils.ts";
3
+ import type { AttachmentStore } from "./store.ts";
4
+ import type { ImageAttachment, LoadImageResult } from "./types.ts";
5
+
6
+ export type TerminalInputResult = { consume?: boolean; data?: string } | undefined;
7
+
8
+ export function createImagePasteTerminalInputHandler(options: {
9
+ cwd: string;
10
+ store: AttachmentStore;
11
+ notify?: (message: string) => void;
12
+ onAccept?: (attachments: ImageAttachment[]) => void;
13
+ loadImage?: (path: string, cwd: string) => LoadImageResult;
14
+ }): (data: string) => TerminalInputResult {
15
+ let pasteBuffer: string | undefined;
16
+
17
+ const transform = (text: string) =>
18
+ replaceImagePathsInText(text, {
19
+ cwd: options.cwd,
20
+ store: options.store,
21
+ loadImage: options.loadImage,
22
+ onReject: (result) => {
23
+ if (result.reason === "too-large") {
24
+ options.notify?.(`paster: image is over 10 MB and was not attached: ${result.path}`);
25
+ }
26
+ },
27
+ });
28
+
29
+ return (data: string): TerminalInputResult => {
30
+ let prefix = "";
31
+ const wasBuffered = pasteBuffer !== undefined;
32
+ if (pasteBuffer === undefined) {
33
+ const start = data.indexOf(PASTE_START);
34
+ if (start === -1) return undefined;
35
+
36
+ prefix = data.slice(0, start);
37
+ pasteBuffer = data.slice(start + PASTE_START.length);
38
+ if (!pasteBuffer.includes(PASTE_END)) {
39
+ return prefix ? { data: prefix } : { consume: true };
40
+ }
41
+ } else {
42
+ pasteBuffer += data;
43
+ if (!pasteBuffer.includes(PASTE_END)) return { consume: true };
44
+ }
45
+
46
+ const end = pasteBuffer.indexOf(PASTE_END);
47
+ const content = pasteBuffer.slice(0, end);
48
+ const remaining = pasteBuffer.slice(end + PASTE_END.length);
49
+ pasteBuffer = undefined;
50
+
51
+ const transformed = transform(content);
52
+ if (transformed.replaced === 0) {
53
+ return wasBuffered ? { data: `${PASTE_START}${content}${PASTE_END}${remaining}` } : undefined;
54
+ }
55
+ options.onAccept?.(transformed.accepted);
56
+ return { data: `${prefix}${transformed.text}${remaining}` };
57
+ };
58
+ }
package/src/types.ts ADDED
@@ -0,0 +1,41 @@
1
+ import type { ImageDimensions } from "@earendil-works/pi-tui";
2
+
3
+ export const EXTENSION_NAME = "paster";
4
+ export const MAX_IMAGE_BYTES = 10 * 1024 * 1024;
5
+
6
+ export type SupportedImageMimeType = "image/png" | "image/jpeg" | "image/webp" | "image/gif";
7
+
8
+ export interface ImageAttachment {
9
+ id: number;
10
+ placeholder: string;
11
+ originalPath: string;
12
+ mimeType: SupportedImageMimeType;
13
+ data: string;
14
+ dimensions?: ImageDimensions;
15
+ createdAt: number;
16
+ }
17
+
18
+ export interface LoadedImage {
19
+ originalPath: string;
20
+ mimeType: SupportedImageMimeType;
21
+ data: string;
22
+ dimensions?: ImageDimensions;
23
+ }
24
+
25
+ export interface PasterImageContent {
26
+ type: "image";
27
+ mimeType: string;
28
+ data: string;
29
+ }
30
+
31
+ export type LoadImageResult =
32
+ | { ok: true; image: LoadedImage }
33
+ | {
34
+ ok: false;
35
+ reason: "missing" | "not-file" | "too-large" | "unsupported" | "read-error";
36
+ path: string;
37
+ };
38
+
39
+ export interface PasterPreviewDetails {
40
+ placeholders: string[];
41
+ }