Skip to content

Commit ac85e36

Browse files
committed
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.
1 parent cea3b8b commit ac85e36

2 files changed

Lines changed: 197 additions & 39 deletions

File tree

src/tui/image-attachments.test.ts

Lines changed: 100 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,117 @@ 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+
180+
describe("image attachment helpers", () => {
181+
test("detects supported image MIME types from paths", () => {
182+
expect(imageMimeTypeForPath("shot.png")).toBe("image/png");
183+
expect(imageMimeTypeForPath("photo.JPEG")).toBe("image/jpeg");
184+
expect(imageMimeTypeForPath("animation.gif")).toBe("image/gif");
185+
expect(imageMimeTypeForPath("notes.txt")).toBeUndefined();
98186
});
99187

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

src/tui/image-attachments.ts

Lines changed: 97 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -67,19 +67,85 @@ 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+
const close = text.indexOf(ch, i + 1);
108+
if (close !== -1 && close < end) {
109+
const raw = text.slice(i, close + 1);
110+
const inner = text.slice(i + 1, close);
111+
push(raw, normalizeImagePathCandidate(inner, cwd, true));
112+
i = close + 1;
113+
continue;
114+
}
115+
// Unmatched opener is not a wrapper; same-line unquoted fallback only.
116+
i += 1;
117+
continue;
118+
}
119+
120+
if (canStartUnquotedPath(text, i, end)) {
121+
const match = UNQUOTED_AT.exec(text.slice(i, end));
122+
if (match?.[0] !== undefined) {
123+
const raw = trimTrailingPunctuation(match[0]);
124+
push(raw, normalizeImagePathCandidate(raw, cwd, false));
125+
i += match[0].length;
126+
continue;
127+
}
128+
}
129+
i += 1;
130+
}
131+
}
132+
133+
function canStartUnquotedPath(text: string, i: number, end: number): boolean {
134+
if (i >= end) return false;
135+
if (text.startsWith("file://", i)) return true;
136+
const ch = text[i];
137+
if (ch === undefined) return false;
138+
if (ch === "~" || ch === "." || ch === "/") return true;
139+
if (
140+
i + 1 < end &&
141+
((ch >= "A" && ch <= "Z") || (ch >= "a" && ch <= "z")) &&
142+
text[i + 1] === ":"
143+
) {
144+
return true;
145+
}
146+
return false;
147+
}
148+
83149
export async function imageAttachmentFromPath(
84150
path: string,
85151
): Promise<AttachImageResult> {
@@ -263,40 +329,44 @@ export function userRowText(
263329
function normalizeImagePathCandidate(
264330
input: string,
265331
cwd: string,
332+
quoted: boolean,
266333
): string | undefined {
267-
const unquoted = unquoteShellPath(trimTrailingPunctuation(input.trim()));
268-
if (unquoted === undefined) return undefined;
334+
const resolved = quoted
335+
? resolveQuotedImagePath(input)
336+
: resolveUnquotedImagePath(input);
337+
if (resolved === undefined) return undefined;
269338
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-
) {
339+
resolved === "~" || resolved.startsWith("~/")
340+
? resolve(homedir(), resolved.slice(2))
341+
: resolved;
342+
if (/\s/.test(expanded) && !isAbsolute(expanded) && !quoted) {
279343
return undefined;
280344
}
281345
const abs = isAbsolute(expanded) ? expanded : resolve(cwd, expanded);
282346
return imageMimeTypeForPath(abs) === undefined ? undefined : abs;
283347
}
284348

285-
function unquoteShellPath(input: string): string | undefined {
286-
if (input.startsWith("file://")) {
349+
function resolveQuotedImagePath(inner: string): string | undefined {
350+
if (inner.startsWith("file://")) {
287351
try {
288-
return decodeURIComponent(new URL(input).pathname);
352+
return decodeURIComponent(new URL(inner).pathname);
289353
} catch {
290354
return undefined;
291355
}
292356
}
293-
if (
294-
(input.startsWith("'") && input.endsWith("'")) ||
295-
(input.startsWith('"') && input.endsWith('"'))
296-
) {
297-
return input.slice(1, -1);
357+
return inner;
358+
}
359+
360+
function resolveUnquotedImagePath(input: string): string | undefined {
361+
const trimmed = trimTrailingPunctuation(input.trim());
362+
if (trimmed.startsWith("file://")) {
363+
try {
364+
return decodeURIComponent(new URL(trimmed).pathname);
365+
} catch {
366+
return undefined;
367+
}
298368
}
299-
return input.replace(/\\([\\\s'"()])/g, "$1");
369+
return trimmed.replace(/\\([\\\s'"()])/g, "$1");
300370
}
301371

302372
function trimTrailingPunctuation(input: string): string {

0 commit comments

Comments
 (0)