diff --git a/src/tui/image-attachments.test.ts b/src/tui/image-attachments.test.ts index 87833448d..519e03264 100644 --- a/src/tui/image-attachments.test.ts +++ b/src/tui/image-attachments.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from "bun:test"; import { deflateSync } from "node:zlib"; import { unlink } from "node:fs/promises"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { homedir, tmpdir } from "node:os"; +import { join, resolve } from "node:path"; import { defined } from "../../tests/helpers/defined.js"; import { findDuplicateAttachment, @@ -72,18 +72,69 @@ function buildTestPng(width: number, height: number): Buffer { ]); } -describe("image attachment helpers", () => { - test("detects supported image MIME types from paths", () => { - expect(imageMimeTypeForPath("shot.png")).toBe("image/png"); - expect(imageMimeTypeForPath("photo.JPEG")).toBe("image/jpeg"); - expect(imageMimeTypeForPath("animation.gif")).toBe("image/gif"); - expect(imageMimeTypeForPath("notes.txt")).toBeUndefined(); +describe("findImagePathMentions", () => { + test("preserves balanced wrappers and exact inner whitespace", () => { + const observed = + "/Users/operator/Desktop/Screenshot 2026-09-10 at 11.42.07\u202fAM.png"; + + expect(findImagePathMentions(`inspect '${observed}'`, "/repo")).toEqual([ + { raw: `'${observed}'`, path: observed }, + ]); + expect( + findImagePathMentions( + '"./screen (final), version; 2.png", `~/screen: final!.webp`!', + "/repo", + ), + ).toEqual([ + { + raw: '"./screen (final), version; 2.png"', + path: "/repo/screen (final), version; 2.png", + }, + { + raw: "`~/screen: final!.webp`", + path: join(homedir(), "screen: final!.webp"), + }, + ]); }); - test("finds image paths embedded in instructions", () => { + test("normalizes wrapped file URLs and keeps quoted backslashes literal", () => { expect( findImagePathMentions( - "what is in /tmp/Screenshot 2026-01-01.png please", + '`file:///tmp/my%20shot.jpeg` and "/tmp/my\\ shot.png"', + "/repo", + ), + ).toEqual([ + { raw: "`file:///tmp/my%20shot.jpeg`", path: "/tmp/my shot.jpeg" }, + { raw: '"/tmp/my\\ shot.png"', path: "/tmp/my\\ shot.png" }, + ]); + }); + + test("preserves every unquoted terminator", () => { + expect(findImagePathMentions("/tmp/shot.png", "/repo")).toEqual([ + { raw: "/tmp/shot.png", path: "/tmp/shot.png" }, + ]); + for (const terminator of [ + " ", + "\t", + "\n", + ")", + ",", + ".", + ";", + ":", + "!", + "?", + ]) { + expect( + findImagePathMentions(`/tmp/shot.png${terminator}after`, "/repo"), + ).toEqual([{ raw: "/tmp/shot.png", path: "/tmp/shot.png" }]); + } + }); + + test("preserves unquoted parsing and normalization", () => { + expect( + findImagePathMentions( + "what is in /tmp/Screenshot 2026-01-01.png please file:///tmp/my%20shot.jpg ./other.webp", "/repo", ), ).toEqual([ @@ -91,10 +142,63 @@ describe("image attachment helpers", () => { raw: "/tmp/Screenshot 2026-01-01.png", path: "/tmp/Screenshot 2026-01-01.png", }, + { raw: "file:///tmp/my%20shot.jpg", path: "/tmp/my shot.jpg" }, + { raw: "./other.webp", path: "/repo/other.webp" }, + ]); + expect(findImagePathMentions("./relative path.png", "/repo")).toEqual([]); + }); + + test("bounds unmatched wrappers to their line and old unquoted boundaries", () => { + expect( + findImagePathMentions( + "look at '/tmp/first.png next\nthen' /tmp/second.jpg", + "/repo", + ), + ).toEqual([ + { raw: "/tmp/first.png", path: "/tmp/first.png" }, + { raw: "/tmp/second.jpg", path: "/tmp/second.jpg" }, + ]); + expect(findImagePathMentions('/tmp/shot.png"', "/repo")).toEqual([]); + expect(findImagePathMentions('"./first.png\ncontinued"', "/repo")).toEqual([ + { raw: "./first.png", path: resolve("/repo", "first.png") }, ]); + }); + + test("keeps source order, deduplicates normalized paths, and rejects unsupported candidates", () => { expect( - findImagePathMentions("look at file:///tmp/my%20shot.png", "/repo"), - ).toEqual([{ raw: "file:///tmp/my%20shot.png", path: "/tmp/my shot.png" }]); + findImagePathMentions( + "`./first.gif` /repo/second.JPG './first.gif' image.png './bad.bmp' \"./valid.png.txt\"", + "/repo", + ), + ).toEqual([ + { raw: "`./first.gif`", path: "/repo/first.gif" }, + { raw: "/repo/second.JPG", path: "/repo/second.JPG" }, + ]); + }); + + test("does not let contractions steal single-quoted path wrappers", () => { + const observed = "/tmp/Screenshot 2026-09-10 at 11.42.07\u202fAM.png"; + expect(findImagePathMentions(`what's in '${observed}'?`, "/repo")).toEqual([ + { raw: `'${observed}'`, path: observed }, + ]); + expect( + findImagePathMentions(`don't use '/tmp/shot.png' please`, "/repo"), + ).toEqual([{ raw: "'/tmp/shot.png'", path: "/tmp/shot.png" }]); + }); + + test("does not let prose quotes invent a relative path over an absolute mention", () => { + expect( + findImagePathMentions(`He said "look at /tmp/shot.png" today`, "/repo"), + ).toEqual([{ raw: "/tmp/shot.png", path: "/tmp/shot.png" }]); + }); +}); + +describe("image attachment helpers", () => { + test("detects supported image MIME types from paths", () => { + expect(imageMimeTypeForPath("shot.png")).toBe("image/png"); + expect(imageMimeTypeForPath("photo.JPEG")).toBe("image/jpeg"); + expect(imageMimeTypeForPath("animation.gif")).toBe("image/gif"); + expect(imageMimeTypeForPath("notes.txt")).toBeUndefined(); }); test("leaves small images untouched", async () => { diff --git a/src/tui/image-attachments.ts b/src/tui/image-attachments.ts index ce304ba4a..1ed38a731 100644 --- a/src/tui/image-attachments.ts +++ b/src/tui/image-attachments.ts @@ -67,19 +67,131 @@ export function findImagePathMentions( ): ImagePathMention[] { const mentions: ImagePathMention[] = []; const seen = new Set(); - const pattern = - /file:\/\/\S+|(?:[~./]|[A-Za-z]:)[^\n\r]*?\.(?:png|jpe?g|webp|gif)(?=$|\s|[),.;:!?])/gi; - let match: RegExpExecArray | null; - while ((match = pattern.exec(text)) !== null) { - const raw = trimTrailingPunctuation(match[0] ?? ""); - const path = normalizeImagePathCandidate(raw, cwd); - if (path === undefined || seen.has(path)) continue; + + const push = (raw: string, path: string | undefined): void => { + if (path === undefined || seen.has(path)) return; seen.add(path); mentions.push({ raw, path }); + }; + + let lineStart = 0; + while (lineStart <= text.length) { + let lineEnd = text.indexOf("\n", lineStart); + if (lineEnd === -1) lineEnd = text.length; + // Keep a lone trailing \r on CRLF out of the scan window. + const contentEnd = + lineEnd > lineStart && text[lineEnd - 1] === "\r" ? lineEnd - 1 : lineEnd; + scanImagePathLine(text, lineStart, contentEnd, cwd, push); + if (lineEnd === text.length) break; + lineStart = lineEnd + 1; } return mentions; } +const WRAPPERS = new Set(["'", '"', "`"]); +const UNQUOTED_AT = + /^(?:file:\/\/\S+|(?:[~./]|[A-Za-z]:)[^\n\r]*?\.(?:png|jpe?g|webp|gif)(?=$|\s|[),.;:!?]))/i; + +function scanImagePathLine( + text: string, + start: number, + end: number, + cwd: string, + push: (raw: string, path: string | undefined) => void, +): void { + let i = start; + while (i < end) { + const ch = text[i]; + if (ch === undefined) break; + if (WRAPPERS.has(ch)) { + // Contractions/possessives (`what's`, `don't`) are not quote openers. + if (ch === "'" && i > start && isWordChar(text[i - 1])) { + i += 1; + continue; + } + const close = text.indexOf(ch, i + 1); + if (close !== -1 && close < end) { + const inner = text.slice(i + 1, close); + if (looksLikeQuotedImagePath(inner)) { + const raw = text.slice(i, close + 1); + push(raw, normalizeImagePathCandidate(inner, cwd, true)); + i = close + 1; + continue; + } + // Balanced quotes around prose are not path wrappers. Search inside + // so an absolute path can still be found, then resume after the closer. + scanImagePathLine(text, i + 1, close, cwd, push); + i = close + 1; + continue; + } + // Unmatched opener is not a wrapper; same-line unquoted fallback only. + i += 1; + continue; + } + + if (canStartUnquotedPath(text, i, end)) { + const match = UNQUOTED_AT.exec(text.slice(i, end)); + if (match?.[0] !== undefined) { + const raw = trimTrailingPunctuation(match[0]); + push(raw, normalizeImagePathCandidate(raw, cwd, false)); + i += match[0].length; + continue; + } + } + i += 1; + } +} + +function isWordChar(ch: string | undefined): boolean { + if (ch === undefined || ch.length !== 1) return false; + return ( + (ch >= "0" && ch <= "9") || + (ch >= "A" && ch <= "Z") || + (ch >= "a" && ch <= "z") + ); +} + +function looksLikeQuotedImagePath(inner: string): boolean { + if (inner.startsWith("file://")) return true; + if (inner === "~" || inner.startsWith("~/") || inner.startsWith("~\\")) + return true; + if (inner.startsWith("/") || inner.startsWith("\\")) return true; + if ( + inner.startsWith("./") || + inner.startsWith("../") || + inner.startsWith(".\\") || + inner.startsWith("..\\") + ) { + return true; + } + const drive = inner[0]; + const sep = inner[2]; + return ( + inner.length >= 3 && + drive !== undefined && + sep !== undefined && + ((drive >= "A" && drive <= "Z") || (drive >= "a" && drive <= "z")) && + inner[1] === ":" && + (sep === "/" || sep === "\\") + ); +} + +function canStartUnquotedPath(text: string, i: number, end: number): boolean { + if (i >= end) return false; + if (text.startsWith("file://", i)) return true; + const ch = text[i]; + if (ch === undefined) return false; + if (ch === "~" || ch === "." || ch === "/") return true; + if ( + i + 1 < end && + ((ch >= "A" && ch <= "Z") || (ch >= "a" && ch <= "z")) && + text[i + 1] === ":" + ) { + return true; + } + return false; +} + export async function imageAttachmentFromPath( path: string, ): Promise { @@ -263,40 +375,44 @@ export function userRowText( function normalizeImagePathCandidate( input: string, cwd: string, + quoted: boolean, ): string | undefined { - const unquoted = unquoteShellPath(trimTrailingPunctuation(input.trim())); - if (unquoted === undefined) return undefined; + const resolved = quoted + ? resolveQuotedImagePath(input) + : resolveUnquotedImagePath(input); + if (resolved === undefined) return undefined; const expanded = - unquoted === "~" || unquoted.startsWith("~/") - ? resolve(homedir(), unquoted.slice(2)) - : unquoted; - if ( - /\s/.test(expanded) && - !isAbsolute(expanded) && - input[0] !== "'" && - input[0] !== '"' - ) { + resolved === "~" || resolved.startsWith("~/") + ? resolve(homedir(), resolved.slice(2)) + : resolved; + if (/\s/.test(expanded) && !isAbsolute(expanded) && !quoted) { return undefined; } const abs = isAbsolute(expanded) ? expanded : resolve(cwd, expanded); return imageMimeTypeForPath(abs) === undefined ? undefined : abs; } -function unquoteShellPath(input: string): string | undefined { - if (input.startsWith("file://")) { +function resolveQuotedImagePath(inner: string): string | undefined { + if (inner.startsWith("file://")) { try { - return decodeURIComponent(new URL(input).pathname); + return decodeURIComponent(new URL(inner).pathname); } catch { return undefined; } } - if ( - (input.startsWith("'") && input.endsWith("'")) || - (input.startsWith('"') && input.endsWith('"')) - ) { - return input.slice(1, -1); + return inner; +} + +function resolveUnquotedImagePath(input: string): string | undefined { + const trimmed = trimTrailingPunctuation(input.trim()); + if (trimmed.startsWith("file://")) { + try { + return decodeURIComponent(new URL(trimmed).pathname); + } catch { + return undefined; + } } - return input.replace(/\\([\\\s'"()])/g, "$1"); + return trimmed.replace(/\\([\\\s'"()])/g, "$1"); } function trimTrailingPunctuation(input: string): string { diff --git a/src/tui/prompt-attachments.test.ts b/src/tui/prompt-attachments.test.ts index 10b62abd4..c395d0386 100644 --- a/src/tui/prompt-attachments.test.ts +++ b/src/tui/prompt-attachments.test.ts @@ -43,6 +43,20 @@ describe("ingestPathMentions", () => { expect(result.attachments).toHaveLength(1); }); + test("replaces the full wrapped token including quotes", async () => { + const load = async (path: string): Promise => ({ + ok: true, + attachment: { ...attachment("shot.png"), path }, + }); + const result = await ingestPathMentions( + "look at '/tmp/shot.png' please", + "/repo", + load, + ); + expect(result.text).toBe("look at [Attached image: shot.png] please"); + expect(result.attachments).toHaveLength(1); + }); + test("keeps the raw path when loading fails", async () => { const load = async (): Promise => ({ ok: false,