From 8fa1ca5f19f219c7a4153e7cf54bac8cca4ab4fd Mon Sep 17 00:00:00 2001 From: Vikas Singhal Date: Tue, 14 Jul 2026 16:40:43 +0530 Subject: [PATCH] fix(terminal): scheme-less links + OSC-8 markdown links clickable (v0.195.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps from the v0.192.0 terminal rework: - Custom link matchers were too narrow — a bare domain needed a short-listed TLD and host/path with an unusual TLD didn't match. Rewrote them: broad common-TLD list for bare domains, and any multi-label host carrying a /path is treated as a URL (the slash is a strong signal), while still not linking Component.tsx / src/main.rs / version strings. Provider registered after the renderer + wrapped so a bad row can't break linkification. - tmux was stripping every OSC-8 hyperlink (claude's markdown links) before it reached the browser — the outer terminal wasn't advertised hyperlink-capable. Added `hyperlinks` to tmux terminal-features so they flow through and open via 's linkHandler. Test bed prints realistic link cases + `?strict=0` reproduces the prod single-run (no StrictMode). Verified 13/13 via Playwright against the mouse-reporting harness. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 19 +++++++++ package-lock.json | 4 +- package.json | 2 +- scripts/mouse-tui.mjs | 19 +++++---- scripts/termbed.mjs | 2 +- src/edge/session-backend.ts | 7 +++- web/src/Xterm.tsx | 83 ++++++++++++++++++++++--------------- web/src/termbed.tsx | 7 +++- 8 files changed, 96 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58d1193..0a8a6e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,25 @@ new version heading in the same commit. ## [Unreleased] +## [0.195.0] — 2026-07-14 +### Fixed +- **Plain-text links in the terminal are now clickable — no scheme needed, and OSC-8 markdown links work + too.** Two gaps from the v0.192.0 rework: + - The custom link matchers were too narrow: a bare domain only clicked if its TLD was in a short list, + and a `host/path` with an unusual TLD didn't match at all. Rewrote them — a broad common-TLD list for + bare `example.com`/`my-shop.store`, and **any multi-label host that carries a `/path` is treated as a + URL** (`foo.bar/baz`, `docs.github.io/xterm`) since the slash is a strong signal — while still NOT + linking `Component.tsx`, `src/main.rs`, or version strings. The provider is now registered after the + renderer and wrapped so a bad row can't break linkification. (`web/src/Xterm.tsx`.) + - **tmux was silently stripping every OSC-8 hyperlink** (claude's markdown links) before they reached + the browser, because the outer terminal wasn't advertised as hyperlink-capable. Added `hyperlinks` to + tmux's `terminal-features`, so OSC-8 links now flow through and open on click via ``'s + `linkHandler`. (`src/edge/session-backend.ts`, `scripts/termbed.mjs`.) +- Test bed: `scripts/mouse-tui.mjs` now prints the realistic link cases (trailing punctuation, in a + sentence, parenthesised, uncommon TLD, an OSC-8 link, non-link tokens), and `termbed.html?strict=0` + renders without React StrictMode so the prod single-run behaviour can be reproduced. Verified 13/13 via + Playwright against the mouse-reporting harness in both StrictMode configs. (`web/src/termbed.tsx`.) + ## [0.193.2] — 2026-07-14 ### Fixed - **Sidebar "All" session badge now counts only genuinely live runs.** It was keyed off `isLive`, which diff --git a/package-lock.json b/package-lock.json index e94ab71..e43fdf2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "agent-os", - "version": "0.193.2", + "version": "0.195.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "agent-os", - "version": "0.193.2", + "version": "0.195.0", "license": "MIT", "bin": { "agent-os": "bin/agent-os" diff --git a/package.json b/package.json index 0eb9479..2e70001 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "agent-os", - "version": "0.193.2", + "version": "0.195.0", "description": "A generic, governed operating system for running autonomous agents safely across brands. Ships with a local web console.", "license": "MIT", "type": "commonjs", diff --git a/scripts/mouse-tui.mjs b/scripts/mouse-tui.mjs index 21fbdba..c936417 100644 --- a/scripts/mouse-tui.mjs +++ b/scripts/mouse-tui.mjs @@ -25,13 +25,18 @@ function draw() { w(clear + home(1, 1)) w('\x1b[1;38;5;39m mouse-tui \x1b[0m — reproduces claude\'s mouse-reporting TUI\r\n') w('\x1b[90m mouse tracking is ON (1000+1006), so without the fix xterm would refuse to select\x1b[0m\r\n\r\n') - w(' Links to click (none carry a scheme except the first):\r\n') - w(' 1. full url https://example.com/path?q=1\r\n') - w(' 2. bare domain example.com\r\n') - w(' 3. subdomain docs.github.io/xterm\r\n') - w(' 4. localhost localhost:5199/foo\r\n') - w(' 5. www www.google.com\r\n') - w(' 6. not a link Component.tsx:42 (should NOT underline)\r\n\r\n') + w(' Links to click (only #1 carries a scheme — the rest are the "plain text" cases):\r\n') + w(' 1. full url https://example.com/path?q=1\r\n') + w(' 2. bare domain example.com\r\n') + w(' 3. subdomain docs.github.io/xterm\r\n') + w(' 4. localhost localhost:5199/foo\r\n') + w(' 5. www www.google.com\r\n') + w(' 6. end of line, trailing period → visit instapods.io.\r\n') + w(' 7. in a sentence: see the deploy at ai.expresstech.io for details\r\n') + w(' 8. parenthesised (github.com/vikasprogrammer/agent-os)\r\n') + w(' 9. uncommon TLD my-shop.store and a path one foo.bar/baz\r\n') + w(' 10. osc-8 markup \x1b]8;;https://claude.com/code\x1b\\the Claude Code site\x1b]8;;\x1b\\\r\n') + w(' 11. NOT a link Component.tsx:42 · src/main.rs · v1.2.io-beta\r\n\r\n') w(' Drag across any line above → it should highlight and land on your clipboard.\r\n\r\n') w(`\x1b[7m WHEEL received: ${wheels} last event: ${lastEvt} \x1b[0m\r\n`) w('\r\n \x1b[90mscroll over the pane to tick the counter · press q or Ctrl-C to quit\x1b[0m') diff --git a/scripts/termbed.mjs b/scripts/termbed.mjs index a059e0c..30e5c96 100755 --- a/scripts/termbed.mjs +++ b/scripts/termbed.mjs @@ -43,7 +43,7 @@ for (const opt of [ ['set', '-g', 'set-clipboard', 'on'], // forward OSC 52 copy-on-select → our client's OSC 52 handler ['set', '-g', 'allow-passthrough', 'on'], ['set', '-s', 'extended-keys', 'on'], - ['set', '-as', 'terminal-features', 'xterm*:extkeys'], + ['set', '-as', 'terminal-features', 'xterm*:extkeys:hyperlinks'], // Mouse on so the WHEEL scrolls tmux's scrollback even at a bare shell prompt (xterm can't reach it // otherwise — everything lives on tmux's alternate screen). A running TUI (claude) that requests its // own mouse mode still gets the wheel forwarded to it. diff --git a/src/edge/session-backend.ts b/src/edge/session-backend.ts index 77d8040..87dfaab 100644 --- a/src/edge/session-backend.ts +++ b/src/edge/session-backend.ts @@ -106,7 +106,10 @@ export class LocalSessionBackend implements SessionBackend { // extended-keys pair lets tmux distinguish Shift+Enter from Enter so the newline shortcut works; // set-clipboard on lets claude's copy-on-select OSC 52 escape reach the browser terminal so a // selection in the TUI lands on the USER's browser clipboard (claude DCS-wraps it for the - // passthrough path, and forwards the raw variant too — this covers both). Global + idempotent, so + // passthrough path, and forwards the raw variant too — this covers both). The `hyperlinks` + // terminal-feature tells tmux the outer terminal (our xterm.js) understands OSC 8 — WITHOUT it tmux + // STRIPS every OSC-8 hyperlink, so claude's markdown links never reach the browser and aren't + // clickable (plain-text URLs still are, via 's link matchers). Global + idempotent, so // re-applying per spawn is harmless; older tmux may reject an option → stdio is ignored so it can't // break a session. // mouse on: the WHEEL scrolls tmux's scrollback at a bare shell prompt (e.g. claude's resume screen); @@ -115,7 +118,7 @@ export class LocalSessionBackend implements SessionBackend { // MouseDragEnd copy-selection-no-clear copies (→ OSC 52 → clipboard) WITHOUT clearing the highlight. for (const opt of [['set', '-g', 'allow-passthrough', 'on'], ['set', '-s', 'extended-keys', 'on'], ['set', '-g', 'set-clipboard', 'on'], - ['set', '-as', 'terminal-features', 'xterm*:extkeys'], + ['set', '-as', 'terminal-features', 'xterm*:extkeys:hyperlinks'], ['set', '-g', 'mouse', 'on'], ['set', '-g', 'mode-style', 'bg=#2563eb,fg=#ffffff'], ['bind', '-T', 'copy-mode', 'MouseDragEnd1Pane', 'send-keys', '-X', 'copy-selection-no-clear'], diff --git a/web/src/Xterm.tsx b/web/src/Xterm.tsx index 47b3614..b3312d3 100644 --- a/web/src/Xterm.tsx +++ b/web/src/Xterm.tsx @@ -86,14 +86,29 @@ function stripMouseTracking(onWant: (want: boolean) => void): (input: Uint8Array // ── link detection ─────────────────────────────────────────────────────────────────────────────────── // Matchers run in priority order; earlier matches win over later ones on the same columns, so a full URL // isn't also picked up as a bare domain. The TLD list is deliberately conservative so `file.ts:42` or -// `Component.tsx` don't get underlined as if they were `example.com`. -const TLD = 'com|net|org|io|ai|dev|app|sh|co|gg|xyz|me|so|to|tv|cloud|tech|edu|gov|info|biz|us|uk|ca|de|fr|jp|in' +// `Component.tsx`, `src/main.rs` and version strings don't get underlined as if they were `example.com`. +// The disambiguation rule mirrors how a human reads it: a **bare** `host.tld` needs a KNOWN TLD (so +// `file.ts`/`main.rs` are excluded), but a host that carries a **/path** is almost certainly a URL, so we +// accept any TLD there (`foo.bar/baz`, `docs.github.io/xterm`). The broad TLD list covers the common +// gTLDs + ccTLDs; add to it rather than loosening the bare-domain rule (that's what guards false hits). +const TLD = 'com|net|org|io|ai|dev|app|sh|co|gg|xyz|me|so|to|tv|cloud|tech|edu|gov|info|biz|us|uk|ca|de|fr' + + '|jp|in|store|shop|site|online|live|ly|im|be|nl|eu|au|nz|ru|cn|br|es|it|se|no|fi|pl|ch|at|dk|ie|pro' + + '|page|blog|wiki|news|design|studio|agency|digital|link|click|network|systems|solutions|group|world' + + '|life|today|media|email|chat|zone|run|build|host|space|fun|art|tools|cc|tw|kr|za|mx|ar|id|ua|sg|hk' const TAIL = `[^\\s"'\`<>)\\]}]` // a link body char — stops at whitespace and common wrappers +const HOST = `[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9-]+)+` // a multi-label host: at least one dot const MATCHERS: { re: RegExp; url: (m: string) => string }[] = [ - { re: new RegExp(`(?:https?|file)://${TAIL}+`, 'gi'), url: (m) => m }, + // 1. explicit scheme — take the whole thing verbatim. + { re: new RegExp(`(?:https?|ftp|file)://${TAIL}+`, 'gi'), url: (m) => m }, + // 2. www.… — no scheme, assume https. { re: new RegExp(`\\bwww\\.${TAIL}+`, 'gi'), url: (m) => `https://${m}` }, - { re: new RegExp(`\\b(?:localhost|127\\.0\\.0\\.1)(?::\\d+)?(?:/${TAIL}*)?`, 'gi'), url: (m) => `http://${m}` }, - { re: new RegExp(`\\b[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9-]+)*\\.(?:${TLD})(?::\\d+)?(?:/${TAIL}*)?`, 'gi'), url: (m) => `https://${m}` }, + // 3. localhost / IPv4, with optional :port and /path — local dev servers, assume http. + { re: new RegExp(`\\b(?:localhost|\\d{1,3}(?:\\.\\d{1,3}){3})(?::\\d+)?(?:/${TAIL}*)?`, 'gi'), url: (m) => `http://${m}` }, + // 4. any multi-label host that carries a /path (the slash makes it a URL regardless of TLD). + { re: new RegExp(`\\b${HOST}(?::\\d+)?/${TAIL}*`, 'gi'), url: (m) => `https://${m}` }, + // 5. a BARE host (no path) — one or more labels ending in a known TLD, and the TLD must not run into a + // longer label (`v1.2.io-beta` is not `…io`). Optional :port for e.g. a bare `host.tld:8080`. + { re: new RegExp(`\\b[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\\.[a-z0-9-]+)*\\.(?:${TLD})(?![a-z0-9-])(?::\\d+)?`, 'gi'), url: (m) => `https://${m}` }, ] function openUrl(url: string) { try { window.open(url, '_blank', 'noopener,noreferrer') } catch { /* popup blocked */ } } @@ -113,32 +128,33 @@ function trimTrail(s: string): { text: string; shaved: number } { function makeLinkProvider(term: Terminal): ILinkProvider { return { provideLinks(row, cb) { - const line = term.buffer.active.getLine(row - 1) - if (!line) return cb(undefined) - const text = line.translateToString(true) - if (!text || !/[.:/]/.test(text)) return cb(undefined) - const taken: boolean[] = [] - const links: ILink[] = [] - for (const { re, url } of MATCHERS) { - re.lastIndex = 0 - let m: RegExpExecArray | null - while ((m = re.exec(text))) { - const start = m.index - const { text: raw, shaved } = trimTrail(m[0]) - const end = start + raw.length // exclusive - if (raw.length < 4 || taken[start] || taken[end - 1]) continue - for (let k = start; k < end; k++) taken[k] = true - const target = url(raw) - links.push({ - text: raw, - range: { start: { x: start + 1, y: row }, end: { x: end, y: row } }, - decorations: { pointerCursor: true, underline: true }, - activate: (e) => { e.preventDefault(); openUrl(target) }, - }) - if (shaved && re.lastIndex > end) re.lastIndex = end // don't skip a link glued right after + try { + const line = term.buffer.active.getLine(row - 1) + if (!line) return cb(undefined) + const text = line.translateToString(true) + if (!text || !/[.:/]/.test(text)) return cb(undefined) + const taken: boolean[] = [] + const links: ILink[] = [] + for (const { re, url } of MATCHERS) { + re.lastIndex = 0 + let m: RegExpExecArray | null + while ((m = re.exec(text))) { + const start = m.index + const { text: raw } = trimTrail(m[0]) + const end = start + raw.length // exclusive + if (raw.length < 4 || taken[start] || taken[end - 1]) continue + for (let k = start; k < end; k++) taken[k] = true + const target = url(raw) + links.push({ + text: raw, + range: { start: { x: start + 1, y: row }, end: { x: end, y: row } }, + decorations: { pointerCursor: true, underline: true }, + activate: (e) => { e.preventDefault(); openUrl(target) }, + }) + } } - } - cb(links.length ? links : undefined) + cb(links.length ? links : undefined) + } catch { cb(undefined) } // never let a bad row break the linkifier }, } } @@ -247,15 +263,16 @@ export function Xterm({ const search = new SearchAddon() term.loadAddon(fit) term.loadAddon(search) - // Clickable links for bare domains / localhost:port / www / full URLs (our own provider — the stock - // WebLinksAddon only matches full `https://…`, missing exactly the "rendered link without a scheme"). - term.registerLinkProvider(makeLinkProvider(term)) term.open(host) // Canvas renderer: draw the grid to a instead of the default DOM renderer, whose real // per-cell text is natively selectable by the browser — that native selection competes with xterm's // own selection overlay and "wobbles" (a second, differently-coloured highlight). Canvas has no // selectable DOM, so only xterm's selection shows. Falls back to DOM if canvas 2d is unavailable. try { term.loadAddon(new CanvasAddon()) } catch { /* no 2d context — DOM renderer stays */ } + // Clickable links for bare domains / localhost:port / www / paths / full URLs (our own provider — the + // stock WebLinksAddon only matches full `https://…`, missing exactly the "rendered link without a + // scheme"). Registered AFTER open()/the renderer so the linkifier is fully wired first. + term.registerLinkProvider(makeLinkProvider(term)) termRef.current = term fitRef.current = fit try { fit.fit() } catch { /* not laid out yet */ } diff --git a/web/src/termbed.tsx b/web/src/termbed.tsx index 5492eff..64508e1 100644 --- a/web/src/termbed.tsx +++ b/web/src/termbed.tsx @@ -84,4 +84,9 @@ function TermBed() { ) } -createRoot(document.getElementById('root')!).render() +// StrictMode double-invokes effects in dev, which can mask single-run bugs that only bite the prod +// build (e.g. link-provider registration). `?strict=0` renders without it so the test bed can reproduce +// the production single-run behaviour. +const strict = new URLSearchParams(location.search).get('strict') !== '0' +const app = +createRoot(document.getElementById('root')!).render(strict ? {app} : app)