fix(terminal): end spaced paths at line suffixes and generic labels

A spaced path is no longer taken across a `path:line` suffix (grep -n, rg -n
and compiler output) and an absolute path after a generic label such as
`Error:` or `Note:` goes back to the ordinary detectors: any label must now
be a path label. `Saved the report to: /mnt/c/My Docs/a.docx` splits as on
master, pinned as a documented trade-off.

Also:
- a rooted candidate needs a directory before its first space, and an
  unlinkable one reserves its span only while its last word holds a `/`, so
  `/usr/bin/env node build.js` and `/review the changes in src/foo.ts` keep
  their links while `/etc/secret files/passwords.txt` still links nothing
- a run of spaces is a column gap, not part of a name
- a leading `./` is not read as a segment
- the response viewer carries a bold path label that starts a later line of
  a paragraph (after a `<br>`), and degrades if the label pattern is missing
- response viewer tests render with the options chat uses
This commit is contained in:
Aamer Akhter committed 2026-10-24 19:55:11 -04:00
1 parent 74a107cf4f
commit 405c9928d9
5 files changed
+123 -28

No files matched your search

+11 -6
View File
@@ -3015,18 +3015,23 @@ class CodemanApp {
// invalidates the traversal.
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
const targets = [];
// Same promise as the guard above: a stale constants.js without the label
// pattern only loses the carried label, it never throws.
const labelPattern = typeof SPACED_FILE_PATH_LABEL_PATTERN !== 'undefined' ? SPACED_FILE_PATH_LABEL_PATTERN : null;
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
if (node.parentElement?.closest('a')) continue;
// Markdown puts **Saved to:** in a separate <strong> node. Carry only
// an explicit leading path label into detection; never arbitrary prose
// or a label from another block. The label itself stays untouched.
// an explicit path label that starts its line into detection; never
// arbitrary prose or a label from another block. Chat renders with
// `breaks: true`, so a later line of a paragraph starts after a <br>.
// The label itself stays untouched.
const label = node.previousSibling;
const beforeLabel = label?.previousSibling;
let beforeLabel = label?.previousSibling;
if (beforeLabel?.nodeType === 3 && !beforeLabel.nodeValue.trim()) beforeLabel = beforeLabel.previousSibling;
const labelText = label?.nodeName === 'STRONG' ? label.textContent : '';
const leadingLabel =
/^(?:P|LI)$/.test(node.parentElement?.tagName || '') &&
(!beforeLabel || (beforeLabel.nodeType === 3 && !beforeLabel.nodeValue.trim() && !beforeLabel.previousSibling));
const prefix = leadingLabel && SPACED_FILE_PATH_LABEL_PATTERN.test(labelText) ? `${labelText} ` : '';
/^(?:P|LI)$/.test(node.parentElement?.tagName || '') && (!beforeLabel || beforeLabel.nodeName === 'BR');
const prefix = leadingLabel && labelPattern?.test(labelText) ? `${labelText} ` : '';
const found = findFilePathLinks(prefix + (node.nodeValue || ''))
.filter((hit) => hit.index >= prefix.length)
.map((hit) => ({ ...hit, index: hit.index - prefix.length }));
+30 -12
View File
@@ -2234,17 +2234,24 @@ const SPACED_FILE_PATH_LABEL_PATTERN =
* - /mnt/c/My Docs/report.docx after a list marker (`-`, `*`, `•`, `1.`, `2)`)
* Saved to: 03 Attachments/Build SIPOC Design/plan.pptx after a short label (one to four words + `:`)
*
* An absolute path may stand alone or follow a marker. Relative and `~/`
* paths require an explicit path label (SPACED_FILE_PATH_LABEL_PATTERN), with
* an optional marker before it: generic labels and bullets also introduce prose. One trailing
* sentence mark is left out. The candidate must read as ONE path: it ends on a
* known extension, and no earlier word ends on one or `/`; later words cannot
* start a new `/`, `~/`, `./` or `../` path. No earlier word ends
* in `:` or is an arrow, so two paths on a line are never merged.
* An absolute path may stand alone or follow a marker. Any label, before an
* absolute, relative or `~/` path, must be an explicit path label
* (SPACED_FILE_PATH_LABEL_PATTERN), with an optional marker before it: generic
* labels (`Error:`, `Note:`) and bare bullets also introduce prose, so a relative
* path needs a path label to be taken spaced. One trailing sentence mark is left
* out, and a leading `./` is not a segment. The candidate must read as ONE path:
* it ends on a known extension, a rooted one has a directory before its first
* space, no earlier word ends on an extension or `/`, and none holds a `:` and a
* digit (`a.ts:42`, a line suffix ends the path). Later words cannot start a new
* `/`, `~/`, `./` or `../` path, no earlier word ends in `:` or is an arrow, and
* a run of spaces (a column gap) is refused, so two paths on a line are never
* merged.
*
* A candidate that is one path but not a linkable one (an unknown root such as
* `/etc`, a `.`/`..`/empty segment) comes back with `link: false`: its span links
* nothing, rather than the fragment after its last space.
* `/etc`, a `.`/`..`/empty segment) comes back with `link: false` when its last
* word still holds a `/`: its span links nothing, rather than the fragment after
* its last space. Otherwise (`/usr/bin/env node build.js`) the line is left to
* the ordinary detectors.
*
* Linear: one bounded prefix match, then string scans.
*
@@ -2257,18 +2264,29 @@ function spacedFilePathOnLine(line) {
const index = prefix[0].length;
const path = text.slice(index).trimEnd().replace(/[.,;:!?]$/, '');
if (!path.includes(' ') || !path.includes('/') || path.length > 1000) return null;
if (!path.startsWith('/') && !SPACED_FILE_PATH_LABEL_PATTERN.test(prefix[2] || '')) return null;
// After a label, only a path label introduces a path; with no label, only an
// absolute path may stand alone. `Error: /a/b contains c.md` is prose.
if (prefix[2] ? !SPACED_FILE_PATH_LABEL_PATTERN.test(prefix[2]) : !path.startsWith('/')) return null;
if (!PATH_TOKEN_ENDS_WITH_EXTENSION.test(path)) return null;
if (/[\t"'`<>|;&\x00-\x1f\\]/.test(path) || path.includes('://')) return null;
// A run of spaces is a column gap (`git status`, `ls -l`), not a name.
if (/[\t"'`<>|;&\x00-\x1f\\]/.test(path) || path.includes('://') || path.includes(' ')) return null;
const words = path.split(' ');
// A rooted candidate must have a directory before its first space: `/usr/bin/env node a.js`
// or `/review the changes in src/a.ts` is a command, not a spaced path.
if (path.startsWith('/') && !/^\/[^/]+\//.test(words[0])) return null;
for (const word of words.slice(0, -1)) {
if (word.endsWith('/') || word.endsWith(':') || /^(?:—|–|→|=>|->|<-)$/.test(word)) return null;
// `a.ts:42` (grep -n, compiler output): the path already ended at the line suffix.
if (/:\d/.test(word)) return null;
if (PATH_TOKEN_ENDS_WITH_EXTENSION.test(word.replace(/[.,;:!?)\]}]+$/, ''))) return null;
}
if (words.slice(1).some((word) => /^(?:\/|~\/|\.{1,2}\/)/.test(word))) return null;
const rooted = SPACED_PATH_ROOT.test(path) || path.startsWith('~/');
const segments = path.replace(/^~?\//, '').split('/');
const segments = path.replace(/^(?:~?\/|\.\/)/, '').split('/');
const link = (rooted || !path.startsWith('/')) && segments.every((seg) => seg && seg !== '.' && seg !== '..');
// Reserve an unlinkable span only when it still reads as a path to its end;
// otherwise leave the line to the ordinary detectors.
if (!link && !words[words.length - 1].includes('/')) return null;
return { path, index, link };
}