feat: make links in Claude's output clickable
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 3s
Secret Scan / scan (pull_request) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 2m47s
Build App (Preview) / build-linux (pull_request) Successful in 8m8s
Build App (Preview) / build-windows (pull_request) Failing after 13m30s
Build App (Preview) / prune-previews (pull_request) Skipped

Claude Code prints links as OSC 8 hyperlinks whose visible text is
hard-wrapped into terminal-width pieces -- urlDetector's header records a
346-character sign-in URL arriving as five emissions, each carrying the
whole URL in its parameter and about 80 characters on screen. WebLinksAddon
regex-matches the painted characters row by row, so against Claude it
matches a fragment or nothing, which is why the URL toast exists.

xterm 5.5 hands over the exact parameter through `linkHandler`, so the
slicing stops mattering. WebLinksAddon stays for plain-text URLs in
ordinary shell output; the two cover different cases and neither replaces
the other. Both now share one failure reporter and one validator.

No new key handling was needed. xterm's mousedown handler is
`if (areMouseEventsActive && !shouldForceSelection(e)) return cancel(e)`,
so holding the force-selection modifier lets the event reach the link
layer while Claude still holds the mouse -- Shift+click, or Option+click on
macOS, which this terminal already enables for text selection.

The hover card is the security half rather than decoration. OSC 8
decouples the label from the target completely: a container can print
`https://claude.ai` and link it anywhere, which is strictly worse than the
userinfo spoofing already guarded against and which invalidated the
justification for opening a click without confirmation ("a deliberate act
on visible text"). Hovering now shows the real origin, in full and never
truncated, because truncating it is the spoof. A target that fails
validation says so and deliberately echoes nothing of itself.

The hint names the modifier for the platform, from xterm's own `isMac`
list, so it cannot tell a Mac user to press a key that does nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-18 19:23:18 -07:00
co-authored by Claude Opus 5
parent 73a6e3d8b4
commit f311ca1990
2 changed files with 365 additions and 19 deletions
@@ -1,6 +1,10 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, fireEvent, cleanup, act } from "@testing-library/react";
import TerminalView, { supersedes } from "./TerminalView";
import TerminalView, {
OSC8_HOVER_CLASS,
createOsc8LinkHandler,
supersedes,
} from "./TerminalView";
import { useAppState } from "../../store/appState";
import {
uploadHostFileToTerminal,
@@ -1074,3 +1078,140 @@ describe("TerminalView — releasing a captured mouse", () => {
expect(terminalInput).not.toHaveBeenCalled();
});
});
describe("the hover hint names the key that actually works", () => {
const platform = (value: string) =>
Object.defineProperty(navigator, "platform", { value, configurable: true });
const original = navigator.platform;
afterEach(() => platform(original));
// xterm gates this on its own `isMac`; if the hint and the gate disagree the
// user is told to press a key that does nothing.
it("says Option on a Mac, because that is xterm's force-selection modifier there", () => {
platform("MacIntel");
const host = document.createElement("div");
createOsc8LinkHandler(() => host).hover?.(
new MouseEvent("mousemove"),
"https://example.com/x",
{ start: { x: 1, y: 1 }, end: { x: 1, y: 1 } },
);
expect(host.textContent).toContain("Option+click");
expect(host.textContent).not.toContain("Shift+click");
});
it("says Shift everywhere else", () => {
platform("Linux x86_64");
const host = document.createElement("div");
createOsc8LinkHandler(() => host).hover?.(
new MouseEvent("mousemove"),
"https://example.com/x",
{ start: { x: 1, y: 1 }, end: { x: 1, y: 1 } },
);
expect(host.textContent).toContain("Shift+click");
});
});
describe("createOsc8LinkHandler — clicking a link Claude Code printed", () => {
/**
* The handler is exercised directly rather than through a rendered terminal.
*
* xterm decides *when* to call it from cell geometry, and jsdom gives every
* element a zero-sized box — so a test driving the mouse over the pane would
* be asserting that jsdom's layout engine exists, not that this app validates
* what it opens. What xterm hands over is the OSC 8 parameter verbatim, which
* is exactly what these arguments are.
*/
const range = {
start: { x: 1, y: 1 },
end: { x: 80, y: 1 },
} as unknown as Parameters<
NonNullable<ReturnType<typeof createOsc8LinkHandler>["hover"]>
>[2];
let host: HTMLDivElement;
let handler: ReturnType<typeof createOsc8LinkHandler>;
beforeEach(() => {
host = document.createElement("div");
document.body.appendChild(host);
handler = createOsc8LinkHandler(() => host);
});
function hoverCard(): HTMLElement | null {
return host.querySelector<HTMLElement>(`.${OSC8_HOVER_CLASS}`);
}
it("refuses a target that fails validation, without reaching the opener", () => {
// The visible text can be anything; the parameter is what gets opened, and
// a container is free to put a scheme in it that the host must never hand
// to an OS-level opener.
handler.activate(new MouseEvent("click"), "javascript:alert(1)", range);
handler.activate(new MouseEvent("click"), "file:///etc/passwd", range);
handler.activate(
new MouseEvent("click"),
"https://claude.ai@evil.tld/authorize",
range,
);
expect(openUrlExternal).not.toHaveBeenCalled();
});
it("opens a valid target through the one sink", async () => {
const url =
"https://claude.ai/oauth/authorize?code=true&client_id=abc123&scope=user%3Ainference";
await act(async () => {
handler.activate(new MouseEvent("click"), url, range);
await Promise.resolve();
});
expect(openUrlExternal).toHaveBeenCalledWith(url);
});
it("shows the real origin on hover, not the text on screen", () => {
// The point of the affordance. OSC 8 decouples label from target: the row
// can read `https://claude.ai` while the parameter points anywhere.
handler.hover?.(
new MouseEvent("mousemove"),
"https://evil.example.com/claude.ai/oauth/authorize?code=true",
range,
);
const card = hoverCard();
expect(card).not.toBeNull();
const origin = card!.querySelector('[data-testid="osc8-hover-origin"]');
expect(origin?.textContent).toBe("https://evil.example.com");
// Whole origin or nothing — a truncated one is the spoof this prevents.
expect(origin?.textContent).not.toContain("…");
expect(card!.textContent).not.toContain("https://claude.ai");
handler.leave?.(new MouseEvent("mouseout"), "https://evil.example.com/", range);
expect(hoverCard()).toBeNull();
});
it("says so on hover when the target would be refused", () => {
handler.hover?.(new MouseEvent("mousemove"), "javascript:alert(1)", range);
const card = hoverCard();
expect(card).not.toBeNull();
expect(card!.querySelector('[data-testid="osc8-hover-origin"]')).toBeNull();
// Never echo the rejected target: it is untrusted text on its way to a DOM
// node, and the only thing worth saying is that clicking does nothing.
expect(card!.textContent).not.toContain("javascript:");
});
it("pushes the shared toast when the host opener fails", async () => {
vi.mocked(openUrlExternal).mockRejectedValueOnce(new Error("no opener"));
await act(async () => {
handler.activate(new MouseEvent("click"), "https://example.com/x", range);
await Promise.resolve();
});
const toasts = useAppState.getState().toasts;
expect(toasts).toHaveLength(1);
expect(toasts[0].kind).toBe("error");
expect(toasts[0].detail).toContain("no opener");
// Same card as every other dead-opener report in this view.
expect(toasts[0].dedupeKey).toBe("host-open-failed");
});
});