diff --git a/.changeset/parenthesised-spaced-conf-paths.md b/.changeset/parenthesised-spaced-conf-paths.md new file mode 100644 index 000000000..7214847e4 --- /dev/null +++ b/.changeset/parenthesised-spaced-conf-paths.md @@ -0,0 +1,5 @@ +--- +'aicodeman': patch +--- + +A parenthesised absolute file path whose name contains a space (`(/mnt/data/Kobo/Kobo eReader.conf)`, including one hard-wrapped between the words) is linked as that whole file. A relative path or a bare spaced filename inside parentheses is left to the ordinary detectors, which still need an explicit path label for a spaced relative path. `.conf` is a link extension, the same text the attachment routes already serve. The space the wrap consumed stays in the path when the parenthesis opens that path, and the clickable cells stay on the rows that hold the name. diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index d4484540f..eb218438d 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -409,9 +409,9 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli ⚠️ **The terminal routes an out-of-workspace path to the preview, not the log viewer.** The log viewer spawns `tail -f` and allows only the workspace, `/var/log` and `~/logs`, so an external `.log`/`.json`/code path answered `Path must be within working directory or allowed log directories` while the SAME path clicked in the response viewer previewed fine. `activate()` now checks `_isExternalPreviewPath` alongside `previewsInFileViewer`. In-workspace text keeps the tail viewer, which is the point of it (live follow), except markdown (`.md`/`.markdown`, in `FILE_PREVIEW_EXTENSIONS` since the owner's 2026-10-13 decision, rendered as a document) and `.html`/`.htm` (`rendersAsPage()`, rendered as a page, see below), which always go to the preview; nothing widened `file-stream-manager`'s allowlist, so no `tail -f` is spawned on an arbitrary host path. -**Path forms beyond the rooted one.** `findFilePathLinks(text)` (constants.js) is the single entry point both consumers call; it reserves spaced candidates first (including unlinkable spans), then detects rooted matches, then `~/` and relative paths (`RELATIVE_FILE_PATH_LINK_PATTERN`, same extension list, pinned equal by `test/link-provider-regex.test.ts`), then folder paths, each only where it overlaps neither an earlier match nor an `http(s)` URL. A relative match must start at a boundary (line start, whitespace, quote, bracket, `=`, `,`, `>`), so it can never begin inside a word, an absolute path or a URL; brackets, `=` and `,` end it (`(shot.png)`, `OUT=shots/a.png`). `resolveLinkedFilePath()` resolves a relative path against the session's `workingDir` (`.`/`..` collapsed, so `../x.png` lands outside and routes like any external path) and passes absolute and `~/` paths through. ⚠️ `~` is the home of the SESSION'S host, which only the server knows: `~/` paths always take the attachment route, where `registerExternalAttachment` expands them (`homedir()` locally, `$HOME` inside the remote probe script), and the guard runs on the expanded, realpath-resolved path as before. ⚠️ Programs hard-wrap their own output short of the terminal edge (Claude Code breaks tool output at 59-118 columns), so xterm's `isWrapped` never fires. `terminalPathLine()` (link provider only; touch selection keeps `terminalLogicalLine`) joins the next row when the previous row ends in a path token with a `/` and no extension and the join reaches an extension; a row that starts a new path is never joined: rooted or `~/`, `./`/`../`, or a relative one that opens on the same top directory the broken token did (the next entry of `git ls-files`/`find`/`ls -R`: `src/components` + `src/index.ts` are two paths, not `src/componentssrc/index.ts`), while a wrapped path continues INSIDE a name, so `…/captures/2` + `026-10-10/…` still joins; a 3+ row chain is kept only if a later row completes the path, and the broken row must be at least as long as the continuing one (keeps `cd /mnt/foo` + a longer next line apart). Tests: `test/file-path-link-forms.test.ts`. +**Path forms beyond the rooted one.** `findFilePathLinks(text)` (constants.js) is the single entry point both consumers call; it reserves spaced candidates first (including unlinkable spans), then detects rooted matches, then `~/` and relative paths (`RELATIVE_FILE_PATH_LINK_PATTERN`, same extension list, pinned equal by `test/link-provider-regex.test.ts`), then folder paths, each only where it overlaps neither an earlier match nor an `http(s)` URL. A relative match must start at a boundary (line start, whitespace, quote, bracket, `=`, `,`, `>`), so it can never begin inside a word, an absolute path or a URL; brackets, `=` and `,` end it (`(shot.png)`, `OUT=shots/a.png`). `resolveLinkedFilePath()` resolves a relative path against the session's `workingDir` (`.`/`..` collapsed, so `../x.png` lands outside and routes like any external path) and passes absolute and `~/` paths through. ⚠️ `~` is the home of the SESSION'S host, which only the server knows: `~/` paths always take the attachment route, where `registerExternalAttachment` expands them (`homedir()` locally, `$HOME` inside the remote probe script), and the guard runs on the expanded, realpath-resolved path as before. ⚠️ Programs hard-wrap their own output short of the terminal edge (Claude Code breaks tool output at 59-118 columns), so xterm's `isWrapped` never fires. `terminalPathLine()` (link provider only; touch selection keeps `terminalLogicalLine`) joins the next row when the previous row ends in a path token with a `/` and no extension and the join reaches an extension; a row that starts a new path is never joined: rooted or `~/`, `./`/`../`, or a relative one that opens on the same top directory the broken token did (the next entry of `git ls-files`/`find`/`ls -R`: `src/components` + `src/index.ts` are two paths, not `src/componentssrc/index.ts`), while a wrapped path continues INSIDE a name, so `…/captures/2` + `026-10-10/…` still joins; a `'join-space'` keeps the space between filename words when the parenthesis that opens the broken-off path is still unclosed (`Kobo` + `eReader.conf` stays `Kobo eReader.conf`, the start cell on the first row and the end cell on the last; an earlier `(` such as `Bash(` does not, and a token containing `://` never keeps the space) while a column cut still glues with no space; a 3+ row chain is kept only if a later row completes the path, and the broken row must be at least as long as the continuing one (keeps `cd /mnt/foo` + a longer next line apart). Tests: `test/file-path-link-forms.test.ts`. A parenthesised path cut mid-letter between two letters is ambiguous with the Kobo wrap and keeps the space, a known trade-off. -Spaced paths are considered first by `findFilePathLinks()`: accepted candidates reserve their entire span even when unlinkable, so later detectors cannot link fragments. Relative and `~/` spaced paths require an explicit path label (`Path:`, `File:`, `Files:`, `Saved to:`, `Saved as:`, `Wrote:`, `Written to:`, `Output:`, `Created:`, `Location:`, `Attachment:`), optionally preceded by a marker. Generic labels and bare bullets defer to ordinary detection, including slash-containing prose such as `CI/CD` or `45/45`. A later `/`, `~/`, `./` or `../` word starts a separate path; an earlier word ending in `/` is a separate folder. Absolute paths keep the own-line and marker forms plus the same path labels; any other label (`Error:`, `Note:`, `Saved the report to:`) defers an absolute path too. A rooted candidate needs a directory before its first space, so `/review the changes in src/a.ts` is prose. An earlier word holding a known extension anywhere (`a.ts#L12 foo.ts`) ends the path at that word, a `:` followed by a digit inside an earlier word is a line suffix (`a.ts:42:See b.md`, grep and compiler output) and ends the path there, a run of spaces is a column gap rather than a name, and a leading run of `./` or `../` is not a segment (`Saved to: ../My Docs/plan.md` links whole; an interior `..` is still refused). An unlinkable candidate reserves its span only while its last word holds a `/`, so `/usr/bin/env node build.js` keeps its ordinary links while `/etc/secret files/passwords.txt` links nothing. A path label vouches that a path follows, not for the words before it: a relative candidate whose only `/` word is its last is prose after the label (`Wrote: 120 lines to src/foo.ts`, `Path: see src/foo.ts`) and defers to the ordinary detectors. The cost is a labelled relative path whose only space is in its top directory, `Saved to: My Docs/plan.md` (and `Saved to: 03 Attachments/plan.pptx`), which splits as on master; `03 Attachments/Build SIPOC Design/Build-SIPOC.pptx`, `notes/a b/c.md`, `~/`, `./` and `../` forms and absolute paths still link whole. An earlier word that is a lowercase connective or verb (`SPACED_PATH_PROSE_WORD`: `and`, `or`, `to`, `in`, `the`, `is`, `has`, `contains`, `then`, `as` and a few more) or a bare `=` makes the candidate a sentence, in every shape: `/home/me/proj contains a.md`, `- /tmp/out has the logs in run.log`, `Path: /home/me/proj/out contains a.md`, `Files: src/components and utils/x.ts`, `Wrote: 3 files to docs/design notes/overview.md`. The cost is a name that uses one of those words (`Pros and Cons.md`, `How to deploy.md`, `Documents and Settings`), which splits as on master; `at` (`Screenshot 2026-10-24 at 10.00.00.png`) and `of` (`Copy of report.docx`) are left out of the list on purpose. What remains ambiguous is an own-line absolute path followed by words that hold none of them (`/home/me/proj/out report.pdf` reads like `/home/me/docs/Read Me.txt`), which links as one path. A command line is refused too: a first word that is an executable under `bin/`, `.bin/`, `scripts/` or `tools/` (`/opt/homebrew/bin/node server.js`, `/home/me/proj/scripts/deploy prod.yml`) or any `-x`/`--x` flag word (`/home/me/proj/node_modules/.bin/tsc -p tsconfig.json`); a bare `-` (`Docs - Copy`, `OneDrive - Contoso`) is not a flag. So is a shell prompt that prints an absolute working directory (WSL's `/mnt/c/...` is outside `$HOME`, so every earlier command in a shell's scrollback has one): a first word ending in `$` (`/home/me/proj$ cat notes.md`) and any earlier word with no letter or digit other than a bare `-` (`/mnt/c/proj ❯ code notes.md`, `/home/me/proj $ cat notes.md`, `/home/me/proj/out ✓ report.pdf`) refuse the candidate, while `Program Files (x86)` and `Docs - Copy` still link whole. The response viewer carries any leading `` label in a paragraph or list item, or one starting a later line of a paragraph (after a `
`, since chat renders with `breaks: true`), into detection without changing the label or displayed text, and `spacedFilePathOnLine()` judges it as it judges the same line in the terminal (`**Note:** /home/me/proj/out report.pdf` splits). A text node that follows any other inline element starts mid-line, so a placeholder word stands in for what precedes it (``Use `--config` /home/me/proj/cfg data.json`` splits as in the terminal), while a path inside inline code still links whole; paths split across inline elements remain detected per text node. Tests feed vendored marked output into the jsdom linkifier, so consumed bullets and bold labels are covered. Trailing whitespace uses linear `trimEnd()` before the length cap. +Spaced paths are considered first by `findFilePathLinks()`: accepted candidates reserve their entire span even when unlinkable, so later detectors cannot link fragments. Relative and `~/` spaced paths require an explicit path label (`Path:`, `File:`, `Files:`, `Saved to:`, `Saved as:`, `Wrote:`, `Written to:`, `Output:`, `Created:`, `Location:`, `Attachment:`), optionally preceded by a marker. Generic labels and bare bullets defer to ordinary detection, including slash-containing prose such as `CI/CD` or `45/45`. A later `/`, `~/`, `./` or `../` word starts a separate path; an earlier word ending in `/` is a separate folder. Absolute paths keep the own-line and marker forms plus the same path labels; any other label (`Error:`, `Note:`, `Saved the report to:`) defers an absolute path too. A rooted candidate needs a directory before its first space, so `/review the changes in src/a.ts` is prose. An earlier word holding a known extension anywhere (`a.ts#L12 foo.ts`) ends the path at that word, a `:` followed by a digit inside an earlier word is a line suffix (`a.ts:42:See b.md`, grep and compiler output) and ends the path there, a run of spaces is a column gap rather than a name, and a leading run of `./` or `../` is not a segment (`Saved to: ../My Docs/plan.md` links whole; an interior `..` is still refused). An unlinkable candidate reserves its span only while its last word holds a `/`, so `/usr/bin/env node build.js` keeps its ordinary links while `/etc/secret files/passwords.txt` links nothing. A path label vouches that a path follows, not for the words before it: a relative candidate whose only `/` word is its last is prose after the label (`Wrote: 120 lines to src/foo.ts`, `Path: see src/foo.ts`) and defers to the ordinary detectors. The cost is a labelled relative path whose only space is in its top directory, `Saved to: My Docs/plan.md` (and `Saved to: 03 Attachments/plan.pptx`), which splits as on master; `03 Attachments/Build SIPOC Design/Build-SIPOC.pptx`, `notes/a b/c.md`, `~/`, `./` and `../` forms and absolute paths still link whole. An earlier word that is a lowercase connective or verb (`SPACED_PATH_PROSE_WORD`: `and`, `or`, `to`, `in`, `the`, `is`, `has`, `contains`, `then`, `as` and a few more) or a bare `=` makes the candidate a sentence, in every shape: `/home/me/proj contains a.md`, `- /tmp/out has the logs in run.log`, `Path: /home/me/proj/out contains a.md`, `Files: src/components and utils/x.ts`, `Wrote: 3 files to docs/design notes/overview.md`. The cost is a name that uses one of those words (`Pros and Cons.md`, `How to deploy.md`, `Documents and Settings`), which splits as on master; `at` (`Screenshot 2026-10-24 at 10.00.00.png`) and `of` (`Copy of report.docx`) are left out of the list on purpose. What remains ambiguous is an own-line absolute path followed by words that hold none of them (`/home/me/proj/out report.pdf` reads like `/home/me/docs/Read Me.txt`), which links as one path. A command line is refused too: a first word that is an executable under `bin/`, `.bin/`, `scripts/` or `tools/` (`/opt/homebrew/bin/node server.js`, `/home/me/proj/scripts/deploy prod.yml`) or any `-x`/`--x` flag word (`/home/me/proj/node_modules/.bin/tsc -p tsconfig.json`); a bare `-` (`Docs - Copy`, `OneDrive - Contoso`) is not a flag. So is a shell prompt that prints an absolute working directory (WSL's `/mnt/c/...` is outside `$HOME`, so every earlier command in a shell's scrollback has one): a first word ending in `$` (`/home/me/proj$ cat notes.md`) and any earlier word with no letter or digit other than a bare `-` (`/mnt/c/proj ❯ code notes.md`, `/home/me/proj $ cat notes.md`, `/home/me/proj/out ✓ report.pdf`) refuse the candidate, while `Program Files (x86)` and `Docs - Copy` still link whole. Parentheses bound a spaced absolute path even with prose in front (`(/mnt/data/Kobo/Kobo eReader.conf)`), judged by the same rules as an own-line path, and that span is reserved so the folder prefix is not linked instead (#733). `.conf` is a link extension, matching text the attachment routes already serve. The response viewer carries any leading `` label in a paragraph or list item, or one starting a later line of a paragraph (after a `
`, since chat renders with `breaks: true`), into detection without changing the label or displayed text, and `spacedFilePathOnLine()` judges it as it judges the same line in the terminal (`**Note:** /home/me/proj/out report.pdf` splits). A text node that follows any other inline element starts mid-line, so a placeholder word stands in for what precedes it (``Use `--config` /home/me/proj/cfg data.json`` splits as in the terminal), while a path inside inline code still links whole; paths split across inline elements remain detected per text node. Tests feed vendored marked output into the jsdom linkifier, so consumed bullets and bold labels are covered. Trailing whitespace uses linear `trimEnd()` before the length cap. The terminal provider scans `terminalLinkLine()`, which extends `terminalPathLine()` for URL continuations in indented paragraphs and table cells. List markers, bare numbers and version tokens never continue a URL. A token with only hyphens, underscores or dots must end its cell (row end or a gap of at least two spaces), so prose such as `self-hosted runners` is not appended. A row end counts as a cell end, so `⏺ Docs live at https://example.com/guide/` followed by ` self-hosted` still links `.../guide/self-hosted`, and an indented `and/or the API reference covers it.` still links `.../guide/and/or`; both are pinned as known trade-offs. Such a token is still refused when it is code with a call paren (`client.get()`, `expect(x).toBe(y)`; a wiki title like `Mercury_(planet)` keeps its paren) or a word ending a sentence (`ready.`, `Done.`, `self-hosted.`; a file name keeps its period, so `install.html.` still joins). Whatever its punctuation, a token never continues a URL when it ends with a colon (a label such as `Failure/Error:` or `prometheus.io/scrape:`), when it is a measurement or clock time (`45%`, `45%|####`, `12.3MB/45.0MB`, `12.3ms`, `10:00:00`; percent-encoding such as `2026%20Plan` still joins), or when it is a `name=value` field (`API_KEY=...`, `level=info`) under a URL ending in `/`, since a query needs a `?` first (after `?`, `&` or `_` it is a query parameter and joins). A continuation must be indented (a column-0 row is new output, which keeps `status=200`, `24/7` and `really?` out), and a shell prompt (`user@host:`), a home path (`~/`), a whole email address or an HTTP status line never continues a URL. A URL in the last column of a space-aligned table never takes the next row's first cell: a continuation row must start inside the URL's own cell, so a gap of two or more spaces between its indent and where the URL starts on the row above means it is the next record. When other columns follow a wrapped URL on its first row (a codex table's path or date column), the URL is stitched without them, and they still link on that row: `terminalLinkLine()` scans them after the stitched rows as a segment of their own, mapped back to their cells. A URL in such a column is the row's last URL, and only the last URL on a row is continued, so that row is never stitched. A divider row never continues the row above it, so a URL that reaches the right edge does not absorb a full-width `────` row below. A real plain slug followed by prose falls back to the original fragment link; `%`, `&`, `=`, `/` or `?` still qualify as stronger URL punctuation. `terminalCellWrappedPaths()` follows each table cell independently: a fragment carries on into the next row only when it ends its cell on `/` or `-` (where a wrapper breaks a path; codex's cell wrap never breaks at `_`), the continuation row is blank to the left of the cell (a wrapper wraps every column of a table row together, so a label there such as `Entry` or `│ Log │` is the next record, unless a left column's path continued on that row too; a left column of wrapped prose, as in a `Description | Path` table, fills that row as well, so the path beside it keeps its per-row fragment links, the safe direction), the next row's token passes `pathContinuation()`, and the cell is no wider than the one above. So a directory that is a whole cell value (`/home/u/proj` over `config.yaml`, or `/home/u/proj/app/` over a labelled `main.py`) keeps its folder link and the next row keeps its own file link. With no label to tell them apart, a directory written with its trailing slash over a relative path in the same column (`/home/u/proj/src/` over `lib/util.ts`) still joins, a known trade-off: it reads exactly like a wrap at `/`. Accepted paths displace other links on their cells. Full-width divider rows stop `terminalLogicalLine()` stitching for both links and touch selection. OSC 8 links confirm the real target URI before the shared opener runs. diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 742ac363a..0e81ad3a8 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -2106,7 +2106,7 @@ function computeRewriteScrollLine(input) { * a `/g` regex, so {@link absoluteFilePathPattern} mints a fresh one per call. */ const FILE_PATH_LINK_PATTERN = - /(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|tsv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|sql|diff|patch|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|zip|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g; + /(\/(?:home|Users|tmp|var|private|opt|mnt|srv|media|data|workspace)\/[^\s"'<>|;&\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|tsv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|conf|sql|diff|patch|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|zip|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g; /** A fresh, zero-state instance of {@link FILE_PATH_LINK_PATTERN}. */ function absoluteFilePathPattern() { @@ -2159,7 +2159,7 @@ function previewsInFileViewer(filePath) { * {@link findFilePathLinks}. */ const RELATIVE_FILE_PATH_LINK_PATTERN = - /(^|[\s"'`(\[{<>=,])((?![^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*:\/\/)(?:~\/|[^\s"'`<>|;&()[\]{}=,\/~\n\x00-\x1f])[^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|tsv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|sql|diff|patch|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|zip|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g; + /(^|[\s"'`(\[{<>=,])((?![^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*:\/\/)(?:~\/|[^\s"'`<>|;&()[\]{}=,\/~\n\x00-\x1f])[^\s"'`<>|;&()[\]{}=,\n\x00-\x1f]*\.(?:log|txt|json|markdown|md|ya?ml|csv|tsv|xml|sh|py|tsx|ts|jsx|js|mjs|cjs|css|html?|toml|ini|conf|sql|diff|patch|png|jpe?g|gif|webp|avif|bmp|ico|svg|pdf|docx|pptx|xlsx|zip|mp4|webm|mov|m4v|ogv|mp3|wav))\b/g; /** The `(?:log|txt|…)` extension group, read off the rooted pattern so it cannot drift. */ const FILE_LINK_EXTENSION_GROUP = /\\\.(\(\?:[^()]*\))\)\\b$/.exec(FILE_PATH_LINK_PATTERN.source)[1]; @@ -2197,12 +2197,18 @@ function findFilePathLinks(text) { const reserved = []; let lineStart = 0; for (const line of value.split('\n')) { - const spaced = spacedFilePathOnLine(line); - if (spaced && !inUrl(lineStart + spaced.index, spaced.path.length)) { - const index = lineStart + spaced.index; - reserved.push({ index, path: spaced.path }); - if (spaced.link) found.push({ path: spaced.path, index }); - } + const consider = (hit) => { + if (!hit || inUrl(lineStart + hit.index, hit.path.length)) return; + const index = lineStart + hit.index; + if (reserved.some((r) => index < r.index + r.path.length && r.index < index + hit.path.length)) return; + reserved.push({ index, path: hit.path }); + if (hit.link) found.push({ path: hit.path, index }); + }; + consider(spacedFilePathOnLine(line)); + // A parenthesised path is bounded by the parentheses, not by the line: + // `(/mnt/data/Kobo/Kobo eReader.conf)` is not an own-line path, and without + // this the folder detector links `/mnt/data/Kobo/Kobo`. + for (const hit of parenthesisedSpacedPaths(line)) consider(hit); lineStart += line.length + 1; } const isReserved = (index, length) => @@ -2246,6 +2252,7 @@ const SPACED_FILE_PATH_LABEL_PATTERN = * /mnt/c/My Docs/report.docx the whole line is the path * - /mnt/c/My Docs/report.docx after a list marker (`-`, `*`, `•`, `1.`, `2)`) * Saved to: 03 Attachments/Build SIPOC Design/plan.pptx after a path label (SPACED_FILE_PATH_LABEL_PATTERN) + * (/mnt/data/Kobo/Kobo eReader.conf) inside parentheses, prose may precede them * * An absolute path may stand alone or follow a marker. Any label, before an * absolute, relative or `~/` path, must be an explicit path label @@ -2335,6 +2342,60 @@ function spacedFilePathOnLine(line) { return { path, index, link }; } +/** + * Spaced paths inside one pair of parentheses on `line`. + * + * The own-line detector never sees `(/mnt/data/Kobo/Kobo eReader.conf)`: prose + * precedes the path, and the closing paren means the line does not end on an + * extension, so the folder detector linked the directory prefix. The inside of + * each pair is judged on its own ({@link spacedPathBoundedByParens}). Inner + * pairs are reported first, and the caller drops a later hit that overlaps one. + * + * @param {string} line + * @returns {Array<{path: string, index: number, link: boolean}>} + */ +function parenthesisedSpacedPaths(line) { + const text = String(line || ''); + const hits = []; + const stack = []; + for (let i = 0; i < text.length; i++) { + const ch = text[i]; + if (ch === '(') stack.push(i); + else if (ch === ')' && stack.length) { + const open = stack.pop(); + const inner = text.slice(open + 1, i); + const hit = spacedPathBoundedByParens(inner); + if (hit) hits.push({ path: hit.path, index: open + 1 + hit.index, link: hit.link }); + } + } + return hits; +} + +/** + * A spaced absolute path that fills the inside of one parenthesis pair, or null. + * + * The inside is judged as its own line ({@link spacedFilePathOnLine}), so only an + * absolute path qualifies. A relative or `~/` path still needs an explicit path + * label, and a bare `word word file.ext` is prose the ordinary detectors keep. + * `link: false` reserves an unlinkable span (`/etc/…`) the same way the own-line + * detector does. + * + * @param {string} inner Text between the parentheses. + * @returns {{path: string, index: number, link: boolean} | null} + * `index` is where `path` starts in `inner`. + */ +function spacedPathBoundedByParens(inner) { + const raw = String(inner || ''); + const lead = raw.length - raw.trimStart().length; + const trimmed = raw.trim(); + // Length before the space scan: each nesting level calls this, and a long + // parenthesis nest with no spaces must not be scanned once per level. + if (trimmed.length > 1000 || !trimmed.includes(' ')) return null; + const direct = spacedFilePathOnLine(trimmed); + if (!direct) return null; + return { path: direct.path, index: lead + direct.index, link: direct.link }; +} + /** * Folder paths, for the folder grid: rooted like {@link FILE_PATH_LINK_PATTERN} * (`/mnt/raid1/neon_getaway/styleframes`), `~/renders`, or relative with a @@ -2548,7 +2609,10 @@ const pathFirstSegment = (path) => path.replace(/^(?:\.?\/)+/, '').split('/')[0] * Whether the line `nextText` carries on a file path `prevText` broke off. * * Answers `'join'` when the path ends (reaches a known extension) on the next - * line, `'chain'` when the next line is nothing but more of the path (a path + * line, `'join-space'` for that same join when the break is between filename + * words inside the parenthesis that opens the broken-off path (the separating + * space the wrap consumed is kept; an earlier `(` and a `://` token do not), + * `'chain'` when the next line is nothing but more of the path (a path * spanning three or more lines; only worth keeping once a later line ends it), * and `null` otherwise. The broken-off token must already look like a path in * progress: it contains a `/`, ends on a path character and does not yet end @@ -2566,10 +2630,49 @@ function pathContinuation(prevText, nextText) { // followed by `src/index.ts` is two entries, not one path broken after `src/components`. // A wrapped path continues INSIDE a name: its next row does not open on the same top directory. if (head.includes('/') && pathFirstSegment(head) === pathFirstSegment(tail)) return null; - if (PATH_TOKEN_HAS_EXTENSION.test(head)) return 'join'; + if (PATH_TOKEN_HAS_EXTENSION.test(head)) return continuationKeepsWordSpace(prevText, head) ? 'join-space' : 'join'; return head === next.replace(/\s+$/, '') ? 'chain' : null; } +/** + * Whether a hard wrap between `prevText` and `head` fell on a space inside the + * parenthesis that opens the broken-off path (`(/mnt/…/Kobo` + `eReader.conf`), + * so the join must keep that space. + * + * Only that path's own `(` counts: the broken-off token starts with it, or + * whitespace alone separates them. An earlier `(` (`Bash(cp /home/…`) does not. + * A token containing `://` never keeps a space. A column cut (`captures/2` + + * `026-10-10/…`) is not one: its head continues the token (a slash, or a + * non-letter). A parenthesised path cut mid-letter between two letters is + * ambiguous with the Kobo wrap and keeps the space. + * + * @param {string} prevText + * @param {string} head The next line's first path token. + * @returns {boolean} + */ +function continuationKeepsWordSpace(prevText, head) { + const prev = String(prevText || '').replace(/\s+$/, ''); + const headText = String(head || ''); + const tail = /[^\s"'<>|;&]+$/.exec(prev)?.[0] || ''; + if (!tail || tail.includes('://') || headText.includes('://')) return false; + const before = prev.slice(0, prev.length - tail.length); + // The `(` that opens this path, not `Bash(` or any earlier one in the stitch. + let openAt = -1; + if (tail.startsWith('(')) openAt = prev.length - tail.length; + else if (/\(\s*$/.test(before)) openAt = before.lastIndexOf('('); + if (openAt < 0) return false; + let depth = 0; + for (let i = openAt; i < prev.length; i++) { + const ch = prev[i]; + if (ch === '(') depth++; + else if (ch === ')') depth = Math.max(0, depth - 1); + } + if (depth === 0) return false; + const bare = headText.replace(/[)\].,;:!?]+$/, ''); + if (!bare || bare.includes('/') || !/^[A-Za-z]/.test(bare)) return false; + return /[A-Za-z0-9]$/.test(tail); +} + /** * {@link terminalLogicalLine}, extended across the line breaks a program puts * INSIDE a file path. Used by the link provider only; touch selection keeps the @@ -2585,8 +2688,10 @@ function pathContinuation(prevText, nextText) { * * A following logical line is glued on (its indent dropped, cell mapping kept * exact) when {@link pathContinuation} says it carries the path on and the row it - * broke off is at least as long as the row it continues on. Bounded by the same - * `maxRows` as the logical line. + * broke off is at least as long as the row it continues on. A `'join-space'` + * keeps one space between the filename words, so `Kobo` + `eReader.conf` stays + * `Kobo eReader.conf` and the cells after that space still map to the + * continuation row. Bounded by the same `maxRows` as the logical line. * * @returns Same shape as {@link terminalLogicalLine}; null when the row does not exist. */ @@ -2607,6 +2712,7 @@ function terminalPathLine(buffer, row, cols, maxRows) { /** `first` and the lines a path carries on into, stopping at the last confirmed join. */ const extend = (first) => { const parts = [first]; + const wordSpace = [false]; let text = first.text; let rows = rowsOf(first); let confirmed = 1; @@ -2616,11 +2722,15 @@ function terminalPathLine(buffer, row, cols, maxRows) { const kind = continues(parts[parts.length - 1], next, text); if (!kind) break; parts.push(next); + // The continuation check keeps the rows glued with no space, so a later + // row still sees the `/` in the tail. The space is inserted only when the + // stitched text is built, which is what findFilePathLinks reads. + wordSpace.push(kind === 'join-space'); text += next.text.replace(/^\s+/, ''); rows += rowsOf(next); - if (kind === 'join') confirmed = parts.length; + if (kind === 'join' || kind === 'join-space') confirmed = parts.length; } - return parts.slice(0, confirmed); + return { parts: parts.slice(0, confirmed), wordSpace: wordSpace.slice(0, confirmed) }; }; // The hovered row may be the second or third line of a broken path: walk back @@ -2633,14 +2743,16 @@ function terminalPathLine(buffer, row, cols, maxRows) { first = prev; rows += rowsOf(prev); } - let parts = extend(first); - if (parts[parts.length - 1].endRow < base.startRow) parts = extend(base); + let extended = extend(first); + if (extended.parts[extended.parts.length - 1].endRow < base.startRow) extended = extend(base); + const { parts, wordSpace } = extended; if (parts.length === 1) return parts[0]; const segments = []; let text = ''; for (const [i, part] of parts.entries()) { const skip = i === 0 ? 0 : part.text.length - part.text.replace(/^\s+/, '').length; + if (wordSpace[i]) text += ' '; segments.push({ part, textStart: text.length, skip }); text += part.text.slice(skip); } diff --git a/test/file-path-link-forms.test.ts b/test/file-path-link-forms.test.ts index e601bc9ec..9b2e8beac 100644 --- a/test/file-path-link-forms.test.ts +++ b/test/file-path-link-forms.test.ts @@ -201,6 +201,49 @@ describe('terminalPathLine', () => { expect(paths(line.text)).toEqual([BROKEN_PATH]); }); }); + + // A parenthesised filename word-wrapped between its words (#733). The space + // the wrap consumed has to come back, and the clickable cells stay on the + // characters that are actually on each row. + it('joins a tool-call path and a URL cut mid-word without inserting a space', () => { + // An earlier `(` (`Bash(`) and a `://` token keep master's join: no space. + const bash = ['Bash(cp /home/me/proj/builds/captures/2026-10-10/ghost1b_gamepl', ' ay.png /tmp/)']; + const bashLine = terminalPathLine(buffer(bash), 0, 80)!; + expect(bashLine.text).toBe('Bash(cp /home/me/proj/builds/captures/2026-10-10/ghost1b_gameplay.png /tmp/)'); + expect(paths(bashLine.text)).toEqual(['/home/me/proj/builds/captures/2026-10-10/ghost1b_gameplay.png']); + + const url = ['Details (see https://example.com/reports/2026/ghost1b_game', ' play.png).']; + const urlLine = terminalPathLine(buffer(url), 0, 80)!; + expect(urlLine.text).toBe('Details (see https://example.com/reports/2026/ghost1b_gameplay.png).'); + expect(paths(urlLine.text)).toEqual([]); + }); + + // The path's own `(` plus a letter/letter cut is the same shape as `Kobo` + + // `eReader.conf`. The wrap wins: the space stays, and the link is that name. + it('keeps the space when a parenthesised path is cut mid-letter, the Kobo-wrap trade-off', () => { + const rows = ['See (/mnt/data/captures/2026-10-10/ghost1b_gamepl', ' ay.png).']; + const spaced = '/mnt/data/captures/2026-10-10/ghost1b_gamepl ay.png'; + const line = terminalPathLine(buffer(rows), 0, 80)!; + expect(line.text).toBe(`See (${spaced}).`); + expect(paths(line.text)).toEqual([spaced]); + }); + + it('keeps the space in a hard-wrapped parenthesised filename and maps both rows', () => { + const rows = ['Prepared a corrected configuration (/mnt/data/Kobo/Kobo', ' eReader.conf).']; + const path = '/mnt/data/Kobo/Kobo eReader.conf'; + for (const row of [0, 1]) { + const line = terminalPathLine(buffer(rows), row, 80)!; + expect([line.startRow, line.endRow]).toEqual([0, 1]); + expect(line.text).toBe(`Prepared a corrected configuration (${path}).`); + const [found] = findFilePathLinks(line.text); + expect(found).toEqual({ path, index: line.text.indexOf(path) }); + expect(line.offsetToCell(found.index)).toEqual({ row: 0, col: rows[0].indexOf('/') }); + expect(line.offsetToCell(found.index + found.path.length - 1)).toEqual({ + row: 1, + col: rows[1].lastIndexOf('f'), + }); + } + }); }); type Link = { @@ -326,6 +369,22 @@ describe('terminal link provider', () => { const { links } = linksOn(['see https://x.io/view?img=shots/a.png now'], 1); expect(links.map((l) => l.text)).toEqual(['https://x.io/view?img=shots/a.png']); }); + + it('opens a hard-wrapped spaced .conf path from either row, on the cells that hold it', () => { + const rows = ['Prepared a corrected configuration (/mnt/data/Kobo/Kobo', ' eReader.conf).']; + const path = '/mnt/data/Kobo/Kobo eReader.conf'; + const endCol = rows[1].lastIndexOf('f'); + for (const row of [1, 2]) { + const { app, links } = linksOn(rows, row); + expect(links.map((l) => l.text)).toEqual([path]); + expect(links[0].range.start).toEqual({ x: rows[0].indexOf('/') + 1, y: 1 }); + // The provider's end cell is the one after the last character of the path. + expect(links[0].range.end).toEqual({ x: endCol + 2, y: 2 }); + links[0].activate({}, links[0].text); + expect(app.openLogViewerWindow).toHaveBeenCalledWith(path, 's1'); + expect(app.openFilePreview).not.toHaveBeenCalled(); + } + }); }); describe('server: ~/ expands on the session host', () => { diff --git a/test/response-viewer-file-links.test.ts b/test/response-viewer-file-links.test.ts index 0eff0b357..b1c0b2985 100644 --- a/test/response-viewer-file-links.test.ts +++ b/test/response-viewer-file-links.test.ts @@ -318,6 +318,18 @@ describe('response viewer file-path linkifier', () => { } }); + it('links a parenthesised .conf path with spaces as one file, not a folder prefix (#733)', () => { + const path = '/mnt/data/Kobo/Kobo eReader.conf'; + const root = linkify(`

Prepared a corrected configuration (${path}).

`); + const links = paths(root); + + expect(links).toHaveLength(1); + expect(links[0].getAttribute('data-path')).toBe(path); + expect(links[0].hasAttribute('data-folder')).toBe(false); + expect(links[0].textContent).toBe(path); + expect(root.textContent).toBe(`Prepared a corrected configuration (${path}).`); + }); + it('is wired into message rendering and the click delegate', () => { // The linkifier is only reachable through these two call sites; losing // either leaves inert paths (no linkify) or dead links (no handler). diff --git a/test/spaced-file-path-links.test.ts b/test/spaced-file-path-links.test.ts index ead0aaf94..8add878c5 100644 --- a/test/spaced-file-path-links.test.ts +++ b/test/spaced-file-path-links.test.ts @@ -427,3 +427,48 @@ describe('terminal link provider with spaced paths', () => { expect(linksFor('/etc/secret files/passwords.txt').links).toEqual([]); }); }); + +describe('parenthesised paths with spaces (#733)', () => { + const KOBO = '/mnt/data/Kobo/Kobo eReader.conf'; + + it('links a parenthesised absolute path whole, not as a folder prefix plus a tail', () => { + const line = `Prepared a corrected configuration (${KOBO}).`; + expect(findFilePathLinks(line)).toEqual([{ path: KOBO, index: line.indexOf(KOBO) }]); + expect(line.slice(line.indexOf(KOBO), line.indexOf(KOBO) + KOBO.length)).toBe(KOBO); + }); + + it.each([ + ['(node server.js)', 'server.js'], + ['(updated package.json)', 'package.json'], + ['(e.g. eslint.config.js)', 'eslint.config.js'], + ['(default: settings.json)', 'settings.json'], + ['(cat notes.md)', 'notes.md'], + ['(CI/CD pipeline config.yml)', 'config.yml'], + ])('links the file only inside %s, not the surrounding prose', (line, file) => { + expect(paths(line)).toEqual([file]); + expect(paths(line)).not.toContain(line.slice(1, -1)); + }); + + it('does not take a relative or bare spaced name inside parentheses as one path', () => { + // Those still need an explicit path label. The ordinary detectors link the file. + expect(paths('wrote (docs/Kobo eReader.conf) today')).toEqual(['eReader.conf']); + expect(paths('edited (Kobo eReader.conf) in place')).toEqual(['eReader.conf']); + expect(paths('kept (~/Kobo/Kobo eReader.conf) aside')).toEqual(['~/Kobo/Kobo', 'eReader.conf']); + }); + + it('links .conf the way other text extensions are linked', () => { + expect(paths('see /mnt/data/Kobo/reader.conf please')).toEqual(['/mnt/data/Kobo/reader.conf']); + expect(paths('see config/reader.conf')).toEqual(['config/reader.conf']); + }); + + it('does not take prose or an /etc path inside parentheses', () => { + expect(paths('(see the notes.conf)')).not.toContain('see the notes.conf'); + expect(paths('(/etc/secret files/passwords.txt)')).toEqual([]); + }); + + it('still links Program Files (x86) as one path', () => { + const line = '/mnt/c/Program Files (x86)/app/readme.txt'; + expect(paths(line)).toEqual([line]); + expect(paths(`(${line})`)).toEqual([line]); + }); +});