Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<Xterm>`'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
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
19 changes: 12 additions & 7 deletions scripts/mouse-tui.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand Down
2 changes: 1 addition & 1 deletion scripts/termbed.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
7 changes: 5 additions & 2 deletions src/edge/session-backend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <Xterm>'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);
Expand All @@ -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'],
Expand Down
83 changes: 50 additions & 33 deletions web/src/Xterm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 */ } }
Expand All @@ -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
},
}
}
Expand Down Expand Up @@ -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 <canvas> 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 */ }
Expand Down
7 changes: 6 additions & 1 deletion web/src/termbed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,4 +84,9 @@ function TermBed() {
)
}

createRoot(document.getElementById('root')!).render(<StrictMode><TermBed /></StrictMode>)
// 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 = <TermBed />
createRoot(document.getElementById('root')!).render(strict ? <StrictMode>{app}</StrictMode> : app)
Loading