@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 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
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "type": "module"
3
+ }
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
+ }