mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 14:39:42 +02:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user