@bacnh85/pi-attachments 0.2.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/CHANGELOG.md +38 -0
- package/README.md +96 -0
- package/extensions/index.ts +198 -0
- package/extensions/lib/clipboard-files.ts +79 -0
- package/extensions/lib/paths.ts +72 -0
- package/extensions/lib/registry.ts +57 -0
- package/extensions/lib/settings.ts +43 -0
- package/extensions/lib/tray.ts +71 -0
- package/extensions/package.json +3 -0
- package/package.json +61 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
- **Readable path references by default (read on demand).** `[[attach:name]]`
|
|
6
|
+
tokens now resolve to `๐ /abs/path` chips โ text files are no longer dumped
|
|
7
|
+
into the message as `<file>` content blocks. The model reads the referenced
|
|
8
|
+
file on demand via its `read` tool (works for absolute paths + images
|
|
9
|
+
natively), keeping the transcript tidy and eliminating per-turn token
|
|
10
|
+
re-reads of large content.
|
|
11
|
+
- Images keep attaching a real `ImageContent` part alongside the ๐ chip.
|
|
12
|
+
- `inlineTextFiles` now defaults to `false`; set `true` to restore the old
|
|
13
|
+
`<file>`-block inlining (Claude Code `@file` style).
|
|
14
|
+
|
|
15
|
+
## 0.1.0
|
|
16
|
+
|
|
17
|
+
Initial release.
|
|
18
|
+
|
|
19
|
+
- **Attachment chips for drag-drop / clipboard paste**: dropping file(s) into the
|
|
20
|
+
terminal (or `alt+shift+v` for Finder/Explorer-copied files) no longer dumps
|
|
21
|
+
raw paths into your prompt. Path-only pastes are intercepted before the
|
|
22
|
+
editor and shown as a tidy ๐ chip list above the input; on submit the chips
|
|
23
|
+
become real attachments.
|
|
24
|
+
- **Image path โ real attachment**: the `input` hook finds existing image file paths
|
|
25
|
+
(png/jpg/jpeg/webp/gif) in submitted text โ including the `/tmp/pi-clipboard-*.png`
|
|
26
|
+
paths Pi's Ctrl+V paste writes and the paths terminals paste on file drag-drop โ
|
|
27
|
+
and converts them into real `ImageContent` parts, so the model sees the image
|
|
28
|
+
instead of just a path string.
|
|
29
|
+
- **Text-file inlining**: existing absolute text-file paths under
|
|
30
|
+
`attachments.maxInlineBytes` (default 100KB) are replaced with
|
|
31
|
+
`<file name="...">โฆ</file>` blocks (same convention as pi's `@file` CLI args).
|
|
32
|
+
- **Paste files from clipboard**: `alt+shift+v` (configurable via
|
|
33
|
+
`attachments.pasteFileShortcut`) reads file references from the OS clipboard
|
|
34
|
+
(macOS Finder, Windows Explorer, Linux X11/Wayland file managers) and pastes
|
|
35
|
+
their paths into the editor.
|
|
36
|
+
- Settings under the `attachments` key in `~/.pi/agent/settings.json`:
|
|
37
|
+
`inlineTextFiles` (bool, default true), `maxInlineBytes` (default 100000),
|
|
38
|
+
`pasteFileShortcut` (default `"alt+shift+v"`).
|
package/README.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# @bacnh85/pi-attachments
|
|
2
|
+
|
|
3
|
+
Image and file attachments for [Pi](https://github.com/earendil-works/pi) โ make
|
|
4
|
+
pasted and drag-dropped files reach the model as real content, not dead path text.
|
|
5
|
+
|
|
6
|
+
## The problem
|
|
7
|
+
|
|
8
|
+
Pi's Ctrl+V image paste writes the clipboard image to a temp file and inserts
|
|
9
|
+
the **path as text** into the editor. Terminals' file drag-drop (iTerm2,
|
|
10
|
+
Terminal.app, WezTerm, kitty, โฆ) pastes the dropped file's path as text too.
|
|
11
|
+
Nothing converts that path into an attachment โ the model receives only the
|
|
12
|
+
path string and must spend a turn calling `read` (and often doesn't). Pasting
|
|
13
|
+
files copied in Finder/Explorer does nothing at all.
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pi install @bacnh85/pi-attachments
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## What it does
|
|
22
|
+
|
|
23
|
+
1. **Attachment chips (drag-drop / clipboard paste).** Dropping file(s) into
|
|
24
|
+
the terminal โ or pressing `alt+shift+v` with files copied in Finder /
|
|
25
|
+
Explorer / a Linux file manager โ inserts filename tokens into your prompt
|
|
26
|
+
and shows a chip list above the editor instead of dumping raw paths:
|
|
27
|
+
```
|
|
28
|
+
๐ demo.jpeg ยท C601079.pdf
|
|
29
|
+
[[attach:demo.jpeg]] what's wrong in this screenshot?
|
|
30
|
+
```
|
|
31
|
+
On submit the tokens resolve to real, readable references. Remove one by
|
|
32
|
+
deleting its `[[attach:...]]` token from the prompt โ the chip disappears
|
|
33
|
+
immediately and the file is not sent.
|
|
34
|
+
2. **Readable path references (default).** A dropped/pasted file resolves to
|
|
35
|
+
a `๐ /abs/path` chip โ never a content dump. The transcript stays tidy
|
|
36
|
+
and the model reads the file on demand with its `read` tool (pi's read
|
|
37
|
+
handles absolute paths and images natively). Zero per-turn token cost.
|
|
38
|
+
- text files โ `๐ /path/to/file.ts`
|
|
39
|
+
- images โ `๐ /path/to/img.png` **plus** a real `ImageContent` attachment
|
|
40
|
+
(requires a vision-capable model; pi shows "(image omitted: model does
|
|
41
|
+
not support images)" otherwise)
|
|
42
|
+
3. **Text-file inlining (opt-in).** With `inlineTextFiles: true`, absolute
|
|
43
|
+
text-file paths are inlined as `<file name="...">โฆ</file>` content blocks
|
|
44
|
+
(pi's `@file` CLI convention) instead of ๐ path chips. Claude Code `@file`
|
|
45
|
+
style โ convenient, but the content is re-read on every turn, so it is
|
|
46
|
+
off by default.
|
|
47
|
+
4. **Paste files from the clipboard.** `alt+shift+v` reads file references
|
|
48
|
+
copied in Finder / Explorer / a Linux file manager and queues them as
|
|
49
|
+
chips; the input hook does the rest.
|
|
50
|
+
|
|
51
|
+
## Configuration
|
|
52
|
+
|
|
53
|
+
`~/.pi/agent/settings.json`:
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
{
|
|
57
|
+
"attachments": {
|
|
58
|
+
"inlineTextFiles": false,
|
|
59
|
+
"maxInlineBytes": 100000,
|
|
60
|
+
"pasteFileShortcut": "alt+shift+v"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
| Key | Default | Description |
|
|
66
|
+
|-----|---------|-------------|
|
|
67
|
+
| `inlineTextFiles` | `false` | Inline text files as `<file>` blocks instead of ๐ path chips |
|
|
68
|
+
| `maxInlineBytes` | `100000` | Max file size for text inlining (`inlineTextFiles` mode) |
|
|
69
|
+
| `pasteFileShortcut` | `"alt+shift+v"` | Keybinding for paste-file-from-clipboard |
|
|
70
|
+
|
|
71
|
+
## Notes
|
|
72
|
+
|
|
73
|
+
- **Removing an attachment**: delete its `[[attach:filename]]` token from the
|
|
74
|
+
prompt โ the chip disappears immediately and the file is not sent. Clickable
|
|
75
|
+
(x) chips aren't possible: the editor owns keyboard focus and extension
|
|
76
|
+
widgets don't receive mouse events, so token-editing is the removal path.
|
|
77
|
+
Same-basename files get unique names (`demo.jpeg`, `demo-2.jpeg`).
|
|
78
|
+
- **Tokens survive restarts**: each drop is remembered in
|
|
79
|
+
`~/.pi/agent/pi-attachments.json` (name โ absolute path, newest 200 kept),
|
|
80
|
+
so referencing `[[attach:foo.ts]]` in a later session still resolves to the
|
|
81
|
+
dropped file โ no dead tokens.
|
|
82
|
+
- Conservative matching: prose like "see main.rs" or "the .jpg extension" never
|
|
83
|
+
triggers anything โ images require an existing file with an image extension,
|
|
84
|
+
text inlining requires an absolute existing path.
|
|
85
|
+
- pi core auto-resizes attached images (see the `images.autoResize` setting).
|
|
86
|
+
- kitty's OSC 72 drag-drop protocol is not supported (requires raw stdin access
|
|
87
|
+
that extensions don't have); kitty <0.47 drops paths, which work.
|
|
88
|
+
|
|
89
|
+
## Development
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
cd pi-attachments
|
|
93
|
+
npm install
|
|
94
|
+
npm test # mocha + tsx
|
|
95
|
+
npm run typecheck
|
|
96
|
+
```
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-attachments โ real attachments from pasted/dropped file paths.
|
|
3
|
+
*
|
|
4
|
+
* Drag-drop / clipboard-paste flow:
|
|
5
|
+
* 1. onTerminalInput intercepts the bracketed paste BEFORE the editor and,
|
|
6
|
+
* when the payload is file paths only, swaps it for [[attach:name]] tokens
|
|
7
|
+
* and shows a ๐ chip list above the editor (widget).
|
|
8
|
+
* 2. On submit, the input hook resolves tokens:
|
|
9
|
+
* - images โ ๐ path text + real ImageContent parts
|
|
10
|
+
* - text files โ ๐ path text (default; model reads on demand via read)
|
|
11
|
+
* or <file> content blocks (inlineTextFiles opt-in)
|
|
12
|
+
* The user's chat line shows only the tidy ๐ chips.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { ExtensionAPI, InputEvent, TerminalInputHandler } from "@earendil-works/pi-coding-agent";
|
|
16
|
+
import { detectSupportedImageMimeTypeFromFile } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import { readFile, stat } from "node:fs/promises";
|
|
18
|
+
import { readClipboardFilePaths } from "./lib/clipboard-files";
|
|
19
|
+
import { absolutePathSpans, extractImagePaths } from "./lib/paths";
|
|
20
|
+
import { lookup, remember } from "./lib/registry";
|
|
21
|
+
import { loadSettings } from "./lib/settings";
|
|
22
|
+
import { AttachmentTray } from "./lib/tray";
|
|
23
|
+
|
|
24
|
+
const BRACKETED_PASTE = /^\x1b\[200~([\s\S]*?)\x1b\[201~$/;
|
|
25
|
+
|
|
26
|
+
/** Split on whitespace NOT preceded by a backslash, so "/a/with\\ space.png" stays one token. */
|
|
27
|
+
function splitPathTokens(payload: string): string[] {
|
|
28
|
+
return payload.trim().split(/(?<!\\)\s+/).filter(Boolean);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Paste payload is all path-like tokens (optionally escaped spaces)? */
|
|
32
|
+
function looksLikePathPayload(payload: string): boolean {
|
|
33
|
+
const tokens = splitPathTokens(payload);
|
|
34
|
+
return tokens.length > 0 && tokens.every((t) => t.replace(/\\ /g, " ").startsWith("/"));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export default function piAttachments(pi: ExtensionAPI): void {
|
|
38
|
+
const settings = loadSettings();
|
|
39
|
+
const tray = new AttachmentTray();
|
|
40
|
+
let trayUi: { setWidget: (key: string, lines: string[]) => void } | undefined;
|
|
41
|
+
|
|
42
|
+
const updateWidget = () => {
|
|
43
|
+
trayUi?.setWidget("pi-attachments", tray.render());
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
// 1. Intercept path-only bracketed pastes โ tokens + chip widget.
|
|
47
|
+
// onTerminalInput fires for EVERY keystroke, so we also keep the chip
|
|
48
|
+
// list in sync for free: if the user deletes a [[attach:N]] token from
|
|
49
|
+
// the prompt, its chip disappears immediately (and the file is not sent).
|
|
50
|
+
const onPaste: TerminalInputHandler = (data) => {
|
|
51
|
+
if (tray.size > 0) {
|
|
52
|
+
const before = tray.size;
|
|
53
|
+
tray.prune(editorText?.() ?? "");
|
|
54
|
+
if (tray.size !== before) updateWidget();
|
|
55
|
+
}
|
|
56
|
+
const m = data.match(BRACKETED_PASTE);
|
|
57
|
+
if (!m || !looksLikePathPayload(m[1])) return undefined;
|
|
58
|
+
const tokens: string[] = [];
|
|
59
|
+
for (const raw of splitPathTokens(m[1])) {
|
|
60
|
+
const item = tray.add(raw.replace(/\\ /g, " "));
|
|
61
|
+
remember(item.name, item.path); // survive session restarts
|
|
62
|
+
tokens.push(item.token);
|
|
63
|
+
}
|
|
64
|
+
updateWidget();
|
|
65
|
+
return { data: tokens.join(" ") };
|
|
66
|
+
};
|
|
67
|
+
// Captured at session_start โ lets onPaste read the editor for prune-sync.
|
|
68
|
+
let editorText: (() => string) | undefined;
|
|
69
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
70
|
+
trayUi = ctx.ui;
|
|
71
|
+
editorText = (ctx.ui as any).getEditorText?.bind(ctx.ui);
|
|
72
|
+
ctx.ui.onTerminalInput?.(onPaste);
|
|
73
|
+
updateWidget();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// 2. On submit: resolve [[attach:name]] tokens โ real content.
|
|
77
|
+
pi.on("input", async (event: InputEvent, ctx) => {
|
|
78
|
+
if (event.source === "extension") return; // don't reprocess our own sends
|
|
79
|
+
|
|
80
|
+
// Prune tray items whose tokens the user deleted from the editor.
|
|
81
|
+
tray.prune(ctx?.ui?.getEditorText?.() ?? event.text ?? "");
|
|
82
|
+
|
|
83
|
+
const raw = event.text ?? "";
|
|
84
|
+
if (!raw.trim()) {
|
|
85
|
+
updateWidget();
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const images: Array<{ type: "image"; data: string; mimeType: string }> = [...(event.images ?? [])];
|
|
90
|
+
const attachedImages = new Set<string>(); // paths already attached via tokens
|
|
91
|
+
let text = raw;
|
|
92
|
+
|
|
93
|
+
// a. Resolve [[attach:name]] tokens (tray first, then persistent registry).
|
|
94
|
+
for (const m of raw.matchAll(/\[\[attach:([^\]]+)\]\]/g)) {
|
|
95
|
+
const token = m[0];
|
|
96
|
+
const name = m[1];
|
|
97
|
+
if (!text.includes(token)) continue; // duplicate token already replaced
|
|
98
|
+
const trayItem = tray.resolve(raw).find((i) => i.token === token);
|
|
99
|
+
const path = trayItem?.path ?? lookup(name);
|
|
100
|
+
if (!path) continue; // unknown token โ leave as-is for the model
|
|
101
|
+
|
|
102
|
+
const mimeType = await detectSupportedImageMimeTypeFromFile(path).catch(() => null);
|
|
103
|
+
if (mimeType) {
|
|
104
|
+
if (!attachedImages.has(path)) {
|
|
105
|
+
// Distinct tokens may resolve to the same path (same file dropped twice) โ attach once.
|
|
106
|
+
try {
|
|
107
|
+
const content = await readFile(path);
|
|
108
|
+
images.push({ type: "image", data: content.toString("base64"), mimeType });
|
|
109
|
+
attachedImages.add(path);
|
|
110
|
+
} catch {
|
|
111
|
+
/* unreadable โ skip */
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
text = text.split(token).join(`๐ ${path}`);
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Non-image: inline as <file> only when inlineTextFiles is on and size allows;
|
|
119
|
+
// otherwise resolve to a ๐ path the model reads on demand.
|
|
120
|
+
if (settings.inlineTextFiles) {
|
|
121
|
+
try {
|
|
122
|
+
const s = await stat(path);
|
|
123
|
+
if (s.size <= settings.maxInlineBytes) {
|
|
124
|
+
const content = (await readFile(path, "utf-8")).replace(/^\uFEFF/, "").replace(/\n$/, "");
|
|
125
|
+
text = text.split(token).join(`<file name="${path}">\n${content}\n</file>`);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
} catch {
|
|
129
|
+
/* fall through to ๐ path */
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
text = text.split(token).join(`๐ ${path}`);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// b. Existing image paths typed elsewhere in the message โ attach too.
|
|
136
|
+
for (const p of extractImagePaths(text)) {
|
|
137
|
+
if (attachedImages.has(p)) continue;
|
|
138
|
+
try {
|
|
139
|
+
const mimeType = await detectSupportedImageMimeTypeFromFile(p);
|
|
140
|
+
if (!mimeType) continue;
|
|
141
|
+
const content = await readFile(p);
|
|
142
|
+
images.push({ type: "image", data: content.toString("base64"), mimeType });
|
|
143
|
+
} catch {
|
|
144
|
+
/* unreadable file โ skip */
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// c. Text-path inlining (opt-in old behavior): absolute text-file paths in
|
|
149
|
+
// the message โ <file> blocks. Spans come from one greedy regex pass
|
|
150
|
+
// (disjoint matches), applied right-to-left so earlier spans stay valid.
|
|
151
|
+
if (settings.inlineTextFiles) {
|
|
152
|
+
const replacements: Array<{ start: number; end: number; block: string }> = [];
|
|
153
|
+
for (const span of absolutePathSpans(text)) {
|
|
154
|
+
try {
|
|
155
|
+
const s = await stat(span.path);
|
|
156
|
+
if (s.size > settings.maxInlineBytes) continue;
|
|
157
|
+
const content = (await readFile(span.path, "utf-8")).replace(/^\uFEFF/, "").replace(/\n$/, ""); // stripBom + trailing newline
|
|
158
|
+
replacements.push({ start: span.start, end: span.end, block: `<file name="${span.path}">\n${content}\n</file>` });
|
|
159
|
+
} catch {
|
|
160
|
+
/* unreadable file โ skip */
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (replacements.length) {
|
|
164
|
+
replacements.sort((a, b) => b.start - a.start);
|
|
165
|
+
for (const r of replacements) {
|
|
166
|
+
text = text.slice(0, r.start) + r.block + text.slice(r.end);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Message sent โ the chip list is consumed; hide the widget.
|
|
172
|
+
if (tray.size > 0 && tray.expand(raw) !== raw) tray.clear();
|
|
173
|
+
updateWidget();
|
|
174
|
+
|
|
175
|
+
if (text === raw && images.length === (event.images?.length ?? 0)) return;
|
|
176
|
+
return { action: "transform", text, images };
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
// 3. Clipboard file paste shortcut โ queue into the tray as tokens.
|
|
180
|
+
// ponytail: settings string โ KeyId cast; a bad key just never matches (pi's keybinding parser ignores unknown ids)
|
|
181
|
+
pi.registerShortcut(settings.pasteFileShortcut as Parameters<ExtensionAPI["registerShortcut"]>[0], {
|
|
182
|
+
description: "Paste file(s) from clipboard as attachments",
|
|
183
|
+
handler: async (ctx) => {
|
|
184
|
+
const paths = await readClipboardFilePaths();
|
|
185
|
+
if (paths.length === 0) {
|
|
186
|
+
ctx.ui.notify("No files in clipboard", "info");
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const tokens = paths.map((p) => {
|
|
190
|
+
const item = tray.add(p);
|
|
191
|
+
remember(item.name, item.path); // survive session restarts
|
|
192
|
+
return item.token;
|
|
193
|
+
});
|
|
194
|
+
ctx.ui.pasteToEditor(tokens.join(" "));
|
|
195
|
+
updateWidget();
|
|
196
|
+
},
|
|
197
|
+
});
|
|
198
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read file paths (not bytes) from the OS clipboard.
|
|
3
|
+
*
|
|
4
|
+
* Each platform exposes copied files as file URLs / file lists:
|
|
5
|
+
* - macOS: ยซclass furlยป via osascript (Finder "Copy")
|
|
6
|
+
* - Windows: FileDropList via PowerShell (Explorer "Copy")
|
|
7
|
+
* - Linux: text/uri-list (xclip / wl-paste), gnome-copied-files fallback
|
|
8
|
+
*
|
|
9
|
+
* Missing tools or an empty (file-less) clipboard yield [] โ never throws.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { execFile } from "node:child_process";
|
|
13
|
+
import { fileURLToPath } from "node:url";
|
|
14
|
+
import { promisify } from "node:util";
|
|
15
|
+
|
|
16
|
+
const run = promisify(execFile);
|
|
17
|
+
|
|
18
|
+
export async function readClipboardFilePaths(): Promise<string[]> {
|
|
19
|
+
try {
|
|
20
|
+
if (process.platform === "darwin") return await readMac();
|
|
21
|
+
if (process.platform === "win32") return await readWindows();
|
|
22
|
+
return await readLinux();
|
|
23
|
+
} catch {
|
|
24
|
+
return [];
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function readMac(): Promise<string[]> {
|
|
29
|
+
// ponytail: single file only; AppleScript repeat-loop if multi-file copies matter
|
|
30
|
+
const { stdout } = await run("osascript", ["-e", 'POSIX path of (the clipboard as ยซclass furlยป)'], { timeout: 3000 });
|
|
31
|
+
return [stdout.trim()].filter(Boolean);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function readWindows(): Promise<string[]> {
|
|
35
|
+
const { stdout } = await run(
|
|
36
|
+
"powershell",
|
|
37
|
+
["-NoProfile", "-STA", "-Command", "(Get-Clipboard -Format FileDropList) -join ';'"],
|
|
38
|
+
{ timeout: 5000 },
|
|
39
|
+
);
|
|
40
|
+
return stdout.split(";").map((p) => p.trim()).filter(Boolean);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function readLinux(): Promise<string[]> {
|
|
44
|
+
const isWayland = Boolean(process.env.WAYLAND_DISPLAY) || process.env.XDG_SESSION_TYPE === "wayland";
|
|
45
|
+
const targets: Array<{ cmd: string; args: string[] }> = isWayland
|
|
46
|
+
? [
|
|
47
|
+
{ cmd: "wl-paste", args: ["--type", "text/uri-list"] },
|
|
48
|
+
{ cmd: "wl-paste", args: ["--type", "x-special/gnome-copied-files"] },
|
|
49
|
+
]
|
|
50
|
+
: [
|
|
51
|
+
{ cmd: "xclip", args: ["-selection", "clipboard", "-t", "text/uri-list", "-o"] },
|
|
52
|
+
{ cmd: "xclip", args: ["-selection", "clipboard", "-t", "x-special/gnome-copied-files", "-o"] },
|
|
53
|
+
];
|
|
54
|
+
for (const t of targets) {
|
|
55
|
+
try {
|
|
56
|
+
const { stdout } = await run(t.cmd, t.args, { timeout: 2000 });
|
|
57
|
+
const paths = parseUriList(stdout);
|
|
58
|
+
if (paths.length) return paths;
|
|
59
|
+
} catch {
|
|
60
|
+
/* try next target */
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Parse `file:///a%20b` lines (uri-list) or `copy\nfile:///...` (gnome-copied-files). */
|
|
67
|
+
export function parseUriList(raw: string): string[] {
|
|
68
|
+
const out: string[] = [];
|
|
69
|
+
for (const line of raw.split(/\r?\n/)) {
|
|
70
|
+
const trimmed = line.trim();
|
|
71
|
+
if (!trimmed.startsWith("file:///") && !trimmed.startsWith("file://localhost/")) continue;
|
|
72
|
+
try {
|
|
73
|
+
out.push(fileURLToPath(trimmed));
|
|
74
|
+
} catch {
|
|
75
|
+
/* skip malformed uri */
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* File-path extraction from submitted input text.
|
|
3
|
+
*
|
|
4
|
+
* Conservative by design: images match any existing path with an image
|
|
5
|
+
* extension; text files must be absolute paths (drag-drop and clipboard
|
|
6
|
+
* always produce absolute paths โ prose mentions like "see main.rs" never
|
|
7
|
+
* match).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { statSync } from "node:fs";
|
|
11
|
+
// Token may contain backslash-escaped spaces ("with\ space.png" โ the form
|
|
12
|
+
// terminals paste on file drops); the escaped form is unescaped before fs access.
|
|
13
|
+
const IMAGE_RE = /[^\s"']+(?:\\ [^\s"']+)*\.(?:png|jpe?g|webp|gif)\b/gi;
|
|
14
|
+
/** Absolute-path token regex: slash segments whose chars may include backslash-escaped spaces (terminal drop form). */
|
|
15
|
+
export const ABSOLUTE_PATH_RE = /(?:\/(?:[\w.@+-]|\\ )+)+/g;
|
|
16
|
+
|
|
17
|
+
/** A matched absolute-path token: unescaped path plus its literal span in the text. */
|
|
18
|
+
export interface AbsolutePathSpan {
|
|
19
|
+
path: string;
|
|
20
|
+
/** Start of the literal match (incl. any `\ ` escapes) in the source text. */
|
|
21
|
+
start: number;
|
|
22
|
+
/** End of the literal match. */
|
|
23
|
+
end: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** All distinct existing-file image paths referenced in text (order-preserving). */
|
|
27
|
+
export function extractImagePaths(text: string): string[] {
|
|
28
|
+
const seen = new Set<string>();
|
|
29
|
+
const out: string[] = [];
|
|
30
|
+
for (const match of text.matchAll(IMAGE_RE)) {
|
|
31
|
+
const p = unescape(match[0]);
|
|
32
|
+
if (seen.has(p)) continue;
|
|
33
|
+
if (!isFile(p)) continue;
|
|
34
|
+
seen.add(p);
|
|
35
|
+
out.push(p);
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Distinct existing absolute non-image file paths referenced in text. */
|
|
41
|
+
export function extractTextFilePaths(text: string): string[] {
|
|
42
|
+
return [...new Set(absolutePathSpans(text).map((s) => s.path))];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Existing absolute non-image path tokens with their literal text spans.
|
|
47
|
+
* One greedy regex pass yields disjoint matches, so a span can never be the
|
|
48
|
+
* prefix of another (e.g. /a/b inside /a/b.c); replacement can therefore use
|
|
49
|
+
* the spans directly, right-to-left.
|
|
50
|
+
*/
|
|
51
|
+
export function absolutePathSpans(text: string): AbsolutePathSpan[] {
|
|
52
|
+
const out: AbsolutePathSpan[] = [];
|
|
53
|
+
for (const match of text.matchAll(ABSOLUTE_PATH_RE)) {
|
|
54
|
+
const p = unescape(match[0]);
|
|
55
|
+
if (/\.(?:png|jpe?g|webp|gif)$/i.test(p)) continue; // images are handled separately
|
|
56
|
+
if (!isFile(p)) continue;
|
|
57
|
+
out.push({ path: p, start: match.index, end: match.index + match[0].length });
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function unescape(p: string): string {
|
|
63
|
+
return p.replace(/\\ /g, " ");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function isFile(p: string): boolean {
|
|
67
|
+
try {
|
|
68
|
+
return statSync(p).isFile();
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent tokenโpath registry: survives session restarts so an
|
|
3
|
+
* [[attach:file.ts]] token pasted/referenced in a LATER session still
|
|
4
|
+
* resolves to the dropped file's absolute path.
|
|
5
|
+
*
|
|
6
|
+
* Stored at <agentDir>/pi-attachments.json (non-secret: names + paths only),
|
|
7
|
+
* capped at REGISTRY_MAX entries (oldest evicted).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { dirname, join } from "node:path";
|
|
13
|
+
|
|
14
|
+
const REGISTRY_MAX = 200;
|
|
15
|
+
|
|
16
|
+
export function registryPath(): string {
|
|
17
|
+
const dir = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
|
|
18
|
+
return join(dir, "pi-attachments.json");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type Registry = Record<string, string>; // name โ absolute path
|
|
22
|
+
|
|
23
|
+
function load(): Registry {
|
|
24
|
+
const p = registryPath();
|
|
25
|
+
if (!existsSync(p)) return {};
|
|
26
|
+
try {
|
|
27
|
+
const j = JSON.parse(readFileSync(p, "utf-8"));
|
|
28
|
+
return j && typeof j === "object" ? j : {};
|
|
29
|
+
} catch {
|
|
30
|
+
return {};
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function save(reg: Registry): void {
|
|
35
|
+
// Evict oldest (first-inserted) entries beyond the cap.
|
|
36
|
+
const keys = Object.keys(reg);
|
|
37
|
+
if (keys.length > REGISTRY_MAX) {
|
|
38
|
+
for (const k of keys.slice(0, keys.length - REGISTRY_MAX)) delete reg[k];
|
|
39
|
+
}
|
|
40
|
+
// Atomic write (tmp + rename): a crash mid-write never leaves a truncated
|
|
41
|
+
// registry, and a fresh PI_CODING_AGENT_DIR gets created on first save.
|
|
42
|
+
const p = registryPath();
|
|
43
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
44
|
+
writeFileSync(`${p}.tmp`, JSON.stringify(reg, null, 2));
|
|
45
|
+
renameSync(`${p}.tmp`, p);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function remember(name: string, path: string): void {
|
|
49
|
+
const reg = load();
|
|
50
|
+
delete reg[name]; // re-insert at the end (most recent)
|
|
51
|
+
reg[name] = path;
|
|
52
|
+
save(reg);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function lookup(name: string): string | undefined {
|
|
56
|
+
return load()[name];
|
|
57
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Settings from the `attachments` key of Pi's settings.json
|
|
3
|
+
* (~/.pi/agent/settings.json, or PI_CODING_AGENT_DIR). Non-secret only.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
7
|
+
import { homedir } from "node:os";
|
|
8
|
+
import { join } from "node:path";
|
|
9
|
+
|
|
10
|
+
export interface AttachmentsSettings {
|
|
11
|
+
/**
|
|
12
|
+
* When true, text-file attachments are inlined as <file> blocks (Claude Code
|
|
13
|
+
* @file style โ content dumped into context, re-read every turn).
|
|
14
|
+
* When false (default), they resolve to a ๐ path the model reads on demand.
|
|
15
|
+
*/
|
|
16
|
+
inlineTextFiles: boolean;
|
|
17
|
+
/** Max bytes for text-file inlining (inlineTextFiles mode only). Default 100_000. */
|
|
18
|
+
maxInlineBytes: number;
|
|
19
|
+
/** Keybinding for paste-file-from-clipboard. Default "alt+shift+v". */
|
|
20
|
+
pasteFileShortcut: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const DEFAULTS: AttachmentsSettings = {
|
|
24
|
+
inlineTextFiles: false,
|
|
25
|
+
maxInlineBytes: 100_000,
|
|
26
|
+
pasteFileShortcut: "alt+shift+v",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function loadSettings(): AttachmentsSettings {
|
|
30
|
+
const dir = process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
|
|
31
|
+
const p = join(dir, "settings.json");
|
|
32
|
+
if (!existsSync(p)) return { ...DEFAULTS };
|
|
33
|
+
try {
|
|
34
|
+
const raw = JSON.parse(readFileSync(p, "utf-8"))?.attachments ?? {};
|
|
35
|
+
return {
|
|
36
|
+
inlineTextFiles: typeof raw.inlineTextFiles === "boolean" ? raw.inlineTextFiles : DEFAULTS.inlineTextFiles,
|
|
37
|
+
maxInlineBytes: typeof raw.maxInlineBytes === "number" && raw.maxInlineBytes > 0 ? raw.maxInlineBytes : DEFAULTS.maxInlineBytes,
|
|
38
|
+
pasteFileShortcut: typeof raw.pasteFileShortcut === "string" && raw.pasteFileShortcut ? raw.pasteFileShortcut : DEFAULTS.pasteFileShortcut,
|
|
39
|
+
};
|
|
40
|
+
} catch {
|
|
41
|
+
return { ...DEFAULTS };
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pending-attachment tray: holds files queued by drag-drop paste / clipboard
|
|
3
|
+
* paste until submit. The tray renders as a single chip line above the editor;
|
|
4
|
+
* on submit the input hook swaps the [[attach:name]] tokens for real content.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { basename } from "node:path";
|
|
8
|
+
|
|
9
|
+
export interface PendingAttachment {
|
|
10
|
+
/** [[attach:name]] token inserted into the editor text (filename-based). */
|
|
11
|
+
token: string;
|
|
12
|
+
path: string;
|
|
13
|
+
/** Display name used inside the token (unique across the tray). */
|
|
14
|
+
name: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export class AttachmentTray {
|
|
18
|
+
private items: PendingAttachment[] = [];
|
|
19
|
+
|
|
20
|
+
add(path: string): PendingAttachment {
|
|
21
|
+
const name = this.uniqueName(basename(path));
|
|
22
|
+
const item = { token: `[[attach:${name}]]`, path, name };
|
|
23
|
+
this.items.push(item);
|
|
24
|
+
return item;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Resolve tokens still present in submitted text; returns kept items in order. */
|
|
28
|
+
resolve(text: string): PendingAttachment[] {
|
|
29
|
+
return this.items.filter((i) => text.includes(i.token));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Replace every live [[attach:name]] token in text with the real path (for the input hook). */
|
|
33
|
+
expand(text: string): string {
|
|
34
|
+
let out = text;
|
|
35
|
+
for (const i of this.items) {
|
|
36
|
+
if (out.includes(i.token)) out = out.split(i.token).join(i.path);
|
|
37
|
+
}
|
|
38
|
+
return out;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Drop items whose tokens are gone from the editor (user deleted them). */
|
|
42
|
+
prune(editorText: string): void {
|
|
43
|
+
this.items = this.items.filter((i) => editorText.includes(i.token));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
clear(): void {
|
|
47
|
+
this.items = [];
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
get size(): number {
|
|
51
|
+
return this.items.length;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Single chip line: ๐ demo.jpeg ยท C601079.pdf */
|
|
55
|
+
render(): string[] {
|
|
56
|
+
if (this.items.length === 0) return [];
|
|
57
|
+
return [`๐ ${this.items.map((i) => i.name).join(" ยท ")}`];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** First basename is used as-is; duplicates get -2, -3โฆ before the extension. */
|
|
61
|
+
private uniqueName(base: string): string {
|
|
62
|
+
if (!this.items.some((i) => i.name === base)) return base;
|
|
63
|
+
const dot = base.lastIndexOf(".");
|
|
64
|
+
const stem = dot > 0 ? base.slice(0, dot) : base;
|
|
65
|
+
const ext = dot > 0 ? base.slice(dot) : "";
|
|
66
|
+
for (let n = 2; ; n++) {
|
|
67
|
+
const candidate = `${stem}-${n}${ext}`;
|
|
68
|
+
if (!this.items.some((i) => i.name === candidate)) return candidate;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bacnh85/pi-attachments",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Image and file attachments for Pi \u2014 converts pasted/dropped file paths into real image attachments, inlines text files, and pastes clipboard file references.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"homepage": "https://github.com/bacnh85/pi-extensions/tree/main/pi-attachments",
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/bacnh85/pi-extensions.git",
|
|
13
|
+
"directory": "pi-attachments"
|
|
14
|
+
},
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/bacnh85/pi-extensions/issues"
|
|
17
|
+
},
|
|
18
|
+
"keywords": [
|
|
19
|
+
"pi-package",
|
|
20
|
+
"pi-extension",
|
|
21
|
+
"attachments",
|
|
22
|
+
"images",
|
|
23
|
+
"clipboard"
|
|
24
|
+
],
|
|
25
|
+
"files": [
|
|
26
|
+
"README.md",
|
|
27
|
+
"CHANGELOG.md",
|
|
28
|
+
"extensions/index.ts",
|
|
29
|
+
"extensions/package.json",
|
|
30
|
+
"extensions/lib/"
|
|
31
|
+
],
|
|
32
|
+
"pi": {
|
|
33
|
+
"extensions": [
|
|
34
|
+
"./extensions/index.ts"
|
|
35
|
+
]
|
|
36
|
+
},
|
|
37
|
+
"scripts": {
|
|
38
|
+
"test": "cd extensions && npx mocha",
|
|
39
|
+
"typecheck": "tsc --noEmit"
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"@earendil-works/pi-coding-agent": ">=0.84.3 <0.85.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@earendil-works/pi-coding-agent": "^0.84.3",
|
|
46
|
+
"@earendil-works/pi-ai": "^0.84.0",
|
|
47
|
+
"@earendil-works/pi-tui": "^0.84.3",
|
|
48
|
+
"@types/mocha": "^10.0.10",
|
|
49
|
+
"@types/node": "^20.19.43",
|
|
50
|
+
"chai": "^4.5.0",
|
|
51
|
+
"mocha": "^11.8.0",
|
|
52
|
+
"tsx": "^4.22.4",
|
|
53
|
+
"typescript": "^5.9.3"
|
|
54
|
+
},
|
|
55
|
+
"overrides": {
|
|
56
|
+
"serialize-javascript@>=5.0.0 <7.0.5": "^7.0.5",
|
|
57
|
+
"js-yaml@>=4.0.0 <4.3.1": "^4.3.1",
|
|
58
|
+
"brace-expansion@>=2.0.0 <2.1.4": "^2.1.4",
|
|
59
|
+
"diff": "^8.0.3"
|
|
60
|
+
}
|
|
61
|
+
}
|