fix(terminal): link whole URLs and paths instead of truncating them

Three separate truncations, each cutting a clickable link short so it opened the
wrong target (or nothing at all).

1. A single `&` ended the match. It is a query-parameter separator, so every real
   query string was cut: a WordPress edit link resolved to `?post=1479` and opened
   the post list instead of the editor, and Claude Code's own `/login` URL was not
   usable at all. `&` is now part of a URL; `&&` stays a boundary, since that is
   the shell operator and never appears inside one. A lone trailing `&` is still
   trimmed as punctuation.

2. Links longer than the terminal is wide were cut at the row boundary. xterm
   calls the link provider once per visible ROW and translateToString returns only
   that row, despite a comment here claiming it handled wrapping. The provider now
   stitches the continuation rows back into one logical line and maps match offsets
   back to (x, y), so a link can span rows.

   Two kinds of continuation exist and handling only the first is not enough. A
   SOFT wrap is the emulator running out of columns, which flags the next row
   `isWrapped`. A HARD wrap is the program wrapping its own output and emitting a
   real newline, which flags nothing: Ink does this, which is why the /login URL
   was cut at the window edge and why the clickable part grew when the window was
   widened. A row that fills the full width is now treated as continuing into the
   next, that being the only trace a hard wrap leaves behind. Bounded to 12 rows so
   a screenful of wide output cannot make every hover re-scan the viewport.

3. Image and PDF paths were not matched at all. `.claude-images/paste-*.png`, what
   Codeman writes for a pasted screenshot, rendered as plain text. Those extensions
   are now linked and open the file preview, which renders images inline, rather
   than the log viewer, which would show binary noise.

Verified in a real terminal: a 450-char /login URL hard-wrapped across 5 rows with
zero isWrapped flags in the buffer (so a genuine hard wrap, not the soft case)
links intact, as do soft-wrapped URLs and a wrapped attachment path. Regression
cases added to link-provider-regex.test.ts, which extracts the patterns from the
shipped source so they cannot drift. Its existing ReDoS guard still passes, which
matters because this changes a pattern that once froze the tab on hover.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-07-27 22:25:29 +02:00
parent b34fcaf928
commit 346bc8b173
2 changed files with 146 additions and 16 deletions
+55
View File
@@ -79,6 +79,61 @@ describe('terminal link-provider regexes (shipped source)', () => {
}
});
it('urlPattern keeps query strings whole (a single & is part of the URL)', () => {
// Excluding `&` truncated every real query string: a WordPress edit link
// resolved to `?post=1479` and opened the wrong page, and Claude Code's OAuth
// login URL (many `&` params) was not clickable at all.
const url = shippedPattern('urlPattern');
const strip = (u: string) => u.replace(/[.,;:!?)&]+$/, '');
const cases: Array<[string, string]> = [
[
'updated in place: https://bio-hacking.blog/wp-admin/post.php?post=1479&action=edit',
'https://bio-hacking.blog/wp-admin/post.php?post=1479&action=edit',
],
[
'open https://claude.ai/oauth/authorize?code=true&client_id=abc&scope=user%3Ainference&state=xyz',
'https://claude.ai/oauth/authorize?code=true&client_id=abc&scope=user%3Ainference&state=xyz',
],
['see https://x.com/a?b=1&c=2&d=3 ok', 'https://x.com/a?b=1&c=2&d=3'],
// A lone trailing & is punctuation, not part of the target.
['trailing https://x.com/a?b=1& next', 'https://x.com/a?b=1'],
];
for (const [line, want] of cases) {
url.lastIndex = 0;
const m = url.exec(line);
expect(m, line).not.toBeNull();
expect(strip(m![0]), line).toBe(want);
}
});
it('urlPattern still stops at the shell && operator', () => {
// `&&` never appears inside a URL, so it must remain a boundary or a link
// would swallow the next command.
const url = shippedPattern('urlPattern');
for (const line of ['curl https://x.com/api && echo done', 'curl https://x.com/api&&echo done']) {
url.lastIndex = 0;
expect(url.exec(line)![0], line).toBe('https://x.com/api');
}
});
it('extPattern links pasted image/PDF attachment paths', () => {
// `.claude-images/paste-*.png` is what Codeman writes for a pasted screenshot;
// without image extensions the path rendered as plain, unclickable text.
const ext = shippedPattern('extPattern');
const cases = [
'/home/arkon/default/claudeman/.claude-images/paste-1785164958410-d11eb7d0.png',
'/tmp/shot.jpeg',
'/opt/app/report.pdf',
'/home/a/diagram.svg',
];
for (const path of cases) {
ext.lastIndex = 0;
const m = ext.exec(`see ${path} here`);
expect(m, path).not.toBeNull();
expect(m![1], path).toBe(path);
}
});
it('cmdPattern arg group cannot match empty tokens (the exponential trigger)', () => {
// structural guard: the dangerous construct is an empty-matchable token
// inside a repeated group — `[^\s\/]*\s+` repeated. Check the pattern