Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 116 additions & 12 deletions src/tui/image-attachments.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -72,29 +72,133 @@ 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([
{
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 () => {
Expand Down
170 changes: 143 additions & 27 deletions src/tui/image-attachments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,19 +67,131 @@ export function findImagePathMentions(
): ImagePathMention[] {
const mentions: ImagePathMention[] = [];
const seen = new Set<string>();
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<AttachImageResult> {
Expand Down Expand Up @@ -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 {
Expand Down
14 changes: 14 additions & 0 deletions src/tui/prompt-attachments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AttachImageResult> => ({
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<AttachImageResult> => ({
ok: false,
Expand Down
Loading