Skip to content

Commit b3b1233

Browse files
Parse quoted image path mentions without losing whitespace or false-positive matches (#889)
* Parse quoted image path mentions without losing inner whitespace macOS screenshot paths often arrive single-quoted with spaces and narrow no-break spaces; the old whole-prompt regex could not own a balanced wrapper, so those mentions never became attachments. * Ignore prose apostrophes and quotes when finding image paths Contractions were pairing with later path quotes, and prose-quoted sentences were resolving as bogus relative paths. Only treat path- shaped wrappers as wrappers, and still find absolute paths inside ordinary quotes.
1 parent 7b09b92 commit b3b1233

3 files changed

Lines changed: 273 additions & 39 deletions

File tree

‎src/tui/image-attachments.test.ts‎

Lines changed: 116 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { describe, expect, test } from "bun:test";
22
import { deflateSync } from "node:zlib";
33
import { unlink } from "node:fs/promises";
4-
import { tmpdir } from "node:os";
5-
import { join } from "node:path";
4+
import { homedir, tmpdir } from "node:os";
5+
import { join, resolve } from "node:path";
66
import { defined } from "../../tests/helpers/defined.js";
77
import {
88
findDuplicateAttachment,
@@ -72,29 +72,133 @@ function buildTestPng(width: number, height: number): Buffer {
7272
]);
7373
}
7474

75-
describe("image attachment helpers", () => {
76-
test("detects supported image MIME types from paths", () => {
77-
expect(imageMimeTypeForPath("shot.png")).toBe("image/png");
78-
expect(imageMimeTypeForPath("photo.JPEG")).toBe("image/jpeg");
79-
expect(imageMimeTypeForPath("animation.gif")).toBe("image/gif");
80-
expect(imageMimeTypeForPath("notes.txt")).toBeUndefined();
75+
describe("findImagePathMentions", () => {
76+
test("preserves balanced wrappers and exact inner whitespace", () => {
77+
const observed =
78+
"/Users/operator/Desktop/Screenshot 2026-09-10 at 11.42.07\u202fAM.png";
79+
80+
expect(findImagePathMentions(`inspect '${observed}'`, "/repo")).toEqual([
81+
{ raw: `'${observed}'`, path: observed },
82+
]);
83+
expect(
84+
findImagePathMentions(
85+
'"./screen (final), version; 2.png", `~/screen: final!.webp`!',
86+
"/repo",
87+
),
88+
).toEqual([
89+
{
90+
raw: '"./screen (final), version; 2.png"',
91+
path: "/repo/screen (final), version; 2.png",
92+
},
93+
{
94+
raw: "`~/screen: final!.webp`",
95+
path: join(homedir(), "screen: final!.webp"),
96+
},
97+
]);
8198
});
8299

83-
test("finds image paths embedded in instructions", () => {
100+
test("normalizes wrapped file URLs and keeps quoted backslashes literal", () => {
84101
expect(
85102
findImagePathMentions(
86-
"what is in /tmp/Screenshot 2026-01-01.png please",
103+
'`file:///tmp/my%20shot.jpeg` and "/tmp/my\\ shot.png"',
104+
"/repo",
105+
),
106+
).toEqual([
107+
{ raw: "`file:///tmp/my%20shot.jpeg`", path: "/tmp/my shot.jpeg" },
108+
{ raw: '"/tmp/my\\ shot.png"', path: "/tmp/my\\ shot.png" },
109+
]);
110+
});
111+
112+
test("preserves every unquoted terminator", () => {
113+
expect(findImagePathMentions("/tmp/shot.png", "/repo")).toEqual([
114+
{ raw: "/tmp/shot.png", path: "/tmp/shot.png" },
115+
]);
116+
for (const terminator of [
117+
" ",
118+
"\t",
119+
"\n",
120+
")",
121+
",",
122+
".",
123+
";",
124+
":",
125+
"!",
126+
"?",
127+
]) {
128+
expect(
129+
findImagePathMentions(`/tmp/shot.png${terminator}after`, "/repo"),
130+
).toEqual([{ raw: "/tmp/shot.png", path: "/tmp/shot.png" }]);
131+
}
132+
});
133+
134+
test("preserves unquoted parsing and normalization", () => {
135+
expect(
136+
findImagePathMentions(
137+
"what is in /tmp/Screenshot 2026-01-01.png please file:///tmp/my%20shot.jpg ./other.webp",
87138
"/repo",
88139
),
89140
).toEqual([
90141
{
91142
raw: "/tmp/Screenshot 2026-01-01.png",
92143
path: "/tmp/Screenshot 2026-01-01.png",
93144
},
145+
{ raw: "file:///tmp/my%20shot.jpg", path: "/tmp/my shot.jpg" },
146+
{ raw: "./other.webp", path: "/repo/other.webp" },
147+
]);
148+
expect(findImagePathMentions("./relative path.png", "/repo")).toEqual([]);
149+
});
150+
151+
test("bounds unmatched wrappers to their line and old unquoted boundaries", () => {
152+
expect(
153+
findImagePathMentions(
154+
"look at '/tmp/first.png next\nthen' /tmp/second.jpg",
155+
"/repo",
156+
),
157+
).toEqual([
158+
{ raw: "/tmp/first.png", path: "/tmp/first.png" },
159+
{ raw: "/tmp/second.jpg", path: "/tmp/second.jpg" },
160+
]);
161+
expect(findImagePathMentions('/tmp/shot.png"', "/repo")).toEqual([]);
162+
expect(findImagePathMentions('"./first.png\ncontinued"', "/repo")).toEqual([
163+
{ raw: "./first.png", path: resolve("/repo", "first.png") },
94164
]);
165+
});
166+
167+
test("keeps source order, deduplicates normalized paths, and rejects unsupported candidates", () => {
95168
expect(
96-
findImagePathMentions("look at file:///tmp/my%20shot.png", "/repo"),
97-
).toEqual([{ raw: "file:///tmp/my%20shot.png", path: "/tmp/my shot.png" }]);
169+
findImagePathMentions(
170+
"`./first.gif` /repo/second.JPG './first.gif' image.png './bad.bmp' \"./valid.png.txt\"",
171+
"/repo",
172+
),
173+
).toEqual([
174+
{ raw: "`./first.gif`", path: "/repo/first.gif" },
175+
{ raw: "/repo/second.JPG", path: "/repo/second.JPG" },
176+
]);
177+
});
178+
179+
test("does not let contractions steal single-quoted path wrappers", () => {
180+
const observed = "/tmp/Screenshot 2026-09-10 at 11.42.07\u202fAM.png";
181+
expect(findImagePathMentions(`what's in '${observed}'?`, "/repo")).toEqual([
182+
{ raw: `'${observed}'`, path: observed },
183+
]);
184+
expect(
185+
findImagePathMentions(`don't use '/tmp/shot.png' please`, "/repo"),
186+
).toEqual([{ raw: "'/tmp/shot.png'", path: "/tmp/shot.png" }]);
187+
});
188+
189+
test("does not let prose quotes invent a relative path over an absolute mention", () => {
190+
expect(
191+
findImagePathMentions(`He said "look at /tmp/shot.png" today`, "/repo"),
192+
).toEqual([{ raw: "/tmp/shot.png", path: "/tmp/shot.png" }]);
193+
});
194+
});
195+
196+
describe("image attachment helpers", () => {
197+
test("detects supported image MIME types from paths", () => {
198+
expect(imageMimeTypeForPath("shot.png")).toBe("image/png");
199+
expect(imageMimeTypeForPath("photo.JPEG")).toBe("image/jpeg");
200+
expect(imageMimeTypeForPath("animation.gif")).toBe("image/gif");
201+
expect(imageMimeTypeForPath("notes.txt")).toBeUndefined();
98202
});
99203

100204
test("leaves small images untouched", async () => {

‎src/tui/image-attachments.ts‎

Lines changed: 143 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -67,19 +67,131 @@ export function findImagePathMentions(
6767
): ImagePathMention[] {
6868
const mentions: ImagePathMention[] = [];
6969
const seen = new Set<string>();
70-
const pattern =
71-
/file:\/\/\S+|(?:[~./]|[A-Za-z]:)[^\n\r]*?\.(?:png|jpe?g|webp|gif)(?=$|\s|[),.;:!?])/gi;
72-
let match: RegExpExecArray | null;
73-
while ((match = pattern.exec(text)) !== null) {
74-
const raw = trimTrailingPunctuation(match[0] ?? "");
75-
const path = normalizeImagePathCandidate(raw, cwd);
76-
if (path === undefined || seen.has(path)) continue;
70+
71+
const push = (raw: string, path: string | undefined): void => {
72+
if (path === undefined || seen.has(path)) return;
7773
seen.add(path);
7874
mentions.push({ raw, path });
75+
};
76+
77+
let lineStart = 0;
78+
while (lineStart <= text.length) {
79+
let lineEnd = text.indexOf("\n", lineStart);
80+
if (lineEnd === -1) lineEnd = text.length;
81+
// Keep a lone trailing \r on CRLF out of the scan window.
82+
const contentEnd =
83+
lineEnd > lineStart && text[lineEnd - 1] === "\r" ? lineEnd - 1 : lineEnd;
84+
scanImagePathLine(text, lineStart, contentEnd, cwd, push);
85+
if (lineEnd === text.length) break;
86+
lineStart = lineEnd + 1;
7987
}
8088
return mentions;
8189
}
8290

91+
const WRAPPERS = new Set(["'", '"', "`"]);
92+
const UNQUOTED_AT =
93+
/^(?:file:\/\/\S+|(?:[~./]|[A-Za-z]:)[^\n\r]*?\.(?:png|jpe?g|webp|gif)(?=$|\s|[),.;:!?]))/i;
94+
95+
function scanImagePathLine(
96+
text: string,
97+
start: number,
98+
end: number,
99+
cwd: string,
100+
push: (raw: string, path: string | undefined) => void,
101+
): void {
102+
let i = start;
103+
while (i < end) {
104+
const ch = text[i];
105+
if (ch === undefined) break;
106+
if (WRAPPERS.has(ch)) {
107+
// Contractions/possessives (`what's`, `don't`) are not quote openers.
108+
if (ch === "'" && i > start && isWordChar(text[i - 1])) {
109+
i += 1;
110+
continue;
111+
}
112+
const close = text.indexOf(ch, i + 1);
113+
if (close !== -1 && close < end) {
114+
const inner = text.slice(i + 1, close);
115+
if (looksLikeQuotedImagePath(inner)) {
116+
const raw = text.slice(i, close + 1);
117+
push(raw, normalizeImagePathCandidate(inner, cwd, true));
118+
i = close + 1;
119+
continue;
120+
}
121+
// Balanced quotes around prose are not path wrappers. Search inside
122+
// so an absolute path can still be found, then resume after the closer.
123+
scanImagePathLine(text, i + 1, close, cwd, push);
124+
i = close + 1;
125+
continue;
126+
}
127+
// Unmatched opener is not a wrapper; same-line unquoted fallback only.
128+
i += 1;
129+
continue;
130+
}
131+
132+
if (canStartUnquotedPath(text, i, end)) {
133+
const match = UNQUOTED_AT.exec(text.slice(i, end));
134+
if (match?.[0] !== undefined) {
135+
const raw = trimTrailingPunctuation(match[0]);
136+
push(raw, normalizeImagePathCandidate(raw, cwd, false));
137+
i += match[0].length;
138+
continue;
139+
}
140+
}
141+
i += 1;
142+
}
143+
}
144+
145+
function isWordChar(ch: string | undefined): boolean {
146+
if (ch === undefined || ch.length !== 1) return false;
147+
return (
148+
(ch >= "0" && ch <= "9") ||
149+
(ch >= "A" && ch <= "Z") ||
150+
(ch >= "a" && ch <= "z")
151+
);
152+
}
153+
154+
function looksLikeQuotedImagePath(inner: string): boolean {
155+
if (inner.startsWith("file://")) return true;
156+
if (inner === "~" || inner.startsWith("~/") || inner.startsWith("~\\"))
157+
return true;
158+
if (inner.startsWith("/") || inner.startsWith("\\")) return true;
159+
if (
160+
inner.startsWith("./") ||
161+
inner.startsWith("../") ||
162+
inner.startsWith(".\\") ||
163+
inner.startsWith("..\\")
164+
) {
165+
return true;
166+
}
167+
const drive = inner[0];
168+
const sep = inner[2];
169+
return (
170+
inner.length >= 3 &&
171+
drive !== undefined &&
172+
sep !== undefined &&
173+
((drive >= "A" && drive <= "Z") || (drive >= "a" && drive <= "z")) &&
174+
inner[1] === ":" &&
175+
(sep === "/" || sep === "\\")
176+
);
177+
}
178+
179+
function canStartUnquotedPath(text: string, i: number, end: number): boolean {
180+
if (i >= end) return false;
181+
if (text.startsWith("file://", i)) return true;
182+
const ch = text[i];
183+
if (ch === undefined) return false;
184+
if (ch === "~" || ch === "." || ch === "/") return true;
185+
if (
186+
i + 1 < end &&
187+
((ch >= "A" && ch <= "Z") || (ch >= "a" && ch <= "z")) &&
188+
text[i + 1] === ":"
189+
) {
190+
return true;
191+
}
192+
return false;
193+
}
194+
83195
export async function imageAttachmentFromPath(
84196
path: string,
85197
): Promise<AttachImageResult> {
@@ -263,40 +375,44 @@ export function userRowText(
263375
function normalizeImagePathCandidate(
264376
input: string,
265377
cwd: string,
378+
quoted: boolean,
266379
): string | undefined {
267-
const unquoted = unquoteShellPath(trimTrailingPunctuation(input.trim()));
268-
if (unquoted === undefined) return undefined;
380+
const resolved = quoted
381+
? resolveQuotedImagePath(input)
382+
: resolveUnquotedImagePath(input);
383+
if (resolved === undefined) return undefined;
269384
const expanded =
270-
unquoted === "~" || unquoted.startsWith("~/")
271-
? resolve(homedir(), unquoted.slice(2))
272-
: unquoted;
273-
if (
274-
/\s/.test(expanded) &&
275-
!isAbsolute(expanded) &&
276-
input[0] !== "'" &&
277-
input[0] !== '"'
278-
) {
385+
resolved === "~" || resolved.startsWith("~/")
386+
? resolve(homedir(), resolved.slice(2))
387+
: resolved;
388+
if (/\s/.test(expanded) && !isAbsolute(expanded) && !quoted) {
279389
return undefined;
280390
}
281391
const abs = isAbsolute(expanded) ? expanded : resolve(cwd, expanded);
282392
return imageMimeTypeForPath(abs) === undefined ? undefined : abs;
283393
}
284394

285-
function unquoteShellPath(input: string): string | undefined {
286-
if (input.startsWith("file://")) {
395+
function resolveQuotedImagePath(inner: string): string | undefined {
396+
if (inner.startsWith("file://")) {
287397
try {
288-
return decodeURIComponent(new URL(input).pathname);
398+
return decodeURIComponent(new URL(inner).pathname);
289399
} catch {
290400
return undefined;
291401
}
292402
}
293-
if (
294-
(input.startsWith("'") && input.endsWith("'")) ||
295-
(input.startsWith('"') && input.endsWith('"'))
296-
) {
297-
return input.slice(1, -1);
403+
return inner;
404+
}
405+
406+
function resolveUnquotedImagePath(input: string): string | undefined {
407+
const trimmed = trimTrailingPunctuation(input.trim());
408+
if (trimmed.startsWith("file://")) {
409+
try {
410+
return decodeURIComponent(new URL(trimmed).pathname);
411+
} catch {
412+
return undefined;
413+
}
298414
}
299-
return input.replace(/\\([\\\s'"()])/g, "$1");
415+
return trimmed.replace(/\\([\\\s'"()])/g, "$1");
300416
}
301417

302418
function trimTrailingPunctuation(input: string): string {

‎src/tui/prompt-attachments.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,20 @@ describe("ingestPathMentions", () => {
4343
expect(result.attachments).toHaveLength(1);
4444
});
4545

46+
test("replaces the full wrapped token including quotes", async () => {
47+
const load = async (path: string): Promise<AttachImageResult> => ({
48+
ok: true,
49+
attachment: { ...attachment("shot.png"), path },
50+
});
51+
const result = await ingestPathMentions(
52+
"look at '/tmp/shot.png' please",
53+
"/repo",
54+
load,
55+
);
56+
expect(result.text).toBe("look at [Attached image: shot.png] please");
57+
expect(result.attachments).toHaveLength(1);
58+
});
59+
4660
test("keeps the raw path when loading fails", async () => {
4761
const load = async (): Promise<AttachImageResult> => ({
4862
ok: false,

0 commit comments

Comments
 (0)