From 0e373fd2d973ee93ef07a16455fc0733a48b24a4 Mon Sep 17 00:00:00 2001 From: sean Date: Sat, 10 Oct 2026 13:08:18 +0100 Subject: [PATCH] =?UTF-8?q?fix(box):=20links=20a=20program=20draws=20as=20?= =?UTF-8?q?text,=20like=20"authorize=20with=20codex",=20open=20with=20?= =?UTF-8?q?=E2=8C=98-click?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit berthd's tmux dropped OSC 8 hyperlinks on the way to the app: the app attaches as xterm-256color, tmux's terminal-features don't give xterm the hyperlinks feature, and ghostty-web doesn't answer the DA/XTVERSION queries tmux could learn it from. tmux 3.4+ now gets terminal-features[90] "xterm*:hyperlinks" (guarded by version, so older tmux skips it). In the app, a link's text is remembered in the pieces tmux draws it in, and adjacent runs with the same address are one link. Co-Authored-By: Claude Opus 5.5 --- app/e2e/terminal-links.spec.ts | 42 ++++++++++++++++++++++++ app/src/lib/term-links.test.ts | 43 +++++++++++++++++++++++++ app/src/lib/term-links.ts | 58 ++++++++++++++++++++++++++++------ app/src/lib/terminal.ts | 6 +++- docs/changelog.mdx | 5 +++ internal/box/sessions.go | 12 +++++++ internal/box/sessions_test.go | 37 ++++++++++++++++++++++ 7 files changed, 192 insertions(+), 11 deletions(-) diff --git a/app/e2e/terminal-links.spec.ts b/app/e2e/terminal-links.spec.ts index 754b6dbf..7867795d 100644 --- a/app/e2e/terminal-links.spec.ts +++ b/app/e2e/terminal-links.spec.ts @@ -288,6 +288,48 @@ test("an OSC 8 link whose text wraps opens its address from every row", async ({ await expect.poll(() => opened(page)).toEqual([uri, uri, other, other]); }); +// fx's "Sign in with Codex" screen through a box's tmux, with a made-up +// address: as an attach draws it (tmux 3.6 with its hyperlinks feature), +// and drawn in pieces, each a hyperlink of its own, the second piece first. +const AUTH = + "https://auth.openai.com/oauth/authorize?response_type=code&client_id=app_acme0123456789&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback&state=c3RhdGVhY21l&originator=fx"; +const FX_SCREENS = { + attached: `${SCREEN}\x1b[4;1HSign in with Codex\x1b[5;1H\x1b[1m\x1b[38;5;255m Open \x1b[4m\x1b]8;id=tmux3;${AUTH}\x1b\\Authorize with Codex\x1b]8;;\x1b\\\x1b(B\x1b[m\x1b[K\r\n`, + "in pieces": `${SCREEN}\x1b[4;1HSign in with Codex\x1b[5;1H Open \x1b[5;19H\x1b[4m\x1b]8;id=tmux5;${AUTH}\x1b\\ with Codex\x1b]8;;\x1b\\\x1b[5;10H\x1b]8;id=tmux4;${AUTH}\x1b\\Authorize\x1b]8;;\x1b\\\x1b(B\x1b[m`, +}; + +for (const [how, out] of Object.entries(FX_SCREENS)) { + test(`a link a program draws as text, like "Authorize with Codex", is one link from any word and opens with ⌘-click (${how})`, async ({ app }) => { + mockOnly("writes to the mock's terminal"); + const page = app.page; + await recordOpens(page); + await app.open(); + await openShell(page); + await write(page, out); + await expect.poll(() => screenText(page)).toContain(" Open Authorize with Codex"); + const hint = page.locator("[data-testid=pane][data-pane-kind=terminal]:visible").getByTestId("terminal-link-hint"); + + // Resting on any word of it, all of it is the link. + const whole = { startX: 9, startY: 4, endX: 28, endY: 4 }; + for (const x of [10, 19, 26]) { + const at = await cellAt(page, x, 4); + await page.mouse.move(at.x, at.y); + await expect.poll(() => hovered(page)).toEqual(whole); + } + await expect(hint).toHaveText("⌘-click to open"); + + // ⌘-click opens its whole address, from its last word or its first. + await cmdClick(page, await cellAt(page, 26, 4)); + await expect.poll(() => opened(page)).toEqual([AUTH]); + await cmdClick(page, await cellAt(page, 10, 4)); + await expect.poll(() => opened(page)).toEqual([AUTH, AUTH]); + // "Open" before it is not part of it. + await cmdClick(page, await cellAt(page, 3, 4)); + await cmdClick(page, await cellAt(page, 10, 4)); + await expect.poll(() => opened(page)).toEqual([AUTH, AUTH, AUTH]); + }); +} + test("in xterm.js, a web address and an OSC 8 link open too", async ({ app }) => { mockOnly("writes to the mock's terminal"); const page = app.page; diff --git a/app/src/lib/term-links.test.ts b/app/src/lib/term-links.test.ts index 152da138..02880afd 100644 --- a/app/src/lib/term-links.test.ts +++ b/app/src/lib/term-links.test.ts @@ -47,6 +47,49 @@ test("an OSC 8 hyperlink's text gives its address back", () => { assert.equal(h.uriFor("the label"), "https://example.com/osc2"); }); +// fx's "Sign in with Codex" screen (fx 0.0.13), with a made-up address. +const AUTH = "https://auth.openai.com/oauth/authorize?response_type=code&client_id=app_acme0123456789&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback&state=c3RhdGVhY21l&originator=fx"; + +test("a sign-in link's text gives its address from any word of it, as fx and tmux send it", () => { + // fx itself, with its own id, its colours reset inside the link. + const fx = new HyperlinkTracker(); + fx.feed(`\x1b[1m\x1b[38;5;255m Open \x1b[4m\x1b]8;id=fx-codex-auth;${AUTH}\x1b\\Authorize with Codex\x1b[0m\x1b]8;;\x1b\\`); + // tmux 3.6 attached with its hyperlinks feature: its own id, and the + // colours reset after the link. + const tmux = new HyperlinkTracker(); + tmux.feed(`\x1b[5;1H\x1b[1m\x1b[38;5;255m Open \x1b[4m\x1b]8;id=tmux3;${AUTH}\x1b\\Authorize with Codex\x1b]8;;\x1b\\\x1b(B\x1b[m\x1b[K\r\n`); + for (const h of [fx, tmux]) { + for (const text of ["Authorize with Codex", "Authorize", "with Codex", " with ", "Codex"]) assert.equal(h.uriFor(text), AUTH, text); + assert.equal(h.uriFor("Open"), undefined); + } + // Without the feature, tmux sent the text alone: nothing to open. + const plain = new HyperlinkTracker(); + plain.feed("\x1b[5;1H\x1b[1m\x1b[38;5;255m Open \x1b[4mAuthorize with Codex\x1b(B\x1b[m\x1b[K\r\n"); + assert.equal(plain.uriFor("Authorize with Codex"), undefined); +}); + +test("a link tmux draws in pieces, moving the cursor between them, is found from all of it", () => { + const h = new HyperlinkTracker(); + // One hyperlink, its text put in two places: as "Authorize with" and + // "Codex", not "Authorize withCodex". + h.feed(`\x1b[5;10H\x1b]8;id=tmux3;${AUTH}\x1b\\Authorize with\x1b[5;25HCodex\x1b]8;;\x1b\\`); + assert.equal(h.uriFor("Authorize with Codex"), AUTH); + assert.equal(h.uriFor("withCodex"), undefined); + // Each piece a hyperlink of its own (tmux opens it again for each). + const again = new HyperlinkTracker(); + again.feed(`\x1b]8;id=tmux4;${AUTH}\x1b\\Authorize\x1b]8;;\x1b\\\x1b[C\x1b]8;id=tmux4;${AUTH}\x1b\\with\x1b]8;;\x1b\\ \x1b]8;id=tmux4;${AUTH}\x1b\\Codex\x1b]8;;\x1b\\`); + assert.equal(again.uriFor("Authorize with Codex"), AUTH); + // Across a line break, as on two rows. + const rows = new HyperlinkTracker(); + rows.feed(`\x1b]8;;${AUTH}\x1b\\Authorize\r\nwith Codex\x1b]8;;\x1b\\`); + assert.equal(rows.uriFor("Authorizewith Codex"), AUTH); + // Text only partly made of one link's pieces is not that link. + assert.equal(again.uriFor("Authorize the acme app"), undefined); + again.feed("\x1b]8;;https://example.com/acme\x1b\\acme app\x1b]8;;\x1b\\"); + assert.equal(again.uriFor("Authorize acme app"), undefined); + assert.equal(again.uriFor("acme app"), "https://example.com/acme"); +}); + test("hyperlinks cut across pieces of output are read whole, as text or bytes", () => { const whole = "x \x1b]8;;https://example.com/split\x1b\\split ✓ link\x1b]8;;\x1b\\ y"; for (const cut of [1, 2, 3, 4, 5, 10, 30, 36, 40, 44, 50]) { diff --git a/app/src/lib/term-links.ts b/app/src/lib/term-links.ts index 1608b897..49ab0c2d 100644 --- a/app/src/lib/term-links.ts +++ b/app/src/lib/term-links.ts @@ -141,6 +141,8 @@ export const openable = (uri: string): boolean => /^(?:https?:\/\/[^\s]|mailto:[ const MAX_OSC = 4096; const MAX_LABEL = 1024; const MAX_LINKS = 500; +// The final characters of the CSI sequences that move the cursor. +const MOVES = "ABCDEFGHadef`"; // HyperlinkTracker reads a terminal's output for OSC 8 hyperlinks // (ESC ] 8 ; params ; URI ST, the text, then ESC ] 8 ; ; ST) and remembers @@ -187,8 +189,10 @@ export class HyperlinkTracker { this.scan(this.decoder.decode(b, { stream: true })); } - // uriFor is the address of the hyperlink whose text (or, for one that - // wraps onto the next row, a piece of whose text) this is. + // uriFor is the address of the hyperlink whose text this is: all of it, + // a piece of it (a row it wraps over), or the pieces it was sent in. tmux + // draws a link in pieces when only some of its cells change, so + // "Authorize with Codex" can come as "Authorize" and " with Codex". uriFor(text: string): string | undefined { const t = text.trim(); if (!t) return undefined; @@ -197,6 +201,27 @@ export class HyperlinkTracker { if (t.length < 2) return undefined; let found: string | undefined; for (const [label, uri] of this.links) if (label.includes(t)) found = uri; + return found ?? this.inPieces(t); + } + + // inPieces is the address whose texts, each put where it is in t, make + // up all of t but its spaces; the latest if more than one do. + private inPieces(t: string): string | undefined { + const covered = new Map(); + for (const [label, uri] of this.links) { + let at = t.indexOf(label); + if (at < 0) continue; + const cov = covered.get(uri) ?? new Uint8Array(t.length); + for (; at >= 0; at = t.indexOf(label, at + 1)) cov.fill(1, at, at + label.length); + covered.delete(uri); + covered.set(uri, cov); + } + let found: string | undefined; + for (const [uri, cov] of covered) { + let all = true; + for (let i = 0; all && i < t.length; i++) all = cov[i] === 1 || t[i] === " "; + if (all) found = uri; + } return found; } @@ -220,7 +245,10 @@ export class HyperlinkTracker { switch (this.mode) { case "text": if (c === "\x1b") this.mode = "esc"; - else if (this.uri !== null && c >= " " && c !== "\x7f" && this.label.length < MAX_LABEL) this.label += c; + else if (this.uri === null) break; + else if (c >= " " && c !== "\x7f") { + if (this.label.length < MAX_LABEL) this.label += c; + } else if (c === "\r" || c === "\n") this.piece(); break; case "esc": if (c === "]") { @@ -234,7 +262,12 @@ export class HyperlinkTracker { this.mode = "text"; break; case "csi": - if (c >= "@" && c <= "~") this.mode = "text"; + if (c >= "@" && c <= "~") { + this.mode = "text"; + // The cursor moved with the link still on: what comes next is + // another piece of its text, somewhere else. + if (this.uri !== null && MOVES.includes(c)) this.piece(); + } break; case "osc": if (c === "\x07") this.endOsc(); @@ -266,14 +299,19 @@ export class HyperlinkTracker { } private close() { - const label = this.label.trim(); - if (this.uri !== null && label) { - this.links.delete(label); - this.links.set(label, this.uri); - if (this.links.size > MAX_LINKS) this.links.delete(this.links.keys().next().value!); - } + this.piece(); this.uri = null; + } + + // piece remembers the text read since the link started or the cursor + // last moved, with the link's address. + private piece() { + const label = this.label.trim(); this.label = ""; + if (this.uri === null || !label) return; + this.links.delete(label); + this.links.set(label, this.uri); + if (this.links.size > MAX_LINKS) this.links.delete(this.links.keys().next().value!); } } diff --git a/app/src/lib/terminal.ts b/app/src/lib/terminal.ts index 4766deea..0cfac1d5 100644 --- a/app/src/lib/terminal.ts +++ b/app/src/lib/terminal.ts @@ -769,7 +769,11 @@ function ghosttyLinks(term: object, host: HTMLElement, openUrl: (url: string) => // tmux sends the hyperlink again on each row it redraws, so the // text it was sent with can be just this row's piece. const uri = tracker.uriFor(text) ?? tracker.uriFor(own); - if (uri && openable(uri)) out.push({ text: uri, range: { start, end: last }, activate: open(uri) }); + const prev = out.at(-1); + // A run right after one with the same address is more of the + // same link, drawn apart: one link over both. + if (uri && prev?.text === uri && prev.range.end.y === start.y && prev.range.end.x + 1 === start.x) prev.range.end = last; + else if (uri && openable(uri)) out.push({ text: uri, range: { start, end: last }, activate: open(uri) }); } x = end; } diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 06d366c5..702da1b8 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -19,6 +19,11 @@ again. links included (Ctrl-click in the Linux app). A long link that wraps over several rows, such as Codex's ChatGPT sign-in link, opens whole from any of its rows; before, it couldn't be clicked at all. +- **Links a program draws as text open with ⌘-click in a box's terminal.** + A sign-in screen's "Authorize with Codex" (fx), for example, showed + underlined but opened nothing: the box's tmux dropped the address on the + way to the app. It now passes it on (tmux 3.4 or later; update the box), + and the whole text is one link from any word of it. - **A home widget's ⋯ menu opens again.** It broke the home screen with "Base UI error #31" instead of showing its Size and Refresh options. - **A Browser tab on a port with nothing listening no longer crashes the diff --git a/internal/box/sessions.go b/internal/box/sessions.go index 719a418c..3f7023c7 100644 --- a/internal/box/sessions.go +++ b/internal/box/sessions.go @@ -153,12 +153,24 @@ const tmuxSocket = "berth" // tmuxConfig is berth's own tmux server's. The status line is off because // the app draws its own chrome around every terminal; focus events pass // through so agents know when their pane is in front. +// +// OSC 8 hyperlinks (text a program links somewhere, like a sign-in screen's +// "Authorize with Codex") reach the app only if tmux knows the terminal +// attached takes them: a client is attached as xterm-256color (Attach), and +// tmux's defaults don't say so for xterm, nor does the app's terminal answer +// the queries tmux would learn it from, so tmux dropped them and the app got +// the link's text with no address. Hyperlinks came in tmux 3.4; the %if +// leaves the line out of an older one (3.0 has no terminal-features option +// at all), and a fixed index keeps it one entry when the file is read again. const tmuxConfig = `set -g remain-on-exit on set -g history-limit 50000 set -g mouse on set -g default-terminal "tmux-256color" set -g status off set -g focus-events on +%if "#{m/r:^(next-|[4-9]|3\.([4-9]|[1-9][0-9])),#{version}}" +set -s terminal-features[90] "xterm*:hyperlinks" +%endif ` func NewSessions(dir string) (*Sessions, error) { diff --git a/internal/box/sessions_test.go b/internal/box/sessions_test.go index 2b63245f..8999a897 100644 --- a/internal/box/sessions_test.go +++ b/internal/box/sessions_test.go @@ -5,6 +5,7 @@ import ( "context" "encoding/base64" "errors" + "fmt" "io" "os" "os/exec" @@ -194,6 +195,42 @@ func TestAttachShowsTheSessionAndCarriesKeystrokes(t *testing.T) { } } +// A program's OSC 8 hyperlink (fx's "Authorize with Codex", say) reaches +// the app attached: tmux sent the text alone, underlined, until berth's +// tmux said its clients take hyperlinks. +func TestAttachPassesOSC8Hyperlinks(t *testing.T) { + out, err := exec.Command("tmux", "-V").Output() + if err != nil { + t.Skip("tmux not installed") + } + var major, minor int + if _, err := fmt.Sscanf(strings.TrimPrefix(strings.TrimSpace(string(out)), "tmux "), "%d.%d", &major, &minor); err == nil && (major < 3 || major == 3 && minor < 4) { + t.Skipf("%s keeps no hyperlinks", strings.TrimSpace(string(out))) + } + s := testSessions(t) + ctx := context.Background() + link := `printf ' Open \033[4m\033]8;id=acme-auth;https://auth.example.com/oauth/authorize?client_id=app_acme\033\\Authorize with Acme\033]8;;\033\\\033[0m\n'; sleep 30` + if _, err := s.Create(ctx, "linked", "", t.TempDir(), link, nil); err != nil { + t.Fatal(err) + } + master, cmd, err := s.Attach(ctx, "linked", 100, 30) + if err != nil { + t.Fatal(err) + } + defer cmd.Process.Kill() + defer master.Close() + var screen syncBuffer + go io.Copy(&screen, master) + want := ";https://auth.example.com/oauth/authorize?client_id=app_acme\x1b\\Authorize with Acme\x1b]8;;\x1b\\" + deadline := time.Now().Add(5 * time.Second) + for !strings.Contains(screen.String(), want) { + if time.Now().After(deadline) { + t.Fatalf("no OSC 8 hyperlink in what the client got: %q", screen.String()) + } + time.Sleep(50 * time.Millisecond) + } +} + // syncBuffer is a bytes.Buffer safe to poll while another goroutine copies // terminal output into it. type syncBuffer struct {