@bacnh85/pi-attachments 0.2.0 → 0.2.2

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 CHANGED
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.2
4
+
5
+ - **Directories are not attachments.** Pasting/dropping a path that is not an
6
+ existing regular file (a directory, a typo) no longer becomes an
7
+ `[[attach:...]]` token + 📎 chip — it stays plain text in the prompt. The
8
+ model still sees the path and can `ls`/`read` it. The clipboard shortcut
9
+ (alt+shift+v) likewise skips folder copies; a folder-only clipboard reports
10
+ "No files in clipboard".
11
+ - Fixed: a dropped/pasted file whose name contains `]` (e.g. `we]ird.png`) now
12
+ attaches on submit instead of silently leaking a dead `[[attach:...]]` token;
13
+ inline mode no longer nests `<file>` blocks when a dropped token and typed
14
+ paths are combined.
15
+
16
+ ## 0.2.1 (2026-09-05)
17
+
18
+ - Widen Pi SDK peer range to `>=0.85.0 <0.86.0` and bump devDep to `^0.85.0` for Pi 0.85.0 compatibility (no breaking changes; peer cap widening only).
19
+
3
20
  ## 0.2.0
4
21
 
5
22
  - **Readable path references by default (read on demand).** `[[attach:name]]`
package/README.md CHANGED
@@ -81,7 +81,9 @@ pi install @bacnh85/pi-attachments
81
81
  dropped file — no dead tokens.
82
82
  - Conservative matching: prose like "see main.rs" or "the .jpg extension" never
83
83
  triggers anything — images require an existing file with an image extension,
84
- text inlining requires an absolute existing path.
84
+ text inlining requires an absolute existing path. Pasted/dropped paths that
85
+ are not existing regular files (directories, typos) pass through as plain
86
+ text — no chip, no token.
85
87
  - pi core auto-resizes attached images (see the `images.autoResize` setting).
86
88
  - kitty's OSC 72 drag-drop protocol is not supported (requires raw stdin access
87
89
  that extensions don't have); kitty <0.47 drops paths, which work.
@@ -2,9 +2,10 @@
2
2
  * pi-attachments — real attachments from pasted/dropped file paths.
3
3
  *
4
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).
5
+ * 1. onTerminalInput intercepts path-only bracketed pastes BEFORE the editor:
6
+ * each existing regular file becomes an [[attach:name]] token and a 📎 chip
7
+ * list shows above the editor (widget); non-file paths (directories, typos)
8
+ * stay literal text, and an all-non-file payload passes through untouched.
8
9
  * 2. On submit, the input hook resolves tokens:
9
10
  * - images → 📎 path text + real ImageContent parts
10
11
  * - text files → 📎 path text (default; model reads on demand via read)
@@ -16,7 +17,7 @@ import type { ExtensionAPI, InputEvent, TerminalInputHandler } from "@earendil-w
16
17
  import { detectSupportedImageMimeTypeFromFile } from "@earendil-works/pi-coding-agent";
17
18
  import { readFile, stat } from "node:fs/promises";
18
19
  import { readClipboardFilePaths } from "./lib/clipboard-files";
19
- import { absolutePathSpans, extractImagePaths } from "./lib/paths";
20
+ import { absolutePathSpans, extractImagePaths, isFile } from "./lib/paths";
20
21
  import { lookup, remember } from "./lib/registry";
21
22
  import { loadSettings } from "./lib/settings";
22
23
  import { AttachmentTray } from "./lib/tray";
@@ -57,10 +58,18 @@ export default function piAttachments(pi: ExtensionAPI): void {
57
58
  if (!m || !looksLikePathPayload(m[1])) return undefined;
58
59
  const tokens: string[] = [];
59
60
  for (const raw of splitPathTokens(m[1])) {
60
- const item = tray.add(raw.replace(/\\ /g, " "));
61
+ const path = raw.replace(/\\ /g, " ");
62
+ if (!isFile(path)) {
63
+ // ponytail: directories/nonexistent paths stay literal text — the model
64
+ // reads or lists them itself; upgrade to 📁 chips if dir drops matter
65
+ tokens.push(raw);
66
+ continue;
67
+ }
68
+ const item = tray.add(path);
61
69
  remember(item.name, item.path); // survive session restarts
62
70
  tokens.push(item.token);
63
71
  }
72
+ if (tokens.every((t) => !t.startsWith("[[attach:"))) return undefined;
64
73
  updateWidget();
65
74
  return { data: tokens.join(" ") };
66
75
  };
@@ -88,13 +97,18 @@ export default function piAttachments(pi: ExtensionAPI): void {
88
97
 
89
98
  const images: Array<{ type: "image"; data: string; mimeType: string }> = [...(event.images ?? [])];
90
99
  const attachedImages = new Set<string>(); // paths already attached via tokens
91
- let text = raw;
100
+ // All replacements are spans in `raw` coordinates (tokens + typed paths are
101
+ // disjoint by construction), applied right-to-left at the end — so generated
102
+ // blocks are never re-scanned (no nested <file> inlining).
103
+ const replacements: Array<{ start: number; end: number; block: string }> = [];
92
104
 
93
105
  // a. Resolve [[attach:name]] tokens (tray first, then persistent registry).
94
- for (const m of raw.matchAll(/\[\[attach:([^\]]+)\]\]/g)) {
106
+ // `]` in a basename is allowed when not followed by the closing `]]`
107
+ // (e.g. a dropped file named "we]ird.png").
108
+ for (const m of raw.matchAll(/\[\[attach:((?:[^\]]|\](?!\]))+)\]\]/g)) {
95
109
  const token = m[0];
96
110
  const name = m[1];
97
- if (!text.includes(token)) continue; // duplicate token already replaced
111
+ const start = m.index;
98
112
  const trayItem = tray.resolve(raw).find((i) => i.token === token);
99
113
  const path = trayItem?.path ?? lookup(name);
100
114
  if (!path) continue; // unknown token — leave as-is for the model
@@ -111,29 +125,29 @@ export default function piAttachments(pi: ExtensionAPI): void {
111
125
  /* unreadable → skip */
112
126
  }
113
127
  }
114
- text = text.split(token).join(`📎 ${path}`);
128
+ replacements.push({ start, end: start + token.length, block: `📎 ${path}` });
115
129
  continue;
116
130
  }
117
131
 
118
132
  // Non-image: inline as <file> only when inlineTextFiles is on and size allows;
119
133
  // otherwise resolve to a 📎 path the model reads on demand.
134
+ let block = `📎 ${path}`;
120
135
  if (settings.inlineTextFiles) {
121
136
  try {
122
137
  const s = await stat(path);
123
138
  if (s.size <= settings.maxInlineBytes) {
124
139
  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;
140
+ block = `<file name="${path}">\n${content}\n</file>`;
127
141
  }
128
142
  } catch {
129
- /* fall through to 📎 path */
143
+ /* keep 📎 path */
130
144
  }
131
145
  }
132
- text = text.split(token).join(`📎 ${path}`);
146
+ replacements.push({ start, end: start + token.length, block });
133
147
  }
134
148
 
135
149
  // b. Existing image paths typed elsewhere in the message → attach too.
136
- for (const p of extractImagePaths(text)) {
150
+ for (const p of extractImagePaths(raw)) {
137
151
  if (attachedImages.has(p)) continue;
138
152
  try {
139
153
  const mimeType = await detectSupportedImageMimeTypeFromFile(p);
@@ -146,11 +160,10 @@ export default function piAttachments(pi: ExtensionAPI): void {
146
160
  }
147
161
 
148
162
  // 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.
163
+ // the message → <file> blocks. One greedy regex pass yields disjoint
164
+ // spans that never overlap the token spans above.
151
165
  if (settings.inlineTextFiles) {
152
- const replacements: Array<{ start: number; end: number; block: string }> = [];
153
- for (const span of absolutePathSpans(text)) {
166
+ for (const span of absolutePathSpans(raw)) {
154
167
  try {
155
168
  const s = await stat(span.path);
156
169
  if (s.size > settings.maxInlineBytes) continue;
@@ -160,11 +173,13 @@ export default function piAttachments(pi: ExtensionAPI): void {
160
173
  /* unreadable file → skip */
161
174
  }
162
175
  }
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
- }
176
+ }
177
+
178
+ let text = raw;
179
+ if (replacements.length) {
180
+ replacements.sort((a, b) => b.start - a.start);
181
+ for (const r of replacements) {
182
+ text = text.slice(0, r.start) + r.block + text.slice(r.end);
168
183
  }
169
184
  }
170
185
 
@@ -181,7 +196,8 @@ export default function piAttachments(pi: ExtensionAPI): void {
181
196
  pi.registerShortcut(settings.pasteFileShortcut as Parameters<ExtensionAPI["registerShortcut"]>[0], {
182
197
  description: "Paste file(s) from clipboard as attachments",
183
198
  handler: async (ctx) => {
184
- const paths = await readClipboardFilePaths();
199
+ // only existing regular files are attachable — Finder/Explorer folder copies pass through
200
+ const paths = (await readClipboardFilePaths()).filter(isFile);
185
201
  if (paths.length === 0) {
186
202
  ctx.ui.notify("No files in clipboard", "info");
187
203
  return;
@@ -63,7 +63,7 @@ function unescape(p: string): string {
63
63
  return p.replace(/\\ /g, " ");
64
64
  }
65
65
 
66
- function isFile(p: string): boolean {
66
+ export function isFile(p: string): boolean {
67
67
  try {
68
68
  return statSync(p).isFile();
69
69
  } catch {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
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.",
3
+ "version": "0.2.2",
4
+ "description": "Image and file attachments for Pi converts pasted/dropped file paths into real image attachments, inlines text files, and pastes clipboard file references.",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
7
7
  "access": "public"
@@ -39,12 +39,12 @@
39
39
  "typecheck": "tsc --noEmit"
40
40
  },
41
41
  "peerDependencies": {
42
- "@earendil-works/pi-coding-agent": ">=0.84.3 <0.85.0"
42
+ "@earendil-works/pi-coding-agent": ">=0.84.3 <0.86.0"
43
43
  },
44
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",
45
+ "@earendil-works/pi-ai": "^0.85.0",
46
+ "@earendil-works/pi-coding-agent": "^0.85.0",
47
+ "@earendil-works/pi-tui": "^0.85.0",
48
48
  "@types/mocha": "^10.0.10",
49
49
  "@types/node": "^20.19.43",
50
50
  "chai": "^4.5.0",