From b50262ce5bec0d0776ae581dd0a991212a325e85 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 10 Sep 2026 21:15:57 -0700 Subject: [PATCH 1/2] Fix root-path mentions minted from relative filenames in prose quotes --- src/tui/image-attachments.test.ts | 12 ++++++++++++ src/tui/image-attachments.ts | 22 +++++++++++++++++++--- 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/tui/image-attachments.test.ts b/src/tui/image-attachments.test.ts index 519e03264..45939d413 100644 --- a/src/tui/image-attachments.test.ts +++ b/src/tui/image-attachments.test.ts @@ -191,6 +191,18 @@ describe("findImagePathMentions", () => { findImagePathMentions(`He said "look at /tmp/shot.png" today`, "/repo"), ).toEqual([{ raw: "/tmp/shot.png", path: "/tmp/shot.png" }]); }); + + test("does not mint a root path from a relative filename inside prose quotes", () => { + expect( + findImagePathMentions('She wrote "notes/plan.png" in the doc', "/repo"), + ).toEqual([]); + expect( + findImagePathMentions('"see https://example.com/x.png"', "/repo"), + ).toEqual([]); + expect(findImagePathMentions(`'a'/b.png`, "/repo")).toEqual([ + { raw: "/b.png", path: "/b.png" }, + ]); + }); }); describe("image attachment helpers", () => { diff --git a/src/tui/image-attachments.ts b/src/tui/image-attachments.ts index 1ed38a731..9702980c0 100644 --- a/src/tui/image-attachments.ts +++ b/src/tui/image-attachments.ts @@ -81,7 +81,7 @@ export function findImagePathMentions( // 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); + scanImagePathLine(text, lineStart, contentEnd, cwd, push, false); if (lineEnd === text.length) break; lineStart = lineEnd + 1; } @@ -98,6 +98,7 @@ function scanImagePathLine( end: number, cwd: string, push: (raw: string, path: string | undefined) => void, + inQuotedProse: boolean, ): void { let i = start; while (i < end) { @@ -120,7 +121,7 @@ function scanImagePathLine( } // 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); + scanImagePathLine(text, i + 1, close, cwd, push, true); i = close + 1; continue; } @@ -129,7 +130,12 @@ function scanImagePathLine( continue; } - if (canStartUnquotedPath(text, i, end)) { + // Inside prose a candidate must open a new token, or the separator in + // `notes/plan.png` mints a root-level `/plan.png` mention. + if ( + canStartUnquotedPath(text, i, end) && + (!inQuotedProse || i === start || !isTokenContinuation(text[i - 1])) + ) { const match = UNQUOTED_AT.exec(text.slice(i, end)); if (match?.[0] !== undefined) { const raw = trimTrailingPunctuation(match[0]); @@ -151,6 +157,16 @@ function isWordChar(ch: string | undefined): boolean { ); } +// Characters that can glue a `/` (or another path start) onto the token +// before it: relative segments (`notes/plan.png`), URLs (`https://x.png`), +// and shell-style quote concatenation (`'a'/b.png`). +const TOKEN_CONTINUATION = "'\"`/\\:.~_-+@%"; + +function isTokenContinuation(ch: string | undefined): boolean { + if (ch === undefined) return false; + return isWordChar(ch) || TOKEN_CONTINUATION.includes(ch); +} + function looksLikeQuotedImagePath(inner: string): boolean { if (inner.startsWith("file://")) return true; if (inner === "~" || inner.startsWith("~/") || inner.startsWith("~\\")) From dbd461c99c7c0e9d76294a9615a84fd77e8af692 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Thu, 10 Sep 2026 21:46:53 -0700 Subject: [PATCH 2/2] Narrow path-token continuation check in quoted prose --- src/tui/image-attachments.ts | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/src/tui/image-attachments.ts b/src/tui/image-attachments.ts index 9702980c0..35dbaecc0 100644 --- a/src/tui/image-attachments.ts +++ b/src/tui/image-attachments.ts @@ -134,7 +134,7 @@ function scanImagePathLine( // `notes/plan.png` mints a root-level `/plan.png` mention. if ( canStartUnquotedPath(text, i, end) && - (!inQuotedProse || i === start || !isTokenContinuation(text[i - 1])) + (!inQuotedProse || i === start || !isPathTokenContinuation(text[i - 1])) ) { const match = UNQUOTED_AT.exec(text.slice(i, end)); if (match?.[0] !== undefined) { @@ -157,14 +157,26 @@ function isWordChar(ch: string | undefined): boolean { ); } -// Characters that can glue a `/` (or another path start) onto the token -// before it: relative segments (`notes/plan.png`), URLs (`https://x.png`), -// and shell-style quote concatenation (`'a'/b.png`). -const TOKEN_CONTINUATION = "'\"`/\\:.~_-+@%"; - -function isTokenContinuation(ch: string | undefined): boolean { +// True when `ch` continues the current token (word, `/\:.~`, or quote-glue) +// so a mid-token `/` inside prose must not open a root path. +function isPathTokenContinuation(ch: string | undefined): boolean { if (ch === undefined) return false; - return isWordChar(ch) || TOKEN_CONTINUATION.includes(ch); + if (isWordChar(ch)) return true; + switch (ch) { + case "/": + case "\\": + case ".": + case "~": + case ":": + case "_": + case "-": + case "'": + case '"': + case "`": + return true; + default: + return false; + } } function looksLikeQuotedImagePath(inner: string): boolean {