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.
- package/README.md +135 -0
- package/dist/index.d.mts +177 -0
- package/dist/index.mjs +634 -0
- package/docs/preview.png +0 -0
- package/package.json +53 -0
- package/spec.md +349 -0
- package/src/clipboard.ts +77 -0
- package/src/config.ts +40 -0
- package/src/editor.ts +214 -0
- package/src/image-utils.ts +236 -0
- package/src/index.ts +143 -0
- package/src/preview.ts +102 -0
- package/src/store.ts +40 -0
- package/src/terminal-input.ts +58 -0
- package/src/types.ts +41 -0
package/spec.md
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
# Pi Image Paste Attachments Extension
|
|
2
|
+
|
|
3
|
+
## Goal
|
|
4
|
+
|
|
5
|
+
Create a pi extension that makes pasted or drag-dropped image paths behave like first-class image attachments in interactive mode.
|
|
6
|
+
|
|
7
|
+
Today, pi can read image files when the assistant/tool explicitly calls `read`, and `Ctrl+V` on an image clipboard saves the clipboard image to a temp file and inserts that temp path into the editor. The desired UX is more direct:
|
|
8
|
+
|
|
9
|
+
1. User pastes or drag-drops an image path, or uses pi's image clipboard paste flow.
|
|
10
|
+
2. The editor replaces the raw file path with a readable placeholder such as `[#image1]`.
|
|
11
|
+
3. The extension stores the corresponding image data immediately.
|
|
12
|
+
4. On submit, the LLM receives the user's text first, with placeholders preserved, followed by the attached image blocks. The assistant should not need to call `read` just to see the image.
|
|
13
|
+
|
|
14
|
+
## Non-goals for MVP
|
|
15
|
+
|
|
16
|
+
- No image description/captioning model yet.
|
|
17
|
+
- No file browser or attachment picker.
|
|
18
|
+
- No editing UI beyond visible placeholder text in the normal editor.
|
|
19
|
+
- No permanent attachment storage across pi restarts.
|
|
20
|
+
- No changes to pi core unless extension APIs prove insufficient.
|
|
21
|
+
|
|
22
|
+
## UX
|
|
23
|
+
|
|
24
|
+
### Paste or drag-drop image path
|
|
25
|
+
|
|
26
|
+
User action:
|
|
27
|
+
|
|
28
|
+
```text
|
|
29
|
+
Here is the bug /var/folders/.../pi-clipboard-abc.png
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Editor should become:
|
|
33
|
+
|
|
34
|
+
```text
|
|
35
|
+
Here is the bug [#image1]
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
A path-only paste must also be handled as a first-class case:
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
/var/folders/.../pi-clipboard-abc.png
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Editor should become:
|
|
45
|
+
|
|
46
|
+
```text
|
|
47
|
+
[#image1]
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
This path-only behavior is important because terminal drag-and-drop commonly inserts only the dropped file path. If that single pasted/dropped token is an image path, the extension should consume it, store the image payload, and insert only the placeholder.
|
|
51
|
+
|
|
52
|
+
Placeholder format:
|
|
53
|
+
|
|
54
|
+
- Use `[#image1]`, `[#image2]`, etc.
|
|
55
|
+
- No space inside the token. This makes placeholders easy to scan, easy to match exactly, and unlikely to conflict with normal prose.
|
|
56
|
+
- The number is allocated when the image is accepted into attachment state.
|
|
57
|
+
|
|
58
|
+
The extension records:
|
|
59
|
+
|
|
60
|
+
- placeholder: `[#image1]`
|
|
61
|
+
- original path: `/var/folders/.../pi-clipboard-abc.png`
|
|
62
|
+
- mime type: `image/png`
|
|
63
|
+
- base64 image payload
|
|
64
|
+
- optional size/dimensions metadata if easy to compute
|
|
65
|
+
|
|
66
|
+
### Multiple images
|
|
67
|
+
|
|
68
|
+
If a paste contains multiple image paths:
|
|
69
|
+
|
|
70
|
+
```text
|
|
71
|
+
/Users/me/a.png /Users/me/b.jpg
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Editor should insert:
|
|
75
|
+
|
|
76
|
+
```text
|
|
77
|
+
[#image1] [#image2]
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Mixed paste
|
|
81
|
+
|
|
82
|
+
If a paste contains text and image paths, only image path tokens are replaced:
|
|
83
|
+
|
|
84
|
+
```text
|
|
85
|
+
compare /tmp/before.png with /tmp/after.png please
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
becomes:
|
|
89
|
+
|
|
90
|
+
```text
|
|
91
|
+
compare [#image1] with [#image2] please
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Submit behavior
|
|
95
|
+
|
|
96
|
+
When the user submits:
|
|
97
|
+
|
|
98
|
+
```text
|
|
99
|
+
What's wrong here? [#image1]
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
The extension should submit a single user message whose content order is equivalent to:
|
|
103
|
+
|
|
104
|
+
1. Text block: `What's wrong here? [#image1]`
|
|
105
|
+
2. Image block for `[#image1]`
|
|
106
|
+
|
|
107
|
+
This keeps the user's placeholder references in the prompt while attaching the actual image payload to the same turn.
|
|
108
|
+
|
|
109
|
+
## Technical design
|
|
110
|
+
|
|
111
|
+
### Extension APIs to use
|
|
112
|
+
|
|
113
|
+
Relevant pi extension APIs from the docs:
|
|
114
|
+
|
|
115
|
+
- `ctx.ui.setEditorComponent(...)` to install a custom editor.
|
|
116
|
+
- `CustomEditor` to preserve app-level keybindings and default editor behavior.
|
|
117
|
+
- `ctx.ui.getEditorComponent()` to detect/wrap a previously configured editor where possible.
|
|
118
|
+
- `pi.on("input", ...)` to transform submitted input and attach images.
|
|
119
|
+
- `event.images` / transformed `images` for attached image blocks.
|
|
120
|
+
- `ctx.ui.notify(...)` for non-blocking warnings.
|
|
121
|
+
|
|
122
|
+
### Editor interception
|
|
123
|
+
|
|
124
|
+
Implement an `ImageAttachmentEditor extends CustomEditor`.
|
|
125
|
+
|
|
126
|
+
Responsibilities:
|
|
127
|
+
|
|
128
|
+
1. Intercept bracketed paste input before default paste handling.
|
|
129
|
+
2. Parse pasted content into tokens, preserving non-image text.
|
|
130
|
+
3. For each candidate image path:
|
|
131
|
+
- resolve shell quoting/escaping,
|
|
132
|
+
- verify the file exists,
|
|
133
|
+
- detect supported image MIME type,
|
|
134
|
+
- read and store the image payload,
|
|
135
|
+
- insert a placeholder instead of the raw path.
|
|
136
|
+
4. Fall back to `super.handleInput(data)` for all non-image paste/input.
|
|
137
|
+
|
|
138
|
+
Important implementation note: `Editor.handlePaste()` is private in the pi-tui TypeScript declarations, so the extension should not override it directly. Instead, intercept bracketed paste sequences in `handleInput()` before delegating to `super.handleInput()`.
|
|
139
|
+
|
|
140
|
+
### Support pi's existing Ctrl+V image clipboard flow
|
|
141
|
+
|
|
142
|
+
Pi's existing image clipboard handler saves clipboard images to a temp file and calls `editor.insertTextAtCursor(filePath)`.
|
|
143
|
+
|
|
144
|
+
To convert that flow into placeholders too, override `insertTextAtCursor(text)` in `ImageAttachmentEditor`:
|
|
145
|
+
|
|
146
|
+
- If `text` is a recognized image path, read/store it and insert `[#imageN]`.
|
|
147
|
+
- Otherwise delegate to `super.insertTextAtCursor(text)`.
|
|
148
|
+
|
|
149
|
+
This lets the extension reuse pi's current clipboard-to-temp-file mechanism without reimplementing native clipboard image access.
|
|
150
|
+
|
|
151
|
+
### Attachment state
|
|
152
|
+
|
|
153
|
+
Maintain in-memory attachment state in the extension runtime:
|
|
154
|
+
|
|
155
|
+
```ts
|
|
156
|
+
interface ImageAttachment {
|
|
157
|
+
id: number;
|
|
158
|
+
placeholder: string; // e.g. "[#image1]"
|
|
159
|
+
originalPath: string;
|
|
160
|
+
mimeType: "image/png" | "image/jpeg" | "image/webp" | "image/gif";
|
|
161
|
+
data: string; // base64
|
|
162
|
+
createdAt: number;
|
|
163
|
+
}
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
State rules:
|
|
167
|
+
|
|
168
|
+
- IDs increment per session/runtime.
|
|
169
|
+
- Keep attachments even if placeholders are later deleted; only submit attachments whose placeholder still appears in the submitted text.
|
|
170
|
+
- Attach each placeholder at most once per submitted message, ordered by first occurrence in the text.
|
|
171
|
+
- Clear attachment state on `/new`, `/resume`, `/fork`, or extension reload via `session_start`/`session_shutdown` lifecycle.
|
|
172
|
+
|
|
173
|
+
### Input transformation
|
|
174
|
+
|
|
175
|
+
Register an `input` handler:
|
|
176
|
+
|
|
177
|
+
1. Ignore `event.source === "extension"` to avoid recursion.
|
|
178
|
+
2. Scan `event.text` for known placeholders.
|
|
179
|
+
3. Build `images` from matching attachment payloads.
|
|
180
|
+
4. Return:
|
|
181
|
+
|
|
182
|
+
```ts
|
|
183
|
+
return {
|
|
184
|
+
action: "transform",
|
|
185
|
+
text: event.text,
|
|
186
|
+
images: [...(event.images ?? []), ...matchedImages],
|
|
187
|
+
};
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
If no placeholders are found, return `continue`.
|
|
191
|
+
|
|
192
|
+
### Detection and parsing
|
|
193
|
+
|
|
194
|
+
The parser must explicitly support path-only paste/drop content. If the complete pasted input is a single image path, possibly quoted or shell-escaped, it should be replaced by one placeholder and should not fall back to default paste behavior. This is the primary drag-and-drop path for many terminals.
|
|
195
|
+
|
|
196
|
+
Supported image formats for MVP:
|
|
197
|
+
|
|
198
|
+
- PNG
|
|
199
|
+
- JPEG
|
|
200
|
+
- WebP
|
|
201
|
+
- GIF
|
|
202
|
+
|
|
203
|
+
Path parsing should handle:
|
|
204
|
+
|
|
205
|
+
- absolute paths: `/tmp/a.png`
|
|
206
|
+
- home paths: `~/Desktop/a.png`
|
|
207
|
+
- relative paths: `./a.png`
|
|
208
|
+
- shell-escaped spaces: `/Users/me/Desktop/My\ Image.png`
|
|
209
|
+
- quoted paths: `'/Users/me/My Image.png'`, `"/Users/me/My Image.png"`
|
|
210
|
+
- multiple paths separated by whitespace or newlines
|
|
211
|
+
|
|
212
|
+
MIME detection should use magic bytes, not only file extension. Extension-only implementation can keep this small:
|
|
213
|
+
|
|
214
|
+
- PNG: `89 50 4E 47 0D 0A 1A 0A`
|
|
215
|
+
- JPEG: `FF D8 FF`
|
|
216
|
+
- GIF: `GIF87a` or `GIF89a`
|
|
217
|
+
- WebP: `RIFF....WEBP`
|
|
218
|
+
|
|
219
|
+
### Image size handling
|
|
220
|
+
|
|
221
|
+
MVP can attach original base64 data, but should include a conservative max file size guard to avoid accidental huge context/provider payloads.
|
|
222
|
+
|
|
223
|
+
Suggested defaults:
|
|
224
|
+
|
|
225
|
+
- Max image file size: 10 MB.
|
|
226
|
+
- If over limit: leave path unchanged and show a warning.
|
|
227
|
+
|
|
228
|
+
Future improvement: use pi's internal image resize logic if it becomes exported/stable, or add a dependency such as `sharp`/`jimp` in the extension package.
|
|
229
|
+
|
|
230
|
+
## Open question: same user message vs additional user messages
|
|
231
|
+
|
|
232
|
+
The preferred MVP is a single user message containing:
|
|
233
|
+
|
|
234
|
+
- one text block with placeholders,
|
|
235
|
+
- then image blocks in placeholder order.
|
|
236
|
+
|
|
237
|
+
This is simpler and avoids multiple turns. `pi.sendUserMessage()` always triggers a turn, so creating separate user messages for each image would likely cause unwanted agent execution unless pi exposes a batch append/send API.
|
|
238
|
+
|
|
239
|
+
If we later need visually separate image messages, investigate whether `input` handling can return `handled` and manually append entries without triggering multiple turns. That is not part of MVP.
|
|
240
|
+
|
|
241
|
+
## Edge cases
|
|
242
|
+
|
|
243
|
+
- Placeholder deleted before submit: do not attach that image.
|
|
244
|
+
- Placeholder copied/duplicated: attach once; text can refer to it multiple times.
|
|
245
|
+
- User manually types `[#image1]`: attach only if it matches existing attachment state.
|
|
246
|
+
- File deleted after paste: not a problem if image payload was read immediately.
|
|
247
|
+
- Paste is not an image path: preserve default pi paste behavior.
|
|
248
|
+
- Unsupported image file: leave text unchanged and optionally notify.
|
|
249
|
+
- Busy/streaming state: input transform should work for queued steer/follow-up messages too, because images are part of the transformed prompt.
|
|
250
|
+
- Non-interactive modes: extension should no-op custom editor setup when `ctx.hasUI` is false; input transform may still work for text containing known placeholders only within the same runtime.
|
|
251
|
+
|
|
252
|
+
## Commands and tools
|
|
253
|
+
|
|
254
|
+
No slash commands or LLM-callable tools for the MVP. The extension should work automatically from paste/edit/submit behavior. Debugging helpers can be reconsidered later, but they should not be part of the initial UX.
|
|
255
|
+
|
|
256
|
+
## Testing strategy
|
|
257
|
+
|
|
258
|
+
Use a layered test approach:
|
|
259
|
+
|
|
260
|
+
1. Unit tests for pure helpers:
|
|
261
|
+
- placeholder allocation (`[#image1]`, `[#image2]`, ...),
|
|
262
|
+
- placeholder matching and submit ordering,
|
|
263
|
+
- path tokenization and shell unescaping,
|
|
264
|
+
- path resolution for absolute, home, and relative paths,
|
|
265
|
+
- MIME detection from magic bytes,
|
|
266
|
+
- max file size rejection,
|
|
267
|
+
- image loading to base64.
|
|
268
|
+
2. Editor-level tests where feasible:
|
|
269
|
+
- path-only paste becomes one placeholder,
|
|
270
|
+
- mixed text plus image paths preserves non-image text,
|
|
271
|
+
- multiple image paths become multiple placeholders,
|
|
272
|
+
- non-image paste delegates to default behavior.
|
|
273
|
+
3. Input-transform tests:
|
|
274
|
+
- only placeholders still present in submitted text are attached,
|
|
275
|
+
- duplicated placeholders attach once,
|
|
276
|
+
- attachments are ordered by first placeholder occurrence,
|
|
277
|
+
- existing `event.images` are preserved before extension-added images.
|
|
278
|
+
4. Manual TUI smoke tests for behavior that depends on pi's interactive editor or terminal integration:
|
|
279
|
+
- Ctrl+V image clipboard on macOS,
|
|
280
|
+
- dragging an image file from Finder into the terminal,
|
|
281
|
+
- pasting a path with spaces,
|
|
282
|
+
- deleting a placeholder before submit,
|
|
283
|
+
- confirming Escape, Ctrl+C, Ctrl+D, Ctrl+P, Ctrl+L, and Enter still behave normally.
|
|
284
|
+
|
|
285
|
+
Run automated tests with `vp test run`; run formatting, linting, and type checks with `vp check`.
|
|
286
|
+
|
|
287
|
+
## Implementation plan
|
|
288
|
+
|
|
289
|
+
1. Implement the package extension entrypoint at `src/index.ts` (the package manifest already exposes it via `pi.extensions`).
|
|
290
|
+
2. Define attachment state and helpers:
|
|
291
|
+
- path tokenization/unescaping,
|
|
292
|
+
- path resolution,
|
|
293
|
+
- MIME detection,
|
|
294
|
+
- image loading to base64,
|
|
295
|
+
- placeholder allocation.
|
|
296
|
+
3. Implement `ImageAttachmentEditor`:
|
|
297
|
+
- intercept bracketed paste,
|
|
298
|
+
- override `insertTextAtCursor`,
|
|
299
|
+
- delegate to `super` for all other input.
|
|
300
|
+
4. Register editor component on `session_start` when `ctx.hasUI`.
|
|
301
|
+
5. Register `input` event transformer to attach images by placeholder order.
|
|
302
|
+
6. Manual test scenarios:
|
|
303
|
+
- Ctrl+V image clipboard on macOS.
|
|
304
|
+
- Drag image file from Finder into terminal.
|
|
305
|
+
- Paste a normal image path.
|
|
306
|
+
- Paste path with spaces.
|
|
307
|
+
- Paste multiple images.
|
|
308
|
+
- Delete placeholder before submit.
|
|
309
|
+
- Submit while agent is streaming as steer/follow-up.
|
|
310
|
+
|
|
311
|
+
## API research notes for implementers
|
|
312
|
+
|
|
313
|
+
Use these references before re-researching pi/TUI internals:
|
|
314
|
+
|
|
315
|
+
- Extension docs: `/Users/beowulf/.vite-plus/packages/@earendil-works/pi-coding-agent/lib/node_modules/@earendil-works/pi-coding-agent/docs/extensions.md`
|
|
316
|
+
- Input transform API is in the "Input Events" section.
|
|
317
|
+
- Custom editor API examples are in the "Custom Editor" section.
|
|
318
|
+
- `ctx.ui.getEditorComponent()`/`setEditorComponent()` wrapping examples are near the UI API examples.
|
|
319
|
+
- TUI docs: `/Users/beowulf/.vite-plus/packages/@earendil-works/pi-coding-agent/lib/node_modules/@earendil-works/pi-coding-agent/docs/tui.md`
|
|
320
|
+
- Pattern 7 documents using `CustomEditor` while preserving app keybindings.
|
|
321
|
+
- Example custom editors:
|
|
322
|
+
- `node_modules/@earendil-works/pi-coding-agent/examples/extensions/modal-editor.ts`
|
|
323
|
+
- `node_modules/@earendil-works/pi-coding-agent/examples/extensions/rainbow-editor.ts`
|
|
324
|
+
- Runtime type declarations used during implementation:
|
|
325
|
+
- `node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/components/custom-editor.d.ts`
|
|
326
|
+
- `CustomEditor` constructor: `(tui, theme, keybindings, options?)`.
|
|
327
|
+
- Public hooks include `handleInput(data)` and `insertTextAtCursor(text)` via the base `Editor`.
|
|
328
|
+
- `node_modules/@earendil-works/pi-tui/dist/components/editor.d.ts`
|
|
329
|
+
- `Editor` exposes `getText()`, `getExpandedText()`, `setText()`, `insertTextAtCursor()`, and `handleInput()`.
|
|
330
|
+
- `handlePaste()` is private; do not override it.
|
|
331
|
+
- `node_modules/@earendil-works/pi-ai/dist/types.d.ts` (resolved through pnpm store if not directly symlinked)
|
|
332
|
+
- `ImageContent` shape is `{ type: "image"; data: string; mimeType: string }`.
|
|
333
|
+
- `node_modules/@earendil-works/pi-coding-agent/dist/core/extensions/types.d.ts`
|
|
334
|
+
- `InputEventResult` transform shape is `{ action: "transform"; text; images? }`.
|
|
335
|
+
- Bracketed paste details are visible in `node_modules/@earendil-works/pi-tui/dist/components/editor.js`:
|
|
336
|
+
- Paste start: `\x1b[200~`.
|
|
337
|
+
- Paste end: `\x1b[201~`.
|
|
338
|
+
- The built-in editor buffers paste content and calls its private `handlePaste()`; this extension should intercept complete bracketed paste sequences in `CustomEditor.handleInput()` before delegating.
|
|
339
|
+
- Existing pi image clipboard flow is in `node_modules/@earendil-works/pi-coding-agent/dist/modes/interactive/interactive-mode.js`:
|
|
340
|
+
- It saves a clipboard image to a temp file, then calls `editor.insertTextAtCursor?.(filePath)`, so overriding `insertTextAtCursor()` is the public extension hook for that path.
|
|
341
|
+
|
|
342
|
+
## Acceptance criteria
|
|
343
|
+
|
|
344
|
+
- Pasting or drag-dropping an image path replaces it with `[#imageN]` in the editor.
|
|
345
|
+
- Ctrl+V image clipboard still works, but inserts `[#imageN]` instead of the temp path.
|
|
346
|
+
- Submitting a prompt with `[#imageN]` sends the actual image as an attachment in the same user turn.
|
|
347
|
+
- The assistant can answer image-content questions without first calling the `read` tool.
|
|
348
|
+
- Non-image pastes behave exactly like default pi.
|
|
349
|
+
- The extension does not break core app keybindings such as Escape, Ctrl+C, Ctrl+D, Ctrl+P, Ctrl+L, and Enter.
|
package/src/clipboard.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { readFileSync, unlinkSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { detectImageMimeType, dimensionsForImage } from "./image-utils.ts";
|
|
7
|
+
import { MAX_IMAGE_BYTES, type LoadedImage } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
export type ClipboardImageResult =
|
|
10
|
+
| { ok: true; image: LoadedImage }
|
|
11
|
+
| {
|
|
12
|
+
ok: false;
|
|
13
|
+
reason: "empty" | "unsupported-platform" | "too-large" | "unsupported" | "read-error";
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export function readClipboardImage(maxBytes = MAX_IMAGE_BYTES): ClipboardImageResult {
|
|
17
|
+
if (process.platform !== "darwin") return { ok: false, reason: "unsupported-platform" };
|
|
18
|
+
return readMacOSClipboardImage(maxBytes);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function readMacOSClipboardImage(maxBytes: number): ClipboardImageResult {
|
|
22
|
+
const attempts = [
|
|
23
|
+
{ appleScriptClass: "PNGf", extension: "png" },
|
|
24
|
+
{ appleScriptClass: "JPEG", extension: "jpg" },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
for (const attempt of attempts) {
|
|
28
|
+
const tmpFile = join(tmpdir(), `paster-clipboard-${randomUUID()}.${attempt.extension}`);
|
|
29
|
+
try {
|
|
30
|
+
const result = spawnSync(
|
|
31
|
+
"osascript",
|
|
32
|
+
[
|
|
33
|
+
"-e",
|
|
34
|
+
`set imageData to the clipboard as «class ${attempt.appleScriptClass}»`,
|
|
35
|
+
"-e",
|
|
36
|
+
`set outputFile to open for access POSIX file ${JSON.stringify(tmpFile)} with write permission`,
|
|
37
|
+
"-e",
|
|
38
|
+
"set eof of outputFile to 0",
|
|
39
|
+
"-e",
|
|
40
|
+
"write imageData to outputFile",
|
|
41
|
+
"-e",
|
|
42
|
+
"close access outputFile",
|
|
43
|
+
],
|
|
44
|
+
{ timeout: 3000, stdio: "ignore" },
|
|
45
|
+
);
|
|
46
|
+
if (result.status !== 0) continue;
|
|
47
|
+
|
|
48
|
+
const bytes = readFileSync(tmpFile);
|
|
49
|
+
if (bytes.length === 0) continue;
|
|
50
|
+
if (bytes.length > maxBytes) return { ok: false, reason: "too-large" };
|
|
51
|
+
|
|
52
|
+
const mimeType = detectImageMimeType(bytes);
|
|
53
|
+
if (!mimeType) continue;
|
|
54
|
+
|
|
55
|
+
const data = bytes.toString("base64");
|
|
56
|
+
return {
|
|
57
|
+
ok: true,
|
|
58
|
+
image: {
|
|
59
|
+
originalPath: `clipboard.${attempt.extension}`,
|
|
60
|
+
mimeType,
|
|
61
|
+
data,
|
|
62
|
+
dimensions: dimensionsForImage(data, mimeType),
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
} catch {
|
|
66
|
+
return { ok: false, reason: "read-error" };
|
|
67
|
+
} finally {
|
|
68
|
+
try {
|
|
69
|
+
unlinkSync(tmpFile);
|
|
70
|
+
} catch {
|
|
71
|
+
// Ignore cleanup errors.
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { ok: false, reason: "empty" };
|
|
77
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
export interface PasterConfig {
|
|
2
|
+
customEditor?: {
|
|
3
|
+
/** Replace pi's input editor to enable inline image UX features. */
|
|
4
|
+
enabled?: boolean;
|
|
5
|
+
/** Show an image preview above the input while the cursor is inside an image placeholder. */
|
|
6
|
+
showImagePreview?: boolean;
|
|
7
|
+
/** Treat image placeholders as atomic blocks for backspace/delete. */
|
|
8
|
+
deletePlaceholderAsBlock?: boolean;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface ResolvedPasterConfig {
|
|
13
|
+
customEditor: {
|
|
14
|
+
enabled: boolean;
|
|
15
|
+
showImagePreview: boolean;
|
|
16
|
+
deletePlaceholderAsBlock: boolean;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export const DEFAULT_PASTER_CONFIG: ResolvedPasterConfig = {
|
|
21
|
+
customEditor: {
|
|
22
|
+
enabled: true,
|
|
23
|
+
showImagePreview: true,
|
|
24
|
+
deletePlaceholderAsBlock: true,
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function resolvePasterConfig(config: PasterConfig = {}): ResolvedPasterConfig {
|
|
29
|
+
return {
|
|
30
|
+
customEditor: {
|
|
31
|
+
enabled: config.customEditor?.enabled ?? DEFAULT_PASTER_CONFIG.customEditor.enabled,
|
|
32
|
+
showImagePreview:
|
|
33
|
+
config.customEditor?.showImagePreview ??
|
|
34
|
+
DEFAULT_PASTER_CONFIG.customEditor.showImagePreview,
|
|
35
|
+
deletePlaceholderAsBlock:
|
|
36
|
+
config.customEditor?.deletePlaceholderAsBlock ??
|
|
37
|
+
DEFAULT_PASTER_CONFIG.customEditor.deletePlaceholderAsBlock,
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
}
|
package/src/editor.ts
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { CustomEditor, type KeybindingsManager } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { EditorTheme, TUI } from "@earendil-works/pi-tui";
|
|
3
|
+
import { replaceImagePathsInText } from "./image-utils.ts";
|
|
4
|
+
import type { AttachmentStore } from "./store.ts";
|
|
5
|
+
import type { ImageAttachment } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
export const PASTE_START = "\x1b[200~";
|
|
8
|
+
export const PASTE_END = "\x1b[201~";
|
|
9
|
+
const PLACEHOLDER_REGEX = /\[#image \d+\]/g;
|
|
10
|
+
|
|
11
|
+
interface EditorCursor {
|
|
12
|
+
line: number;
|
|
13
|
+
col: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface PlaceholderAtCursor {
|
|
17
|
+
attachment: ImageAttachment;
|
|
18
|
+
line: number;
|
|
19
|
+
start: number;
|
|
20
|
+
end: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function findPlaceholderAtCursor(
|
|
24
|
+
store: AttachmentStore,
|
|
25
|
+
lines: string[],
|
|
26
|
+
cursor: EditorCursor,
|
|
27
|
+
mode: "hover" | "backspace" | "delete",
|
|
28
|
+
): PlaceholderAtCursor | undefined {
|
|
29
|
+
const line = lines[cursor.line] ?? "";
|
|
30
|
+
for (const match of line.matchAll(PLACEHOLDER_REGEX)) {
|
|
31
|
+
const placeholder = match[0];
|
|
32
|
+
const start = match.index;
|
|
33
|
+
const end = start + placeholder.length;
|
|
34
|
+
const attachment = store.get(placeholder);
|
|
35
|
+
if (!attachment) continue;
|
|
36
|
+
|
|
37
|
+
if (mode === "hover" && cursor.col >= start && cursor.col < end) {
|
|
38
|
+
return { attachment, line: cursor.line, start, end };
|
|
39
|
+
}
|
|
40
|
+
if (mode === "backspace" && cursor.col > start && cursor.col <= end) {
|
|
41
|
+
return { attachment, line: cursor.line, start, end };
|
|
42
|
+
}
|
|
43
|
+
if (mode === "delete" && cursor.col >= start && cursor.col < end) {
|
|
44
|
+
return { attachment, line: cursor.line, start, end };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface EditorStateAccess {
|
|
51
|
+
state: { lines: string[]; cursorLine: number; cursorCol: number };
|
|
52
|
+
pushUndoSnapshot?: () => void;
|
|
53
|
+
setCursorCol?: (col: number) => void;
|
|
54
|
+
lastAction?: unknown;
|
|
55
|
+
historyIndex?: number;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export class PasterEditor extends CustomEditor {
|
|
59
|
+
private pasterPasteBuffer: string | undefined;
|
|
60
|
+
private activePreviewPlaceholder: string | undefined;
|
|
61
|
+
|
|
62
|
+
constructor(
|
|
63
|
+
tui: TUI,
|
|
64
|
+
theme: EditorTheme,
|
|
65
|
+
private readonly pasterKeybindings: KeybindingsManager,
|
|
66
|
+
private readonly pasterOptions: {
|
|
67
|
+
cwd: string;
|
|
68
|
+
store: AttachmentStore;
|
|
69
|
+
notify: (message: string) => void;
|
|
70
|
+
deletePlaceholderAsBlock: boolean;
|
|
71
|
+
setCursorPreview: (attachment: ImageAttachment | undefined) => void;
|
|
72
|
+
pasteClipboardImage?: () =>
|
|
73
|
+
| Promise<ImageAttachment | undefined>
|
|
74
|
+
| ImageAttachment
|
|
75
|
+
| undefined;
|
|
76
|
+
},
|
|
77
|
+
) {
|
|
78
|
+
super(tui, theme, pasterKeybindings);
|
|
79
|
+
this.onPasteImage = () => {
|
|
80
|
+
void this.handlePasteClipboardImage();
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
override insertTextAtCursor(text: string): void {
|
|
85
|
+
const transformed = this.transform(text);
|
|
86
|
+
super.insertTextAtCursor(transformed.replaced > 0 ? transformed.text : text);
|
|
87
|
+
this.updateCursorPreview();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
override handleInput(data: string): void {
|
|
91
|
+
if (this.handleBracketedPaste(data)) return;
|
|
92
|
+
if (this.pasterOptions.deletePlaceholderAsBlock && this.handleAtomicPlaceholderDelete(data))
|
|
93
|
+
return;
|
|
94
|
+
|
|
95
|
+
super.handleInput(data);
|
|
96
|
+
this.updateCursorPreview();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
clearCursorPreview(): void {
|
|
100
|
+
this.activePreviewPlaceholder = undefined;
|
|
101
|
+
this.pasterOptions.setCursorPreview(undefined);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private async handlePasteClipboardImage(): Promise<void> {
|
|
105
|
+
const attachment = await this.pasterOptions.pasteClipboardImage?.();
|
|
106
|
+
if (!attachment) return;
|
|
107
|
+
super.insertTextAtCursor(attachment.placeholder);
|
|
108
|
+
this.updateCursorPreview();
|
|
109
|
+
this.tui.requestRender();
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
private handleBracketedPaste(data: string): boolean {
|
|
113
|
+
let prefix = "";
|
|
114
|
+
const original = data;
|
|
115
|
+
const wasBuffered = this.pasterPasteBuffer !== undefined;
|
|
116
|
+
|
|
117
|
+
if (this.pasterPasteBuffer === undefined) {
|
|
118
|
+
const start = data.indexOf(PASTE_START);
|
|
119
|
+
if (start === -1) return false;
|
|
120
|
+
prefix = data.slice(0, start);
|
|
121
|
+
this.pasterPasteBuffer = data.slice(start + PASTE_START.length);
|
|
122
|
+
if (!this.pasterPasteBuffer.includes(PASTE_END)) {
|
|
123
|
+
if (prefix) super.handleInput(prefix);
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
} else {
|
|
127
|
+
this.pasterPasteBuffer += data;
|
|
128
|
+
if (!this.pasterPasteBuffer.includes(PASTE_END)) return true;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const end = this.pasterPasteBuffer.indexOf(PASTE_END);
|
|
132
|
+
const content = this.pasterPasteBuffer.slice(0, end);
|
|
133
|
+
const remaining = this.pasterPasteBuffer.slice(end + PASTE_END.length);
|
|
134
|
+
this.pasterPasteBuffer = undefined;
|
|
135
|
+
|
|
136
|
+
const transformed = this.transform(content);
|
|
137
|
+
if (transformed.replaced === 0) {
|
|
138
|
+
super.handleInput(
|
|
139
|
+
wasBuffered ? `${PASTE_START}${content}${PASTE_END}${remaining}` : original,
|
|
140
|
+
);
|
|
141
|
+
this.updateCursorPreview();
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (prefix) super.handleInput(prefix);
|
|
146
|
+
super.insertTextAtCursor(transformed.text);
|
|
147
|
+
if (remaining) super.handleInput(remaining);
|
|
148
|
+
this.updateCursorPreview();
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private handleAtomicPlaceholderDelete(data: string): boolean {
|
|
153
|
+
const isBackspace = this.pasterKeybindings.matches(data, "tui.editor.deleteCharBackward");
|
|
154
|
+
const isDelete = this.pasterKeybindings.matches(data, "tui.editor.deleteCharForward");
|
|
155
|
+
if (!isBackspace && !isDelete) return false;
|
|
156
|
+
if (isDelete && this.getText().length === 0) return false;
|
|
157
|
+
|
|
158
|
+
const target = findPlaceholderAtCursor(
|
|
159
|
+
this.pasterOptions.store,
|
|
160
|
+
this.getLines(),
|
|
161
|
+
this.getCursor(),
|
|
162
|
+
isBackspace ? "backspace" : "delete",
|
|
163
|
+
);
|
|
164
|
+
if (!target) return false;
|
|
165
|
+
|
|
166
|
+
this.deleteLineRange(target.line, target.start, target.end);
|
|
167
|
+
this.updateCursorPreview();
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private deleteLineRange(lineIndex: number, start: number, end: number): void {
|
|
172
|
+
const editor = this as unknown as EditorStateAccess;
|
|
173
|
+
editor.pushUndoSnapshot?.();
|
|
174
|
+
const line = editor.state.lines[lineIndex] ?? "";
|
|
175
|
+
editor.state.lines[lineIndex] = line.slice(0, start) + line.slice(end);
|
|
176
|
+
editor.state.cursorLine = lineIndex;
|
|
177
|
+
if (editor.setCursorCol) {
|
|
178
|
+
editor.setCursorCol(start);
|
|
179
|
+
} else {
|
|
180
|
+
editor.state.cursorCol = start;
|
|
181
|
+
}
|
|
182
|
+
editor.lastAction = null;
|
|
183
|
+
editor.historyIndex = -1;
|
|
184
|
+
this.onChange?.(this.getText());
|
|
185
|
+
this.tui.requestRender();
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
private transform(text: string): { text: string; replaced: number; accepted: ImageAttachment[] } {
|
|
189
|
+
return replaceImagePathsInText(text, {
|
|
190
|
+
cwd: this.pasterOptions.cwd,
|
|
191
|
+
store: this.pasterOptions.store,
|
|
192
|
+
onReject: (result) => {
|
|
193
|
+
if (result.reason === "too-large") {
|
|
194
|
+
this.pasterOptions.notify(
|
|
195
|
+
`paster: image is over 10 MB and was not attached: ${result.path}`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
private updateCursorPreview(): void {
|
|
203
|
+
const target = findPlaceholderAtCursor(
|
|
204
|
+
this.pasterOptions.store,
|
|
205
|
+
this.getLines(),
|
|
206
|
+
this.getCursor(),
|
|
207
|
+
"hover",
|
|
208
|
+
);
|
|
209
|
+
const nextPlaceholder = target?.attachment.placeholder;
|
|
210
|
+
if (nextPlaceholder === this.activePreviewPlaceholder) return;
|
|
211
|
+
this.activePreviewPlaceholder = nextPlaceholder;
|
|
212
|
+
this.pasterOptions.setCursorPreview(target?.attachment);
|
|
213
|
+
}
|
|
214
|
+
}
|