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
42 changes: 42 additions & 0 deletions app/e2e/terminal-links.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
43 changes: 43 additions & 0 deletions app/src/lib/term-links.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]) {
Expand Down
58 changes: 48 additions & 10 deletions app/src/lib/term-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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<string, Uint8Array>();
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;
}

Expand All @@ -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 === "]") {
Expand All @@ -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();
Expand Down Expand Up @@ -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!);
}
}

Expand Down
6 changes: 5 additions & 1 deletion app/src/lib/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
5 changes: 5 additions & 0 deletions docs/changelog.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions internal/box/sessions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
37 changes: 37 additions & 0 deletions internal/box/sessions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"context"
"encoding/base64"
"errors"
"fmt"
"io"
"os"
"os/exec"
Expand Down Expand Up @@ -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 {
Expand Down
Loading