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

+14 -3
View File
@@ -58,7 +58,9 @@ vm.runInContext(
readFileSync(resolve(import.meta.dirname, '../src/web/public/vendor/marked.min.js'), 'utf8'),
markedContext
);
const marked = markedContext.marked as { parse(text: string): string };
const marked = markedContext.marked as { parse(text: string, options?: object): string };
/** The options `_renderMarkdown` passes, so the tests see chat's real markup (a newline becomes `<br>`). */
const renderChat = (markdown: string) => marked.parse(markdown, { breaks: true, gfm: true });
const CodemanApp = loadCodemanAppClass();
const APP_SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/app.js'), 'utf8');
@@ -89,8 +91,16 @@ describe('response viewer file-path linkifier', () => {
['**Saved to:** /mnt/c/My Docs/a.docx', ['/mnt/c/My Docs/a.docx']],
['**Tests:** 45/45 pass in test/foo.test.ts', ['test/foo.test.ts']],
['**Error:** I/O failure reading data.json', ['data.json']],
[
'Done.\n**Saved to:** 03 Attachments/Build SIPOC Design/Build-SIPOC.pptx',
['03 Attachments/Build SIPOC Design/Build-SIPOC.pptx'],
],
[
'**Size:** 2 MB\n**Saved to:** 03 Attachments/Build SIPOC Design/Build-SIPOC.pptx',
['03 Attachments/Build SIPOC Design/Build-SIPOC.pptx'],
],
])('links rendered Markdown with consumed markers and labels: %s', (markdown, expected) => {
const html = marked.parse(markdown);
const html = renderChat(markdown);
const original = document.createElement('div');
original.innerHTML = html;
const root = linkify(html);
@@ -103,8 +113,9 @@ describe('response viewer file-path linkifier', () => {
for (const markdown of [
'See **Saved to:** 03 Attachments/Build SIPOC Design/Build-SIPOC.pptx',
'**Saved to:**\n\n03 Attachments/Build SIPOC Design/Build-SIPOC.pptx',
'Done.\nSee **Saved to:** 03 Attachments/Build SIPOC Design/Build-SIPOC.pptx',
]) {
expect(paths(linkify(marked.parse(markdown))).map((a) => a.getAttribute('data-path'))).toEqual([
expect(paths(linkify(renderChat(markdown))).map((a) => a.getAttribute('data-path'))).toEqual([
'Design/Build-SIPOC.pptx',
]);
}
+67 -6
View File
@@ -67,12 +67,15 @@ describe('spaced file paths', () => {
expect(paths(`${label} ${p}`)).toEqual([p]);
});
it.each(['- ', '* ', '• ', '1. ', '2) '])('links a spaced path after the list marker %j', (marker) => {
const rel = '03 Attachments/Build SIPOC Design/Build-SIPOC.pptx';
expect(paths(`${marker}${rel}`)).toEqual(['Design/Build-SIPOC.pptx']);
expect(paths(`${marker}Path: ${rel}`)).toEqual([rel]);
expect(paths(` ${marker}${LONG}`)).toEqual([LONG]);
});
it.each(['- ', '* ', '• ', '1. ', '2) '])(
'defers a relative spaced path after a bare list marker %j, links it after a path label',
(marker) => {
const rel = '03 Attachments/Build SIPOC Design/Build-SIPOC.pptx';
expect(paths(`${marker}${rel}`)).toEqual(['Design/Build-SIPOC.pptx']);
expect(paths(`${marker}Path: ${rel}`)).toEqual([rel]);
expect(paths(` ${marker}${LONG}`)).toEqual([LONG]);
}
);
it('works line by line in code-block text, where list markers remain literal', () => {
const text = `Wrote two files:\n- ${LONG}\n- Path: notes/a b/c.md\n`;
@@ -165,6 +168,64 @@ describe('spaced file paths', () => {
});
});
/** The links master returned for `line`, in order: `[path, folder?]` pairs, each at its first occurrence. */
const masterLinks = (line: string, ...links: Array<[string, true?]>) =>
links.map(([path, folder]) => ({ path, index: line.indexOf(path), ...(folder ? { folder } : {}) }));
describe('spaced file paths: shapes that keep master links', () => {
// grep -n, rg -n and compiler output: a `:` plus a digit inside an earlier
// word is a line suffix, so the path already ended there.
it.each([
['/home/me/proj/README.md:12:See docs/setup.md', '/home/me/proj/README.md', 'docs/setup.md'],
[
' /home/me/proj/CLAUDE.md:42:Deep detail lives in docs/architecture-invariants.md',
'/home/me/proj/CLAUDE.md',
'docs/architecture-invariants.md',
],
['/home/me/proj/src/a.ts:42:5 - error in b.ts', '/home/me/proj/src/a.ts', 'b.ts'],
['/home/me/proj/src/index.ts:10 updated config.json', '/home/me/proj/src/index.ts', 'config.json'],
])('ends the path at a line suffix: %s', (line, first, second) => {
expect(findFilePathLinks(line)).toEqual(masterLinks(line, [first], [second]));
});
// A generic label introduces prose for an absolute path as much as a relative one.
it.each([
['Error: /home/me/proj contains a.md', '/home/me/proj', 'a.md'],
['Warning: /home/me/proj is not tracked, see notes.md', '/home/me/proj', 'notes.md'],
['Working directory: /home/me/proj, see README.md', '/home/me/proj', 'README.md'],
['- Note: /tmp/out has the logs in run.log', '/tmp/out', 'run.log'],
])('defers an absolute path after a generic label: %s', (line, folder, file) => {
expect(findFilePathLinks(line)).toEqual(masterLinks(line, [folder, true], [file]));
});
it('documented trade-off: an absolute spaced path after a non-path label splits as on master', () => {
const line = 'Saved the report to: /mnt/c/My Docs/a.docx';
expect(findFilePathLinks(line)).toEqual(masterLinks(line, ['/mnt/c/My', true], ['Docs/a.docx']));
});
it.each([
['/usr/bin/python3 script.py', 'script.py'],
['/usr/bin/env node build.js', 'build.js'],
['/review the changes in src/foo.ts', 'src/foo.ts'],
])('does not reserve a command line that only looks like one path: %s', (line, file) => {
expect(findFilePathLinks(line)).toEqual(masterLinks(line, [file]));
});
it('still refuses an unlinkable spaced path that reads as one path to its end', () => {
expect(paths('/etc/secret files/passwords.txt')).toEqual([]);
});
it('does not merge a column gap into one path', () => {
const line = '/home/me/proj modified notes.md';
expect(findFilePathLinks(line)).toEqual(masterLinks(line, ['/home/me/proj', true], ['notes.md']));
});
it('reads a leading ./ as the workspace, not a segment', () => {
expect(paths('Saved to: ./My Docs/plan.md')).toEqual(['./My Docs/plan.md']);
expect(paths('Saved to: ./notes.md')).toEqual(['./notes.md']);
});
});
describe('terminal link provider with spaced paths', () => {
type Link = { text: string; activate: (event: unknown, text: string) => void };
const TERMINAL_UI = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8');