diff --git a/app/e2e/terminal-links.spec.ts b/app/e2e/terminal-links.spec.ts new file mode 100644 index 00000000..754b6dbf --- /dev/null +++ b/app/e2e/terminal-links.spec.ts @@ -0,0 +1,306 @@ +import type { Page } from "@playwright/test"; + +import { expect, mockOnly, test } from "./fixtures"; + +// Links in a terminal open in your browser with ⌘-click, as in Terminal, +// iTerm2 and Ghostty: a web address in the text, and an OSC 8 hyperlink +// (text a program links somewhere). The output comes the way a box's tmux +// draws it, on its alternate screen with mouse reporting on. In the browser +// build openUrl opens a link with window.open, which records it here. + +// The tmux of a box attaching: alternate screen, a scroll region, mouse +// reporting and synchronized output (captured from tmux 3.7, OSC 8 passed +// through with its hyperlinks terminal feature). +const OUTPUT = + "\x1b[?1049h\x1b[H\x1b[2J\x1b[?69h\x1b[1;24r\x1b[?1006h\x1b[?1000h\x1b[?1002h\x1b[?2026h\x1b[H" + + "\x1b[2mOpened\x1b[0m https://example.com/acme/pull/47.\x1b[K\r\n" + + " ⎿ \x1b]8;id=tmux1;https://example.com/acme/docs\x1b\\the acme docs\x1b]8;;\x1b\\ for more\x1b[K\r\n" + + "\x1b[?2026l"; + +async function openShell(page: Page) { + await expect(page.getByRole("heading", { name: "What should your agents work on?" })).toBeVisible(); + await page.keyboard.press("Meta+KeyT"); + const picker = page.getByRole("dialog", { name: "New terminal on a box" }); + await expect(picker.locator("[data-box=gpu]")).toBeVisible(); + await page.keyboard.type("gpu"); + await page.keyboard.press("Enter"); + await expect(picker).toBeHidden(); + await expect.poll(() => screenText(page)).toContain("me@gpu:~$"); +} + +type Term = { + cols: number; + rows: number; + write(d: string): void; + buffer: { active: { length: number; viewportY?: number; getLine(y: number): { translateToString(trim: boolean): string } | undefined } }; + renderer?: { getCanvas(): HTMLCanvasElement; charWidth: number; charHeight: number }; +}; + +function screenText(page: Page) { + return page.evaluate(() => { + const host = document.querySelector("[data-testid=pane][data-pane-kind=terminal] [data-terminal] > div") as (HTMLElement & { __berthTerm?: Term }) | null; + const b = host?.__berthTerm?.buffer.active; + if (!b) return ""; + const lines: string[] = []; + for (let y = 0; y < b.length; y++) lines.push(b.getLine(y)?.translateToString(true) ?? ""); + return lines.join("\n"); + }); +} + +// The middle of the cell `into` characters into `text` on the screen, in +// the page's coordinates. The screen here is the alternate one, with no +// history: a buffer row is a screen row. +function cellOf(page: Page, text: string, into = 2) { + return page.evaluate( + ({ text, into }) => { + const host = document.querySelector("[data-testid=pane][data-pane-kind=terminal] [data-terminal] > div") as HTMLElement & { __berthTerm: Term }; + const t = host.__berthTerm; + const b = t.buffer.active; + for (let y = 0; y < b.length; y++) { + const col = b.getLine(y)?.translateToString(true).indexOf(text) ?? -1; + if (col < 0) continue; + let rect: DOMRect, w: number, h: number; + if (t.renderer) { + rect = t.renderer.getCanvas().getBoundingClientRect(); + w = t.renderer.charWidth; + h = t.renderer.charHeight; + } else { + rect = host.querySelector(".xterm-screen")!.getBoundingClientRect(); + w = rect.width / t.cols; + h = rect.height / t.rows; + } + return { x: rect.left + (col + into + 0.5) * w, y: rect.top + (y - (b.viewportY ?? 0) + 0.5) * h }; + } + throw new Error(`${text} is not on the screen`); + }, + { text, into }, + ); +} + +async function cmdClick(page: Page, at: { x: number; y: number }) { + await page.keyboard.down("Meta"); + await page.mouse.click(at.x, at.y); + await page.keyboard.up("Meta"); +} + +const opened = (page: Page) => page.evaluate(() => (window as unknown as { __opened: string[] }).__opened); + +async function recordOpens(page: Page) { + await page.addInitScript(() => { + const w = window as unknown as { __opened: string[] }; + w.__opened = []; + window.open = (url?: string | URL) => { + w.__opened.push(String(url)); + return null; + }; + }); +} + +async function draw(page: Page) { + await page.evaluate((out) => (document.querySelector("[data-testid=pane][data-pane-kind=terminal] [data-terminal] > div") as HTMLElement & { __berthTerm: Term }).__berthTerm.write(out), OUTPUT); + await expect.poll(() => screenText(page)).toContain("the acme docs for more"); +} + +test("⌘-click opens a web address and an OSC 8 link in a terminal; resting on one says so", async ({ app }) => { + mockOnly("writes to the mock's terminal"); + const page = app.page; + await recordOpens(page); + await app.open(); + await openShell(page); + await draw(page); + const pane = page.locator("[data-testid=pane][data-pane-kind=terminal]:visible"); + const canvas = pane.locator("canvas").first(); + const hint = pane.getByTestId("terminal-link-hint"); + + // Resting on an address says how to open it, with no pointing hand, and + // a plain click is the terminal's (focus, selection): nothing opens. + const url = await cellOf(page, "https://example.com/acme/pull/47"); + await page.mouse.move(url.x, url.y); + await expect(hint).toHaveText("⌘-click to open"); + await expect(canvas).toHaveCSS("cursor", "text"); + await page.mouse.click(url.x, url.y); + + // ⌘ held: the hand, and no hint. + await page.keyboard.down("Meta"); + await page.mouse.move(url.x + 1, url.y); + await expect(canvas).toHaveCSS("cursor", "pointer"); + await expect(hint).toBeHidden(); + await page.mouse.click(url.x + 1, url.y); + await page.keyboard.up("Meta"); + // Without the full stop after it. + await expect.poll(() => opened(page)).toEqual(["https://example.com/acme/pull/47"]); + + // An OSC 8 link opens its own address, not its text. + const link = await cellOf(page, "the acme docs", 4); + await page.mouse.move(link.x, link.y); + await expect(hint).toBeVisible(); + await cmdClick(page, link); + await expect.poll(() => opened(page)).toEqual(["https://example.com/acme/pull/47", "https://example.com/acme/docs"]); + + // Off the links, the hint goes and ⌘-click opens nothing. + const plain = await cellOf(page, "for more", 1); + await page.mouse.move(plain.x, plain.y); + await expect(hint).toBeHidden(); + await cmdClick(page, plain); + await page.mouse.click(url.x, url.y); + await expect(hint).toBeVisible(); + expect(await opened(page)).toHaveLength(2); +}); + +const HOST = "[data-testid=pane][data-pane-kind=terminal] [data-terminal] > div"; +type Ghostty = Term & { renderer: NonNullable & { hoveredLinkRange: unknown } }; + +// The middle of the cell at column x of buffer row y, in the page's +// coordinates (ghostty-web, with no history above the screen). +function cellAt(page: Page, x: number, y: number) { + return page.evaluate( + ({ x, y, host }) => { + const t = (document.querySelector(host) as HTMLElement & { __berthTerm: Ghostty }).__berthTerm; + const rect = t.renderer.getCanvas().getBoundingClientRect(); + return { x: rect.left + (x + 0.5) * t.renderer.charWidth, y: rect.top + (y + 0.5) * t.renderer.charHeight }; + }, + { x, y, host: HOST }, + ); +} + +const cols = (page: Page) => page.evaluate((host) => (document.querySelector(host) as HTMLElement & { __berthTerm: Term }).__berthTerm.cols, HOST); + +const write = (page: Page, out: string) => page.evaluate(({ out, host }) => (document.querySelector(host) as HTMLElement & { __berthTerm: Term }).__berthTerm.write(out), { out, host: HOST }); + +// The cells the link under the pointer covers, as ghostty-web underlines +// them. +const hovered = (page: Page) => page.evaluate((host) => (document.querySelector(host) as HTMLElement & { __berthTerm: Ghostty }).__berthTerm.renderer.hoveredLinkRange, HOST); + +// A ChatGPT sign-in address as Codex prints it, long enough to take three +// rows or more of a pane `cols` wide after two spaces, ending short of a +// row's end. +function signIn(cols: number) { + let url = + "https://auth.openai.com/oauth/authorize?response_type=code&client_id=app_acme0123456789&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback" + + "&scope=openid%20profile%20email%20offline_access&code_challenge=Zm9vYmFyYmF6cXV4cXV1eGZvb2JhcmJhenF1eHF1dXg&code_challenge_method=S256" + + "&id_token_add_organizations=true&codex_cli_simplified_flow=true&state=c3RhdGVzdGF0ZXN0YXRlc3RhdGVzdGF0ZXN0YXRlcw"; + while (2 + url.length < cols * 2 + 10 || (2 + url.length) % cols === 0) url += "Q"; + return url; +} + +const SCREEN = "\x1b[?1049h\x1b[H\x1b[2J"; +const HEADING = "Sign in with ChatGPT. If your browser did not open, go to:"; + +// The address over its rows, as the terminal wraps it ("soft") or as tmux +// redraws it, each row put with the cursor so nothing is marked wrapped. +function signInScreen(url: string, cols: number, how: "soft" | "tmux") { + const line = " " + url; + const rows = Math.ceil(line.length / cols); + if (how === "soft") return { rows, out: `${SCREEN}${HEADING}\r\n${line}\r\n\r\nWaiting for sign-in` }; + let out = `${SCREEN}\x1b[?2026h\x1b[1;1H${HEADING}`; + for (let i = 0; i < rows; i++) out += `\x1b[${i + 2};1H${line.slice(i * cols, (i + 1) * cols)}`; + out += `\x1b[${rows + 3};1HWaiting for sign-in\x1b[?2026l`; + return { rows, out }; +} + +for (const how of ["soft", "tmux"] as const) { + test(`a sign-in address over several rows opens whole from any of them (${how === "soft" ? "wrapped by the terminal" : "redrawn by tmux"})`, async ({ app }) => { + mockOnly("writes to the mock's terminal"); + const page = app.page; + await recordOpens(page); + await app.open(); + await openShell(page); + const n = await cols(page); + const url = signIn(n); + const { rows, out } = signInScreen(url, n, how); + expect(rows).toBeGreaterThanOrEqual(3); + await write(page, out); + await expect.poll(() => screenText(page)).toContain("Waiting for sign-in"); + const pane = page.locator("[data-testid=pane][data-pane-kind=terminal]:visible"); + const hint = pane.getByTestId("terminal-link-hint"); + + // Resting on its second row: the whole address is the link, and the + // hint says how to open it. + const second = await cellAt(page, Math.floor(n / 2), 2); + await page.mouse.move(second.x, second.y); + await expect(hint).toHaveText("⌘-click to open"); + await expect.poll(() => hovered(page)).toEqual({ startX: 2, startY: 1, endX: (url.length + 1) % n, endY: rows }); + // A plain click opens nothing. + await page.mouse.click(second.x, second.y); + + // ⌘-click on its third row opens all of it. + const third = await cellAt(page, 1, 3); + await page.mouse.move(third.x, third.y); + await cmdClick(page, third); + await expect.poll(() => opened(page)).toEqual([url]); + + // The rows around it are not part of it. + await cmdClick(page, await cellAt(page, 3, 0)); + await cmdClick(page, await cellAt(page, 3, rows + 2)); + await cmdClick(page, await cellAt(page, 1, 3)); + await expect.poll(() => opened(page)).toEqual([url, url]); + }); +} + +test("a device sign-in address wrapped in a narrow pane opens from both rows", async ({ app }) => { + mockOnly("writes to the mock's terminal"); + const page = app.page; + await page.setViewportSize({ width: 760, height: 700 }); + await recordOpens(page); + await app.open(); + await openShell(page); + const n = await cols(page); + const url = "https://vercel.com/oauth/device?user_code=LNWD-GLJB"; + // The address crosses the right edge, 17 characters on the next row. + const at = n - (url.length - 17); + await write(page, `${SCREEN}${" ".repeat(at - 6)}Visit ${url} to sign in`); + await expect.poll(() => screenText(page)).toContain("GLJB to sign in"); + expect(await screenText(page)).not.toContain(url); + + await cmdClick(page, await cellAt(page, 3, 1)); + await expect.poll(() => opened(page)).toEqual([url]); + await cmdClick(page, await cellAt(page, at + 2, 0)); + await expect.poll(() => opened(page)).toEqual([url, url]); +}); + +test("an OSC 8 link whose text wraps opens its address from every row", async ({ app }) => { + mockOnly("writes to the mock's terminal"); + const page = app.page; + await recordOpens(page); + await app.open(); + await openShell(page); + const n = await cols(page); + const uri = "https://example.com/acme/sign-in"; + const label = "Sign in to acme in your browser ".repeat(Math.ceil((n * 2) / 32)).trim(); + // As a program prints it, and as tmux redraws it: the hyperlink again on + // each row it puts with the cursor. + await write(page, `${SCREEN}\x1b]8;;${uri}\x1b\\${label}\x1b]8;;\x1b\\\r\n`); + await expect.poll(() => screenText(page)).toContain("Sign in to acme"); + await cmdClick(page, await cellAt(page, 4, 1)); + await expect.poll(() => opened(page)).toEqual([uri]); + await cmdClick(page, await cellAt(page, 4, 0)); + await expect.poll(() => opened(page)).toEqual([uri, uri]); + + const other = "https://example.com/acme/sign-in?via=tmux"; + const text = "Open the acme sign-in page here ".repeat(Math.ceil((n * 2) / 32)).trim(); + let tmux = SCREEN; + for (let i = 0; i * n < text.length; i++) tmux += `\x1b[${i + 1};1H\x1b]8;id=tmux2;${other}\x1b\\${text.slice(i * n, (i + 1) * n)}`; + await write(page, `${tmux}\x1b]8;;\x1b\\`); + await expect.poll(() => screenText(page)).toContain("Open the acme"); + await cmdClick(page, await cellAt(page, 4, 1)); + await expect.poll(() => opened(page)).toEqual([uri, uri, other]); + await cmdClick(page, await cellAt(page, 4, 0)); + await expect.poll(() => opened(page)).toEqual([uri, uri, other, other]); +}); + +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; + await recordOpens(page); + await app.open({ prefs: { terminal: { renderer: "xterm" } } }); + await openShell(page); + await draw(page); + const url = await cellOf(page, "https://example.com/acme/pull/47"); + await page.mouse.move(url.x, url.y); + await cmdClick(page, url); + await expect.poll(() => opened(page)).toEqual(["https://example.com/acme/pull/47"]); + const link = await cellOf(page, "the acme docs", 4); + await page.mouse.move(link.x, link.y); + await cmdClick(page, link); + await expect.poll(() => opened(page)).toEqual(["https://example.com/acme/pull/47", "https://example.com/acme/docs"]); +}); diff --git a/app/package.json b/app/package.json index 4e02ec8e..9d6491d0 100644 --- a/app/package.json +++ b/app/package.json @@ -21,7 +21,7 @@ "perf:guard": "node perf/guard.mjs", "perf:soak": "node perf/soak.mjs", "perf:terminals": "node perf/terminals.mjs", - "test": "node --experimental-strip-types --test src/lib/layout.test.ts src/lib/groups.test.ts src/lib/screen.test.ts src/lib/removing.test.ts src/lib/conversation-store.test.ts src/lib/wheel.test.ts src/lib/browser-url.test.ts src/lib/terminal.test.ts src/lib/predict.test.ts src/lib/browser-sandbox.test.ts src/lib/requirements.test.ts src/lib/diagnostics.test.ts src/lib/preview-frames.test.ts src/lib/home-layout.test.ts src/lib/file-match.test.ts src/lib/file-marks.test.ts src/lib/syntax-colors.test.ts src/lib/draft-text.test.ts src/lib/derive.test.ts src/lib/tree-model.test.ts src/lib/team-ref.test.ts src/lib/agent-messages.test.ts src/lib/page-loads.test.ts src/lib/devtools-model.test.ts src/lib/sidebar-width.test.ts src/lib/local-titles.test.ts src/lib/net.test.ts src/lib/share.test.ts src/lib/palette-shortcuts.test.ts src/lib/art/chart-spec.test.ts src/lib/art/gist.test.ts src/lib/art/vdiff.test.ts src/lib/term-output.test.ts src/lib/poll.test.ts src/lib/whats-new.test.ts src/lib/platform.test.ts src/lib/processes.test.ts src/lib/link.test.ts src/lib/worktree-tree.test.ts src/lib/review-link.test.ts src/lib/login-url.test.ts src/lib/team-suggest-model.test.ts src/lib/last-of.test.ts src/lib/css-has.test.ts ../site/review/review.test.mjs" + "test": "node --experimental-strip-types --test src/lib/layout.test.ts src/lib/groups.test.ts src/lib/screen.test.ts src/lib/removing.test.ts src/lib/conversation-store.test.ts src/lib/wheel.test.ts src/lib/browser-url.test.ts src/lib/terminal.test.ts src/lib/predict.test.ts src/lib/browser-sandbox.test.ts src/lib/requirements.test.ts src/lib/diagnostics.test.ts src/lib/preview-frames.test.ts src/lib/home-layout.test.ts src/lib/file-match.test.ts src/lib/file-marks.test.ts src/lib/syntax-colors.test.ts src/lib/draft-text.test.ts src/lib/derive.test.ts src/lib/tree-model.test.ts src/lib/team-ref.test.ts src/lib/agent-messages.test.ts src/lib/page-loads.test.ts src/lib/devtools-model.test.ts src/lib/sidebar-width.test.ts src/lib/local-titles.test.ts src/lib/net.test.ts src/lib/share.test.ts src/lib/palette-shortcuts.test.ts src/lib/art/chart-spec.test.ts src/lib/art/gist.test.ts src/lib/art/vdiff.test.ts src/lib/term-output.test.ts src/lib/term-links.test.ts src/lib/poll.test.ts src/lib/whats-new.test.ts src/lib/platform.test.ts src/lib/processes.test.ts src/lib/link.test.ts src/lib/worktree-tree.test.ts src/lib/review-link.test.ts src/lib/login-url.test.ts src/lib/team-suggest-model.test.ts src/lib/last-of.test.ts src/lib/css-has.test.ts ../site/review/review.test.mjs" }, "dependencies": { "@base-ui/react": "^1.8.0", diff --git a/app/src/components/workspace/terminal-view.tsx b/app/src/components/workspace/terminal-view.tsx index fd25b60f..038f0837 100644 --- a/app/src/components/workspace/terminal-view.tsx +++ b/app/src/components/workspace/terminal-view.tsx @@ -9,7 +9,7 @@ import { useActiveTheme } from "@/hooks/use-theme"; import { ApiError, type TerminalConnection } from "@/lib/api"; import { attachable, localPaths, named, onThisComputer, pastedFiles, shrinkImage, uploadAttachment, uploadLocalFile } from "@/lib/attachments"; import { copyText } from "@/lib/clipboard"; -import { IS_LINUX } from "@/lib/platform"; +import { IS_LINUX, platformKeys } from "@/lib/platform"; import { usePrefs } from "@/lib/prefs"; import { tryNow } from "@/lib/reconnect"; import { useStore } from "@/lib/store"; @@ -53,6 +53,9 @@ interface Props { // session keeps running on the box, and attaching again redraws it. export function TerminalView({ box, session, agent, command, wsKey, tab, pane, visible, focused, predict = false, onFocus, onClose }: Props) { const host = useRef(null); + const root = useRef(null); + // Where the pointer rests on a link, to say ⌘-click opens it (lib/terminal). + const [linkHint, setLinkHint] = useState<{ x: number; y: number; left: boolean; up: boolean }>(); const [term, setTerm] = useState(); const predictor = useRef(null); const conn = useRef(null); @@ -140,6 +143,10 @@ export function TerminalView({ box, session, agent, command, wsKey, tab, pane, v if (!typedAt.current || typedAt.current <= heardAt.current) typedAt.current = Date.now(); }); t.onResize(({ cols, rows }) => conn.current?.resize(cols, rows)); + t.onLinkHint((at) => { + const r = root.current?.getBoundingClientRect(); + setLinkHint(at && r ? { x: at.x - r.left, y: at.y - r.top, left: at.x - r.left > r.width - 160, up: at.y - r.top > r.height - 48 } : undefined); + }); // ⌘-click a file path to open it at its line in your editor. t.registerLinkFinder((line) => { const dir = dirRef.current; @@ -168,6 +175,7 @@ export function TerminalView({ box, session, agent, command, wsKey, tab, pane, v t?.dispose(); mount.remove(); setTerm(undefined); + setLinkHint(undefined); }; // xterm takes a new theme in place (below). // eslint-disable-next-line react-hooks/exhaustive-deps @@ -434,7 +442,7 @@ export function TerminalView({ box, session, agent, command, wsKey, tab, pane, v const blocked = state === "offline" || state === "ended"; return ( -
+
{state === "offline" && setRetry((n) => n + 1)} />} {stoppedService && state !== "ended" && state !== "offline" && } @@ -445,6 +453,18 @@ export function TerminalView({ box, session, agent, command, wsKey, tab, pane, v {`Waiting for ${box} to answer… what you type may not arrive`}
)} + {linkHint && ( +
+ {platformKeys("⌘-click to open")} +
+ )} {(state === "connecting" || state === "reconnecting") && (
diff --git a/app/src/lib/term-links.test.ts b/app/src/lib/term-links.test.ts new file mode 100644 index 00000000..152da138 --- /dev/null +++ b/app/src/lib/term-links.test.ts @@ -0,0 +1,146 @@ +// node --experimental-strip-types --test src/lib/term-links.test.ts (pnpm test) +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { continues, findUrls, HyperlinkTracker, joinRows, linksAround, openable, type Rows } from "./term-links.ts"; + +const urls = (line: string) => findUrls(line).map((m) => [m.url, line.slice(m.start, m.end)]); + +test("addresses in a line are found where they are, without the sentence around them", () => { + assert.deepEqual(urls("see https://example.com/acme for more"), [["https://example.com/acme", "https://example.com/acme"]]); + assert.deepEqual(findUrls("see https://example.com/acme for more")[0], { start: 4, end: 28, url: "https://example.com/acme" }); + assert.deepEqual(urls("Opened https://github.com/acme/web/pull/47."), [["https://github.com/acme/web/pull/47", "https://github.com/acme/web/pull/47"]]); + assert.deepEqual(urls("(see http://localhost:3000/api?q=1&x=2)"), [["http://localhost:3000/api?q=1&x=2", "http://localhost:3000/api?q=1&x=2"]]); + assert.deepEqual(urls("https://en.wikipedia.org/wiki/Tmux_(software), then"), [["https://en.wikipedia.org/wiki/Tmux_(software)", "https://en.wikipedia.org/wiki/Tmux_(software)"]]); + assert.deepEqual(urls('"https://example.com/a" and '), [ + ["https://example.com/a", "https://example.com/a"], + ["https://example.com/b", "https://example.com/b"], + ]); + assert.deepEqual(urls("mail mailto:dev@example.com now"), [["mailto:dev@example.com", "mailto:dev@example.com"]]); + assert.deepEqual(urls("no links: https:// file:///etc/hosts ssh://box"), []); +}); + +test("only web and mail addresses open from a terminal", () => { + assert.ok(openable("https://example.com")); + assert.ok(openable("http://localhost:3000")); + assert.ok(openable("mailto:dev@example.com")); + assert.ok(!openable("file:///etc/hosts")); + assert.ok(!openable("ssh://box")); + assert.ok(!openable("javascript:alert(1)")); + assert.ok(!openable("https://")); +}); + +test("an OSC 8 hyperlink's text gives its address back", () => { + const h = new HyperlinkTracker(); + h.feed("and \x1b]8;;https://example.com/osc\x1b\\the label\x1b]8;;\x1b\\ here\r\n"); + assert.equal(h.uriFor("the label"), "https://example.com/osc"); + assert.equal(h.uriFor(" the label "), "https://example.com/osc"); + // A piece of it, as on a row it wrapped from. + assert.equal(h.uriFor("label"), "https://example.com/osc"); + assert.equal(h.uriFor("here"), undefined); + // BEL ends it as well, with parameters (tmux sends an id), and colours + // inside the text are not part of it. + h.feed("\x1b]8;id=tmux1;https://example.com/pr\x07PR \x1b[1m#47\x1b[0m\x1b]8;;\x07"); + assert.equal(h.uriFor("PR #47"), "https://example.com/pr"); + // The latest address for the same text wins. + h.feed("\x1b]8;;https://example.com/osc2\x1b\\the label\x1b]8;;\x1b\\"); + assert.equal(h.uriFor("the label"), "https://example.com/osc2"); +}); + +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]) { + const s = new HyperlinkTracker(); + s.feed(whole.slice(0, cut)); + s.feed(whole.slice(cut)); + assert.equal(s.uriFor("split ✓ link"), "https://example.com/split", `text cut at ${cut}`); + const bytes = new TextEncoder().encode(whole); + for (const at of [cut, bytes.length - cut]) { + const b = new HyperlinkTracker(); + b.feed(bytes.slice(0, at)); + b.feed(bytes.slice(at)); + assert.equal(b.uriFor("split ✓ link"), "https://example.com/split", `bytes cut at ${at}`); + } + } +}); + +test("output without hyperlinks leaves nothing behind", () => { + const h = new HyperlinkTracker(); + h.feed("\x1b[1mbold\x1b[0m \x1b]0;title\x07 https://example.com\r\n"); + h.feed(new TextEncoder().encode("plain \x1b[32mgreen\x1b[0m")); + assert.equal(h.uriFor("bold"), undefined); + assert.equal(h.uriFor("https://example.com"), undefined); +}); + +// A screen of `cols` columns, each row padded to the full width as the +// terminal's cells are; wrapped lists the rows the terminal soft-wrapped. +function screen(cols: number, lines: string[], wrapped: number[] = []): Rows { + const rows = lines.map((l) => l.padEnd(cols, " ")); + return { text: (y) => rows[y], wrapped: (y) => wrapped.includes(y) }; +} + +// A line cut into rows of `cols`, as a terminal wraps it. +function cut(line: string, cols: number): string[] { + const out: string[] = []; + for (let i = 0; i < line.length; i += cols) out.push(line.slice(i, i + cols)); + return out; +} + +const SIGN_IN = + "https://auth.openai.com/oauth/authorize?response_type=code&client_id=app_acme0123456789&redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback" + + "&scope=openid%20profile%20email%20offline_access&code_challenge=Zm9vYmFyYmF6cXV4cXV1eGZvb2JhcmJhenF1eHF1dXg&code_challenge_method=S256" + + "&id_token_add_organizations=true&codex_cli_simplified_flow=true&state=c3RhdGVzdGF0ZXN0YXRlc3RhdGVzdGF0ZXN0YXRlcw"; + +const urlsAt = (rows: Rows, y: number) => linksAround(rows, y, findUrls).map((l) => ({ url: l.match.url, ...l.span })); + +test("an address the terminal wrapped is found whole from any of its rows", () => { + const lines = ["If your browser did not open, go to:", ...cut(SIGN_IN, 80), "", "Waiting for sign-in"]; + const last = lines.length - 3; + assert.ok(last >= 4, "the address takes four rows or more"); + const rows = screen(80, lines, Array.from({ length: last - 1 }, (_, i) => i + 1)); + for (let y = 1; y <= last; y++) { + assert.deepEqual(urlsAt(rows, y), [{ url: SIGN_IN, start: { x: 0, y: 1 }, end: { x: (SIGN_IN.length - 1) % 80, y: last } }], `from row ${y}`); + } + assert.deepEqual(urlsAt(rows, 0), []); + assert.deepEqual(urlsAt(rows, last + 2), []); +}); + +test("an address tmux redrew row by row, with no wrap marked, is found whole", () => { + // tmux puts each row with the cursor: the outer terminal never wraps, + // so only the text says the rows go on. + const lines = [" Sign in at " + SIGN_IN.slice(0, 27), ...cut(SIGN_IN.slice(27), 40), "", "> "]; + const rows = screen(40, lines); + const last = lines.length - 3; + for (let y = 0; y <= last; y++) { + assert.deepEqual(urlsAt(rows, y), [{ url: SIGN_IN, start: { x: 13, y: 0 }, end: { x: (SIGN_IN.length - 27 - 1) % 40, y: last } }], `from row ${y}`); + } + // The device code link from a CLI's sign-in, at a narrow width. + const fx = screen(24, ["Open https://vercel.com/", "oauth/device?user_code=L", "NWD-GLJB to sign in"]); + for (const y of [0, 1, 2]) assert.deepEqual(urlsAt(fx, y).map((l) => l.url), ["https://vercel.com/oauth/device?user_code=LNWD-GLJB"]); +}); + +test("rows go on only when wrapped, or full to the last column into text that picks up without a space", () => { + assert.ok(continues("short", true, "anything")); + assert.ok(continues("https://example.com/a", false, "bc/d")); + // The next row starts with a space, or the row doesn't reach its end. + assert.ok(!continues("https://example.com/a", false, " next")); + assert.ok(!continues("https://example.com/a ", false, "next")); + // A box's border is not part of an address. + assert.ok(!continues("see https://example.com/a", false, "│ next")); + assert.ok(!continues("│ https://example.com/a │", false, "more")); + + // An address that ends at the last column, then an unrelated line + // starting with a space, then one under a row that stops short. + const ends = "Opened https://example.com/acme/pull/47"; + const rows = screen(ends.length, [ends, " next line", "short", "https://example.com/b"]); + assert.deepEqual(urlsAt(rows, 0), [{ url: "https://example.com/acme/pull/47", start: { x: 7, y: 0 }, end: { x: ends.length - 1, y: 0 } }]); + assert.equal(joinRows(rows, 1)?.texts.length, 1); + assert.deepEqual(urlsAt(rows, 3), [{ url: "https://example.com/b", start: { x: 0, y: 3 }, end: { x: 20, y: 3 } }]); +}); + +test("a run of rows is followed only so far", () => { + const rows = screen(10, Array.from({ length: 200 }, () => "a".repeat(10))); + const line = joinRows(rows, 100)!; + assert.ok(line.texts.length <= 32); + assert.ok(line.first <= 100 && line.first + line.texts.length > 100); +}); diff --git a/app/src/lib/term-links.ts b/app/src/lib/term-links.ts new file mode 100644 index 00000000..1608b897 --- /dev/null +++ b/app/src/lib/term-links.ts @@ -0,0 +1,300 @@ +// Links in a terminal's text (lib/terminal): web addresses found in a line, +// and OSC 8 hyperlinks (a program's "text that links somewhere"), whose +// addresses ghostty-web 0.4 keeps to itself, so they are read from the +// output here on the way in. + +export interface UrlMatch { + start: number; + // Exclusive. + end: number; + url: string; +} + +// Web and mail addresses only: what the app can hand to the system browser +// (lib/open-url, and the shell plugin's scope in the app). +const URL_RE = /\b(?:https?:\/\/|mailto:)[^\s<>"'`]+/gi; +const TRAILING = /[.,;:!?'"*]+$/; +const CLOSERS: Record = { ")": "(", "]": "[", "}": "{", ">": "<" }; + +// findUrls finds the addresses in a line of a terminal's text. Punctuation +// that ends a sentence is left out, and so is a closing bracket the address +// doesn't open: "(see https://example.com/a)" links https://example.com/a, +// https://en.wikipedia.org/wiki/Tmux_(software) keeps its own. +export function findUrls(line: string): UrlMatch[] { + const out: UrlMatch[] = []; + URL_RE.lastIndex = 0; + for (let m = URL_RE.exec(line); m; m = URL_RE.exec(line)) { + let url = m[0]; + for (;;) { + const before = url; + url = url.replace(TRAILING, ""); + const last = url.at(-1); + if (last && CLOSERS[last] && count(url, CLOSERS[last]) < count(url, last)) url = url.slice(0, -1); + if (url === before) break; + } + if (/^(?:https?:\/\/|mailto:)./i.test(url)) out.push({ start: m.index, end: m.index + url.length, url }); + } + return out; +} + +// A terminal's rows as lib/terminal reads them, for links that run on from +// one row to the next: text has one character per cell (so a place in it +// is a column), and wrapped says the terminal soft-wrapped the row onto +// the next one, where it knows. +export interface Rows { + text(y: number): string | undefined; + wrapped(y: number): boolean; +} + +// A link's cells, first to last (inclusive), over one row or several. +export interface Span { + start: { x: number; y: number }; + end: { x: number; y: number }; +} + +// How far a line is followed across rows: a long sign-in address is a few +// hundred characters, a few rows to a dozen in a narrow pane. +const MAX_JOIN_ROWS = 32; +const MAX_JOIN_CHARS = 4096; + +// What may be either side of the break in an address that runs on to the +// next row: the letters, digits and punctuation addresses and paths are +// made of. A box's border (│), a space or a quote is not. +const RUNS_ON = /^[\p{L}\p{N}\-._~:/?#[\]@!$&()*+,;=%]$/u; + +// continues says whether a row's text goes on in the next row. Either the +// terminal wrapped it there, or, as when tmux redraws a long line row by +// row with the cursor (the terminal never wraps it, so it isn't marked), +// the row is full to its last column and the next one picks up without a +// space: "…state=ab" then "cd…" is one address, "…/pull/47" then " next" +// or "…/47 " then "next" is not. +export function continues(row: string, wrapped: boolean, next: string): boolean { + if (wrapped) return true; + return RUNS_ON.test(row.at(-1) ?? "") && RUNS_ON.test(next[0] ?? ""); +} + +// joinRows is the line row y is part of: the rows before it that run on +// into it, it, and the rows it runs on into, as far as the limits go. +export function joinRows(rows: Rows, y: number): { first: number; texts: string[] } | undefined { + const here = rows.text(y); + if (here === undefined) return undefined; + const texts = [here]; + let size = here.length; + let first = y; + while (texts.length < MAX_JOIN_ROWS && first > 0) { + const prev = rows.text(first - 1); + if (prev === undefined || size + prev.length > MAX_JOIN_CHARS || !continues(prev, rows.wrapped(first - 1), texts[0])) break; + texts.unshift(prev); + size += prev.length; + first--; + } + for (let last = y; texts.length < MAX_JOIN_ROWS; last++) { + const next = rows.text(last + 1); + if (next === undefined || size + next.length > MAX_JOIN_CHARS || !continues(texts[texts.length - 1], rows.wrapped(last), next)) break; + texts.push(next); + size += next.length; + } + return { first, texts }; +} + +// spanOf places start..end (end exclusive) of a joined line back on its +// rows. +export function spanOf(first: number, texts: string[], start: number, end: number): Span { + const at = (i: number) => { + let y = first; + for (const t of texts) { + if (i < t.length || y === first + texts.length - 1) return { x: i, y }; + i -= t.length; + y++; + } + return { x: i, y }; + }; + return { start: at(start), end: at(Math.max(start, end - 1)) }; +} + +// linksAround finds links in the line row y is part of, joined across the +// rows it runs on over, and gives the ones with a cell on row y, each with +// its place on the rows. find gets the joined text, as one line. +export function linksAround(rows: Rows, y: number, find: (line: string) => M[]): { match: M; text: string; span: Span }[] { + const line = joinRows(rows, y); + if (!line) return []; + const joined = line.texts.join(""); + const out: { match: M; text: string; span: Span }[] = []; + for (const m of find(joined)) { + if (m.end <= m.start) continue; + const span = spanOf(line.first, line.texts, m.start, m.end); + if (span.start.y <= y && span.end.y >= y) out.push({ match: m, text: joined.slice(m.start, m.end), span }); + } + return out; +} + +function count(s: string, c: string): number { + let n = 0; + for (const x of s) if (x === c) n++; + return n; +} + +// Only these open from a terminal: a file:// or ssh:// link would go +// nowhere useful, or somewhere surprising. +export const openable = (uri: string): boolean => /^(?:https?:\/\/[^\s]|mailto:[^\s])/i.test(uri); + +const MAX_OSC = 4096; +const MAX_LABEL = 1024; +const MAX_LINKS = 500; + +// HyperlinkTracker reads a terminal's output for OSC 8 hyperlinks +// (ESC ] 8 ; params ; URI ST, the text, then ESC ] 8 ; ; ST) and remembers +// each one's text and address, the most recent few hundred. A cell on the +// screen only says that it is part of some hyperlink; its text is looked up +// here (uriFor). Output with no OSC 8 in it costs a scan for ESC ] 8 only. +export class HyperlinkTracker { + private mode: "text" | "esc" | "escArg" | "csi" | "osc" | "oscEsc" = "text"; + private osc = ""; + private uri: string | null = null; + private label = ""; + private carry = ""; + private bytesCarry: Uint8Array | null = null; + private decoder: TextDecoder | null = null; + private readonly links = new Map(); + + // feed takes output as the terminal gets it, in pieces cut anywhere. + feed(data: string | Uint8Array) { + if (typeof data === "string") { + const s = this.carry + data; + this.carry = ""; + if (this.idle() && !s.includes("\x1b]8;")) { + this.carry = partial(s); + return; + } + this.scan(s); + return; + } + let b = data; + if (this.bytesCarry) { + const joined = new Uint8Array(this.bytesCarry.length + b.length); + joined.set(this.bytesCarry); + joined.set(b, this.bytesCarry.length); + b = joined; + this.bytesCarry = null; + } + if (this.idle() && !hasOsc8(b)) { + this.decoder = null; + const at = partialBytes(b); + if (at >= 0) this.bytesCarry = b.slice(at); + return; + } + this.decoder ??= new TextDecoder(); + 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(text: string): string | undefined { + const t = text.trim(); + if (!t) return undefined; + const exact = this.links.get(t); + if (exact) return exact; + 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; + } + + reset() { + this.mode = "text"; + this.osc = ""; + this.uri = null; + this.label = ""; + this.carry = ""; + this.bytesCarry = null; + this.decoder = null; + } + + private idle() { + return this.mode === "text" && this.uri === null; + } + + private scan(s: string) { + for (let i = 0; i < s.length; i++) { + const c = s[i]; + 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; + break; + case "esc": + if (c === "]") { + this.mode = "osc"; + this.osc = ""; + } else if (c === "[") this.mode = "csi"; + else if ("()*+-./#%".includes(c)) this.mode = "escArg"; + else this.mode = "text"; + break; + case "escArg": + this.mode = "text"; + break; + case "csi": + if (c >= "@" && c <= "~") this.mode = "text"; + break; + case "osc": + if (c === "\x07") this.endOsc(); + else if (c === "\x1b") this.mode = "oscEsc"; + else if (this.osc.length < MAX_OSC) this.osc += c; + break; + case "oscEsc": + this.endOsc(); + // ESC \ ends it; ESC and anything else starts the next sequence. + if (c !== "\\") { + this.mode = "esc"; + i--; + } + break; + } + } + } + + private endOsc() { + this.mode = "text"; + const osc = this.osc; + this.osc = ""; + if (!osc.startsWith("8;")) return; + const params = osc.indexOf(";", 2); + if (params < 0) return; + const uri = osc.slice(params + 1); + this.close(); + if (uri) this.uri = uri; + } + + 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.uri = null; + this.label = ""; + } +} + +// What may be the start of ESC ] 8 ; at the end of a piece of output. +function partial(s: string): string { + const at = s.lastIndexOf("\x1b", s.length - 1); + return at >= 0 && at >= s.length - 3 && "\x1b]8;".startsWith(s.slice(at)) ? s.slice(at) : ""; +} + +function partialBytes(b: Uint8Array): number { + for (let at = Math.max(0, b.length - 3); at < b.length; at++) { + if (b[at] !== 0x1b) continue; + const rest = b.length - at; + if ((rest < 2 || b[at + 1] === 0x5d) && (rest < 3 || b[at + 2] === 0x38)) return at; + } + return -1; +} + +function hasOsc8(b: Uint8Array): boolean { + for (let i = b.indexOf(0x1b); i >= 0 && i < b.length - 3; i = b.indexOf(0x1b, i + 1)) { + if (b[i + 1] === 0x5d && b[i + 2] === 0x38 && b[i + 3] === 0x3b) return true; + } + return false; +} diff --git a/app/src/lib/terminal.ts b/app/src/lib/terminal.ts index a0b0f93b..4766deea 100644 --- a/app/src/lib/terminal.ts +++ b/app/src/lib/terminal.ts @@ -1,5 +1,7 @@ import type { TerminalColors } from "@/lib/api"; +import { modKey } from "./platform.ts"; import type { PredictMode, Screen } from "./predict.ts"; +import { findUrls, HyperlinkTracker, linksAround, openable, type Rows } from "./term-links.ts"; import { HIDDEN_FLUSH_MS, OutputGate } from "./term-output.ts"; // One small interface over the terminal emulator, so the renderer can be @@ -61,6 +63,11 @@ export interface TermHandle { // Adds links found in each line's text, such as file paths. find gets a // line and returns ranges in it (end exclusive). registerLinkFinder(find: LinkFinder): void; + // fn hears where the pointer rests on a link that ⌘-click (Ctrl-click + // off a Mac) would open, while that key isn't held, in client + // coordinates; null once it isn't. ghostty-web only: xterm.js opens its + // links with a plain click. + onLinkHint(fn: (at: { x: number; y: number } | null) => void): void; // Whether the terminal is on screen. Hidden, it draws nothing and takes // its output in batches (lib/term-output); shown again, it catches up and // redraws at once. A terminal starts shown. @@ -215,6 +222,7 @@ function gated(t: RawHandle, hiddenFlushMs = HIDDEN_FLUSH_MS): TermHandle { selection: () => t.selection(), paste: (text) => t.paste(text), registerLinkFinder: (find) => t.registerLinkFinder(find), + onLinkHint: (fn) => t.onLinkHint(fn), screen: () => t.screen(), geometry: () => t.geometry(), onParsed: (fn) => t.onParsed(fn), @@ -518,6 +526,8 @@ async function createGhostty(host: HTMLElement, colors: TerminalColors, prefs: T keepLastColumn(t); oneReadPerFrame(t); const frames = drawOnDemand(t, host, prefs.cursorBlink); + const { openUrl } = await import("@/lib/open-url"); + const links = ghosttyLinks(t, host, openUrl); forTests(host, t); // Output still on its way when the terminal is replaced (a new font size, // ⌘+) is dropped: ghostty-web throws on a write after dispose. @@ -576,20 +586,11 @@ async function createGhostty(host: HTMLElement, colors: TerminalColors, prefs: T // Without the frame loop (a ghostty-web that changed inside), a frame // is assumed a moment after output. onDrawn: (fn) => (frames ? frames.onDrawn(fn) : void parsed.push(() => requestAnimationFrame(() => requestAnimationFrame(fn)))), - registerLinkFinder: (find) => - t.registerLinkProvider({ - provideLinks(y, callback) { - const text = t.buffer.active.getLine(y)?.translateToString(true) ?? ""; - const links = find(text).map((l) => ({ - text: text.slice(l.start, l.end), - range: { start: { x: l.start, y }, end: { x: l.end - 1, y } }, - activate: (e: MouseEvent) => l.activate(e), - })); - callback(links.length ? links : undefined); - }, - }), + registerLinkFinder: (find) => links.addFinder(find), + onLinkHint: (fn) => links.onHint(fn), dispose: () => { disposed = true; + links.dispose(); frames?.dispose(); t.dispose(); hung.release(); @@ -597,6 +598,248 @@ async function createGhostty(host: HTMLElement, colors: TerminalColors, prefs: T }; } +// The parts of ghostty-web 0.4 its links go through: the link detector, +// which asks each provider for a row's links when the pointer is over it +// and runs the one under a click (activate) on any click, and the rows' +// cells. +interface GhosttyLink { + text: string; + range: { start: { x: number; y: number }; end: { x: number; y: number } }; + activate(e: MouseEvent): void; + hover?(hovered: boolean): void; +} +interface GhosttyLinkProvider { + provideLinks(y: number, callback: (links: GhosttyLink[] | undefined) => void): void; +} +interface GhosttyCell { + getChars(): string; + getHyperlinkId(): number; +} +interface GhosttyRow { + length: number; + // That this row goes on from the one above, which the terminal + // soft-wrapped onto it (as in xterm.js; ghostty-web 0.4's own docs say + // "wraps to the next line", but it reads the row's continuation). Rows + // of the screen only: one in the history always says false. + isWrapped?: boolean; + getCell(x: number): GhosttyCell | undefined; +} +interface GhosttyLinks { + write(data: string | Uint8Array, callback?: () => void): void; + linkDetector?: { providers?: GhosttyLinkProvider[]; invalidateCache?(): void }; + buffer: { active: { getLine(y: number): GhosttyRow | undefined } }; + renderer?: { getCanvas?(): HTMLCanvasElement }; + registerLinkProvider(p: GhosttyLinkProvider): void; +} + +// rowText is a row's text with one character per cell, so that a match's +// place in it is a column: an empty cell (erased, the right half of a wide +// character) reads as a space, where translateToString leaves it out. +function rowText(row: GhosttyRow): string { + let s = ""; + for (let x = 0; x < row.length; x++) { + const c = row.getCell(x)?.getChars() ?? ""; + // One UTF-16 unit per cell; an astral character (an emoji) is a space. + s += c.length === 1 ? c : " "; + } + return s; +} + +// ghosttyLinks makes a terminal's links open with ⌘-click (Ctrl-click off +// a Mac), as in Terminal, iTerm2 and Ghostty, and says so. +// +// ghostty-web 0.4 found web addresses and OSC 8 hyperlinks itself and +// opened them with window.open on ⌘-click, but it underlined a link and +// showed the pointing hand on a plain hover, where a plain click does +// nothing: links looked clickable and weren't. Its OSC 8 links never +// opened at all (it can't read their address back from its WASM, so they +// had none). Here its providers are replaced: addresses are found per cell, +// OSC 8 addresses are read from the output (lib/term-links), and both open +// through openUrl, the system browser in the app. The hand shows only while +// ⌘ is held; without it, resting on a link shows how to open it (onHint). +function ghosttyLinks(term: object, host: HTMLElement, openUrl: (url: string) => Promise) { + const t = term as GhosttyLinks; + const tracker = new HyperlinkTracker(); + const providers = t.linkDetector?.providers; + const canvas = t.renderer?.getCanvas?.(); + const hints = new Set<(at: { x: number; y: number } | null) => void>(); + let hovered: { key: string; link: GhosttyLink } | undefined; + let held = false; + let at: { x: number; y: number } | null = null; + let hintKey = ""; + let hintTimer = 0; + + const say = (to: { x: number; y: number } | null) => { + for (const fn of hints) fn(to); + }; + const sync = () => { + if (canvas && providers) canvas.style.cursor = hovered && held ? "pointer" : "text"; + const key = hovered && !held && at ? hovered.key : ""; + if (key === hintKey) return; + hintKey = key; + window.clearTimeout(hintTimer); + say(null); + // As long as a tooltip waits (components/tip), so sweeping across the + // screen doesn't flash one at every link. + if (key) hintTimer = window.setTimeout(() => hintKey === key && at && say(at), 300); + }; + // A link hovers and leaves through ghostty-web (hover); the same link + // found again after output (a new object) is the same link here. + const watched = (link: GhosttyLink): GhosttyLink => { + const key = `${link.range.start.y}:${link.range.start.x}:${link.range.end.y}:${link.range.end.x}:${link.text}`; + return { + ...link, + // ghostty-web leaves the old link before it enters the new one: the + // pair settles before anything changes on screen. + hover(on) { + if (on) hovered = { key, link }; + else if (hovered?.link === link) hovered = undefined; + queueMicrotask(sync); + }, + }; + }; + // The rows a provider reads, each read once per question. An address + // that runs on over several rows (a sign-in link hundreds of characters + // long) is found whole, from any of its rows (lib/term-links joinRows). + const reader = () => { + const lines = new Map(); + const texts = new Map(); + const line = (y: number) => { + if (!lines.has(y)) lines.set(y, y < 0 ? undefined : t.buffer.active.getLine(y)); + return lines.get(y); + }; + const rows: Rows & { line(y: number): GhosttyRow | undefined } = { + line, + text(y) { + if (!texts.has(y)) { + const row = line(y); + texts.set(y, row && rowText(row)); + } + return texts.get(y); + }, + wrapped: (y) => line(y + 1)?.isWrapped === true, + }; + return rows; + }; + const provider = (find: (rows: ReturnType, y: number) => GhosttyLink[]): GhosttyLinkProvider => ({ + provideLinks(y, callback) { + const rows = reader(); + const links = rows.line(y) ? find(rows, y).map(watched) : []; + callback(links.length ? links : undefined); + }, + }); + const open = (uri: string) => (e: MouseEvent) => { + if (modKey(e) && openable(uri)) void openUrl(uri); + }; + const urls = provider((rows, y) => linksAround(rows, y, findUrls).map(({ match, span }) => ({ text: match.url, range: span, activate: open(match.url) }))); + // Each run of cells in one OSC 8 hyperlink, by the address its text had. + // A run that fills a row to its end and goes on at the start of the next + // row in the same hyperlink is one link over both. + const osc8 = provider((rows, y) => { + const row = rows.line(y)!; + const id = (r: GhosttyRow | undefined, x: number) => r?.getCell(x)?.getHyperlinkId() ?? 0; + const out: GhosttyLink[] = []; + for (let x = 0; x < row.length; ) { + const h = id(row, x); + let end = x + 1; + while (end < row.length && id(row, end) === h) end++; + if (h) { + const start = { x, y }; + const own = rows.text(y)!.slice(x, end); + let text = own; + for (let n = 0; start.x === 0 && n < 32; n++) { + const prev = rows.line(start.y - 1); + if (!prev?.length || id(prev, prev.length - 1) !== h) break; + let from = prev.length - 1; + while (from > 0 && id(prev, from - 1) === h) from--; + text = rows.text(start.y - 1)!.slice(from) + text; + start.y--; + start.x = from; + } + const last = { x: end - 1, y }; + for (let n = 0; last.x === row.length - 1 && n < 32; n++) { + const next = rows.line(last.y + 1); + if (!next?.length || id(next, 0) !== h) break; + let to = 1; + while (to < next.length && id(next, to) === h) to++; + text += rows.text(last.y + 1)!.slice(0, to); + last.y++; + last.x = to - 1; + } + // 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) }); + } + x = end; + } + return out; + }); + // ghostty-web caches each row's links, keyed by the hyperlink a link + // starts in when it does: providers later in the list win there, so an + // OSC 8 link's own address beats an address its text shows. + if (providers) { + providers.splice(0, providers.length, urls, osc8); + t.linkDetector?.invalidateCache?.(); + // Everything the terminal is given, read for OSC 8 on the way in. + const write = t.write.bind(t); + t.write = (data, callback) => { + tracker.feed(data); + write(data, callback); + }; + } + + const onMove = (e: MouseEvent) => { + at = { x: e.clientX, y: e.clientY }; + held = modKey(e); + sync(); + }; + const onLeave = () => { + at = null; + sync(); + }; + const onKey = (e: KeyboardEvent) => { + if (held === modKey(e)) return; + held = modKey(e); + sync(); + }; + const onBlur = () => { + held = false; + sync(); + }; + const opts = { capture: true, passive: true } as const; + host.addEventListener("mousemove", onMove, opts); + host.addEventListener("mouseleave", onLeave, opts); + window.addEventListener("keydown", onKey, opts); + window.addEventListener("keyup", onKey, opts); + window.addEventListener("blur", onBlur); + sync(); + + return { + // A finder's links (file paths) go before the OSC 8 ones. + addFinder(find: LinkFinder) { + const p = provider((rows, y) => + linksAround(rows, y, (line) => find(line.trimEnd())).map(({ match, text, span }) => ({ text, range: span, activate: (e: MouseEvent) => match.activate(e) })), + ); + if (!providers) return t.registerLinkProvider(p); + providers.splice(providers.indexOf(osc8), 0, p); + t.linkDetector?.invalidateCache?.(); + }, + onHint(fn: (at: { x: number; y: number } | null) => void) { + hints.add(fn); + }, + dispose() { + window.clearTimeout(hintTimer); + hints.clear(); + host.removeEventListener("mousemove", onMove, opts); + host.removeEventListener("mouseleave", onLeave, opts); + window.removeEventListener("keydown", onKey, opts); + window.removeEventListener("keyup", onKey, opts); + window.removeEventListener("blur", onBlur); + }, + }; +} + // xterm.js draws only what changed, and stops drawing while its element is // off screen (an IntersectionObserver), so it needs no help when hidden. async function createXterm(host: HTMLElement, colors: TerminalColors, prefs: TerminalPrefs): Promise { @@ -620,6 +863,10 @@ async function createXterm(host: HTMLElement, colors: TerminalColors, prefs: Ter macOptionIsMeta: true, allowProposedApi: true, theme: theme(colors), + // OSC 8 hyperlinks open like the addresses below. Without a handler, + // xterm.js asks with confirm() and then opens a blank window.open(), + // neither of which the app's webview does. + linkHandler: { activate: (_e, uri) => void (openable(uri) && openUrl(uri)) }, }); const fit = new FitAddon(); t.loadAddon(fit); @@ -674,6 +921,7 @@ async function createXterm(host: HTMLElement, colors: TerminalColors, prefs: Ter callback(links.length ? links : undefined); }, }), + onLinkHint: () => {}, dispose: () => t.dispose(), }; } diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 07722e61..06d366c5 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -11,6 +11,14 @@ again. ## Unreleased +- **Links in a terminal open with ⌘-click, and say so.** A link used to + underline and show the pointing hand on a plain hover, where a click did + nothing, and links a program made with its own text (OSC 8) never opened + at all. Now resting on a link shows "⌘-click to open", the hand appears + while ⌘ is held, and ⌘-click opens the address in your browser, OSC 8 + 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. - **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