Make links in Claude's output clickable #59
@@ -45,6 +45,33 @@ const ptyOutput = vi.hoisted(() => ({
|
||||
listeners: new Map<string, (e: { payload: number[] }) => void>(),
|
||||
}));
|
||||
|
||||
/**
|
||||
* What `TerminalView` actually handed the `Terminal` constructor, and the
|
||||
* instances it built.
|
||||
*
|
||||
* The real xterm is kept — these tests depend on its parser, its modes and its
|
||||
* DOM — and only the constructor is wrapped, because the wiring of
|
||||
* `linkHandler` is otherwise unobservable from outside: xterm decides when to
|
||||
* call it from cell geometry that jsdom has no layout for, so deleting the
|
||||
* `linkHandler:` line changed nothing any assertion could see.
|
||||
*/
|
||||
const xterm = vi.hoisted(() => ({
|
||||
options: null as Record<string, unknown> | null,
|
||||
instances: [] as unknown[],
|
||||
}));
|
||||
|
||||
vi.mock("@xterm/xterm", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("@xterm/xterm")>();
|
||||
class SpyTerminal extends actual.Terminal {
|
||||
constructor(options?: ConstructorParameters<typeof actual.Terminal>[0]) {
|
||||
super(options);
|
||||
xterm.options = (options ?? null) as Record<string, unknown> | null;
|
||||
xterm.instances.push(this);
|
||||
}
|
||||
}
|
||||
return { ...actual, Terminal: SpyTerminal };
|
||||
});
|
||||
|
||||
/**
|
||||
* Shift+Enter has to reach the container as ESC+CR.
|
||||
*
|
||||
@@ -161,6 +188,8 @@ beforeEach(() => {
|
||||
useAppState.setState({ toasts: [] });
|
||||
document.body.innerHTML = "";
|
||||
useAppState.setState({ sessions: [] });
|
||||
xterm.options = null;
|
||||
xterm.instances.length = 0;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -1085,29 +1114,42 @@ describe("the hover hint names the key that actually works", () => {
|
||||
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.
|
||||
const hoverHint = (tracking: boolean): string => {
|
||||
const host = document.createElement("div");
|
||||
createOsc8LinkHandler(
|
||||
() => host,
|
||||
() => tracking,
|
||||
).hover?.(new MouseEvent("mousemove"), "https://example.com/x", {
|
||||
start: { x: 1, y: 1 },
|
||||
end: { x: 1, y: 1 },
|
||||
});
|
||||
return host.textContent ?? "";
|
||||
};
|
||||
|
||||
// The hint and the gate read one predicate; these pin that they cannot
|
||||
// drift, because a hint naming a key the gate does not accept is the bug
|
||||
// that was already fixed once on this branch.
|
||||
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");
|
||||
expect(hoverHint(true)).toContain("Option+click");
|
||||
expect(hoverHint(true)).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");
|
||||
expect(hoverHint(true)).toContain("Shift+click");
|
||||
});
|
||||
|
||||
// No program holds the mouse, so no modifier is needed — and naming one
|
||||
// would tell the user to press a key the gate ignores.
|
||||
it("names no modifier at all while nothing is tracking the mouse", () => {
|
||||
platform("Linux x86_64");
|
||||
const hint = hoverHint(false);
|
||||
expect(hint).toContain("Click to open");
|
||||
expect(hint).not.toContain("Shift+click");
|
||||
|
||||
platform("MacIntel");
|
||||
expect(hoverHint(false)).not.toContain("Option+click");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1130,28 +1172,35 @@ describe("createOsc8LinkHandler — clicking a link Claude Code printed", () =>
|
||||
|
||||
let host: HTMLDivElement;
|
||||
let handler: ReturnType<typeof createOsc8LinkHandler>;
|
||||
/** What the terminal's live mouse-tracking mode says, per test. */
|
||||
let tracking: boolean;
|
||||
|
||||
beforeEach(() => {
|
||||
host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
handler = createOsc8LinkHandler(() => host);
|
||||
tracking = false;
|
||||
handler = createOsc8LinkHandler(
|
||||
() => host,
|
||||
() => tracking,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => host.remove());
|
||||
|
||||
function hoverCard(): HTMLElement | null {
|
||||
return host.querySelector<HTMLElement>(`.${OSC8_HOVER_CLASS}`);
|
||||
}
|
||||
|
||||
const click = (init: MouseEventInit = {}) =>
|
||||
new MouseEvent("click", { button: 0, ...init });
|
||||
|
||||
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,
|
||||
);
|
||||
handler.activate(click(), "javascript:alert(1)", range);
|
||||
handler.activate(click(), "file:///etc/passwd", range);
|
||||
handler.activate(click(), "https://claude.ai@evil.tld/authorize", range);
|
||||
|
||||
expect(openUrlExternal).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -1160,13 +1209,75 @@ describe("createOsc8LinkHandler — clicking a link Claude Code printed", () =>
|
||||
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);
|
||||
handler.activate(click(), url, range);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(openUrlExternal).toHaveBeenCalledWith(url);
|
||||
});
|
||||
|
||||
describe("the gate on activation", () => {
|
||||
const URL = "https://example.com/x";
|
||||
|
||||
/**
|
||||
* The attack this gate exists for.
|
||||
*
|
||||
* xterm's mouse-reporting mousedown does *not* cancel anything —
|
||||
* `cancelEvents` defaults to false — and the Linkifier is a descendant of
|
||||
* the element those listeners are bound to, so the link layer sees every
|
||||
* click first and `_handleMouseUp` activates with no modifier, button or
|
||||
* mode check of its own. A TUI widget the user is meant to click can
|
||||
* therefore be wrapped in an OSC 8 pointing anywhere, and a plain click
|
||||
* opens the host browser on it while the mouse report still reaches the
|
||||
* program, so nothing looks wrong. The modifier is the only thing that
|
||||
* separates "I clicked the menu item" from "I asked to leave the app".
|
||||
*/
|
||||
it("refuses a plain click while a program is tracking the mouse", () => {
|
||||
tracking = true;
|
||||
|
||||
handler.activate(click(), URL, range);
|
||||
|
||||
expect(openUrlExternal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens on the force-selection modifier while tracking", async () => {
|
||||
tracking = true;
|
||||
|
||||
await act(async () => {
|
||||
handler.activate(click({ shiftKey: true }), URL, range);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(openUrlExternal).toHaveBeenCalledWith(URL);
|
||||
});
|
||||
|
||||
it("opens on a plain click when nothing holds the mouse", async () => {
|
||||
// A normal shell. This is what `WebLinksAddon` does for the plain-text
|
||||
// URLs in the same buffer, and asking for a modifier here would read as
|
||||
// a broken link.
|
||||
tracking = false;
|
||||
|
||||
await act(async () => {
|
||||
handler.activate(click(), URL, range);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(openUrlExternal).toHaveBeenCalledWith(URL);
|
||||
});
|
||||
|
||||
it("ignores every button but the primary one", () => {
|
||||
// Right-click is the context menu this pane already binds; middle-click
|
||||
// is paste. Neither is a request to leave the app.
|
||||
tracking = false;
|
||||
|
||||
handler.activate(click({ button: 2 }), URL, range);
|
||||
handler.activate(click({ button: 1 }), URL, range);
|
||||
handler.activate(click({ button: 2, shiftKey: true }), URL, range);
|
||||
|
||||
expect(openUrlExternal).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
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.
|
||||
@@ -1180,14 +1291,49 @@ describe("createOsc8LinkHandler — clicking a link Claude Code printed", () =>
|
||||
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("keeps a very long origin whole, and gives way in the remainder instead", () => {
|
||||
// The attacker picks the origin's length. `https://claude.ai.<300 a's>
|
||||
// .evil.tld/` parses, passes every `sanitizeRelayUrl` rule, and under a
|
||||
// non-shrinking flex item runs off the right edge of the pane — which
|
||||
// hides the registrable domain just as effectively as an ellipsis would.
|
||||
const origin = `https://claude.ai.${"a".repeat(300)}.${"b".repeat(200)}.evil.tld`;
|
||||
handler.hover?.(new MouseEvent("mousemove"), `${origin}/oauth?code=1`, range);
|
||||
|
||||
const originEl = hoverCard()!.querySelector<HTMLElement>(
|
||||
'[data-testid="osc8-hover-origin"]',
|
||||
)!;
|
||||
// Whole origin or nothing: every character is in the DOM...
|
||||
expect(originEl.textContent).toBe(origin);
|
||||
// ...and it is allowed to wrap rather than be clipped or pushed off-pane.
|
||||
expect(originEl.style.flexShrink).not.toBe("0");
|
||||
expect(originEl.style.whiteSpace).not.toBe("nowrap");
|
||||
expect(originEl.style.overflowWrap).toBe("anywhere");
|
||||
|
||||
// The truncatable half is the remainder, and only the remainder.
|
||||
const restEl = hoverCard()!.querySelector<HTMLElement>(
|
||||
'[data-testid="osc8-hover-rest"]',
|
||||
)!;
|
||||
expect(restEl.style.textOverflow).toBe("ellipsis");
|
||||
expect(restEl.style.whiteSpace).toBe("nowrap");
|
||||
});
|
||||
|
||||
it("cannot take the pointer away from the link that summoned it", () => {
|
||||
// The card is appended to `Terminal.element`, a *sibling* of the
|
||||
// `screenElement` the Linkifier listens on, so `xterm-hover` buys nothing
|
||||
// here: a card under the pointer means `mouseleave` on screenElement, the
|
||||
// card is torn down, and the mouseup that would activate the link lands on
|
||||
// the card instead of the terminal.
|
||||
handler.hover?.(new MouseEvent("mousemove"), "https://example.com/x", range);
|
||||
|
||||
expect(hoverCard()!.style.pointerEvents).toBe("none");
|
||||
});
|
||||
|
||||
it("says so on hover when the target would be refused", () => {
|
||||
handler.hover?.(new MouseEvent("mousemove"), "javascript:alert(1)", range);
|
||||
|
||||
@@ -1199,11 +1345,37 @@ describe("createOsc8LinkHandler — clicking a link Claude Code printed", () =>
|
||||
expect(card!.textContent).not.toContain("javascript:");
|
||||
});
|
||||
|
||||
it("does not call a refused web address something other than a web address", () => {
|
||||
// `https://claude.ai@evil.tld/` is a perfectly good URL; it is refused
|
||||
// because the userinfo makes the visible host a lie. Telling the user it
|
||||
// "is not a web address" is false, and a false explanation teaches them to
|
||||
// distrust the card.
|
||||
handler.hover?.(
|
||||
new MouseEvent("mousemove"),
|
||||
"https://claude.ai@evil.tld/authorize",
|
||||
range,
|
||||
);
|
||||
|
||||
expect(hoverCard()!.textContent).not.toContain("not a web address");
|
||||
});
|
||||
|
||||
it("drops a stale card when the pane is no longer on screen", () => {
|
||||
// `leave` only ever arrives from the Linkifier's `_clearCurrentLink`, and
|
||||
// switching tabs from the keyboard moves no pointer: without this, the
|
||||
// card is still sitting there when the user comes back.
|
||||
handler.hover?.(new MouseEvent("mousemove"), "https://example.com/x", range);
|
||||
expect(hoverCard()).not.toBeNull();
|
||||
|
||||
handler.dismiss();
|
||||
|
||||
expect(hoverCard()).toBeNull();
|
||||
});
|
||||
|
||||
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);
|
||||
handler.activate(click(), "https://example.com/x", range);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
@@ -1215,3 +1387,70 @@ describe("createOsc8LinkHandler — clicking a link Claude Code printed", () =>
|
||||
expect(toasts[0].dedupeKey).toBe("host-open-failed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("the link handler is wired into the terminal, and reads its live mode", () => {
|
||||
const range = {
|
||||
start: { x: 1, y: 1 },
|
||||
end: { x: 80, y: 1 },
|
||||
} as unknown as Parameters<
|
||||
NonNullable<ReturnType<typeof createOsc8LinkHandler>["hover"]>
|
||||
>[2];
|
||||
|
||||
/** What the mounted view passed as `linkHandler`. */
|
||||
function wiredHandler() {
|
||||
const handler = xterm.options?.linkHandler as
|
||||
| ReturnType<typeof createOsc8LinkHandler>
|
||||
| undefined;
|
||||
if (!handler) throw new Error("no linkHandler was passed to Terminal");
|
||||
return handler;
|
||||
}
|
||||
|
||||
/** Feed the terminal a DECSET the way the container would. */
|
||||
async function write(data: string) {
|
||||
const term = xterm.instances.at(-1) as { write(d: string, cb: () => void): void };
|
||||
await act(
|
||||
() => new Promise<void>((resolve) => term.write(data, resolve)),
|
||||
);
|
||||
}
|
||||
|
||||
it("passes one at all — without it OSC 8 links are inert", () => {
|
||||
mountSession("claude");
|
||||
|
||||
const handler = wiredHandler();
|
||||
expect(typeof handler.activate).toBe("function");
|
||||
expect(typeof handler.hover).toBe("function");
|
||||
});
|
||||
|
||||
// The gate has to ask the terminal, not a boolean captured at construction:
|
||||
// the mode changes whenever the container prints a DECSET, which is several
|
||||
// times a second in Claude Code.
|
||||
it("refuses a plain click once the container turns mouse tracking on", async () => {
|
||||
mountSession("claude");
|
||||
await write("\x1b[?1002h");
|
||||
|
||||
wiredHandler().activate(
|
||||
new MouseEvent("click", { button: 0 }),
|
||||
"https://example.com/x",
|
||||
range,
|
||||
);
|
||||
|
||||
expect(openUrlExternal).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("opens again once the container gives the mouse back", async () => {
|
||||
mountSession("claude");
|
||||
await write("\x1b[?1002h");
|
||||
await write("\x1b[?1002l");
|
||||
|
||||
await act(async () => {
|
||||
wiredHandler().activate(
|
||||
new MouseEvent("click", { button: 0 }),
|
||||
"https://example.com/x",
|
||||
range,
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(openUrlExternal).toHaveBeenCalledWith("https://example.com/x");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -108,12 +108,14 @@ export function supersedes(
|
||||
}
|
||||
|
||||
/**
|
||||
* Class xterm requires on an element that floats over the terminal.
|
||||
* Marks the hover card, for xterm's stylesheet and for the tests.
|
||||
*
|
||||
* Not decoration: `ILinkHandler.hover`'s contract is that the hover element
|
||||
* lives inside `Terminal.element` and carries this class, otherwise xterm's
|
||||
* own hit-testing does not know to stop at it and mouse events fall through to
|
||||
* whatever link is underneath.
|
||||
* It does *not* make xterm route pointer events around the card. xterm only
|
||||
* consults this class inside `Linkifier._handleMouseMove`, which is registered
|
||||
* on `screenElement`; the card is appended to `Terminal.element`, a *sibling*
|
||||
* of that node, so the check never sees it. What keeps the card out of the way
|
||||
* is `pointerEvents: "none"` on the card itself — see `hover` below for what
|
||||
* goes wrong without it.
|
||||
*/
|
||||
export const OSC8_HOVER_CLASS = "xterm-hover";
|
||||
|
||||
@@ -135,6 +137,91 @@ function reportOpenFailure(e: unknown) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* `ILinkHandler`, plus the one thing xterm never asks for.
|
||||
*
|
||||
* `leave` is only ever reached through `Linkifier._clearCurrentLink`, i.e. a
|
||||
* pointer that moved. Switching tabs from the keyboard moves no pointer and
|
||||
* the Linkifier's dispose path does not clear either, so the card outlives the
|
||||
* pane and is still there when the user comes back. {@link dismiss} is how the
|
||||
* view says "this pane is gone" without pretending to be a mouse event.
|
||||
*/
|
||||
export type Osc8LinkHandler = ILinkHandler & { dismiss(): void };
|
||||
|
||||
/**
|
||||
* Is a program holding the mouse?
|
||||
*
|
||||
* One expression, two readers that must never disagree: the status-bar badge
|
||||
* (`syncMouseCapture`) and the gate on opening a link ({@link opensOnClick}).
|
||||
* A gate that thought tracking was off while the badge said it was on would be
|
||||
* the whole security hole back again.
|
||||
*/
|
||||
function terminalTracksMouse(term: Terminal): boolean {
|
||||
return term.modes.mouseTrackingMode !== "none";
|
||||
}
|
||||
|
||||
/** xterm's `isMac` verbatim (`common/Platform.ts`), so we split where it does. */
|
||||
function isMacPlatform(): boolean {
|
||||
const platform = typeof navigator === "undefined" ? "" : navigator.platform;
|
||||
return ["Macintosh", "MacIntel", "MacPPC", "Mac68K"].includes(platform);
|
||||
}
|
||||
|
||||
/**
|
||||
* xterm's `SelectionService.shouldForceSelection`, mirrored.
|
||||
*
|
||||
* The modifier is not our choice and it must not drift: while a program holds
|
||||
* the mouse, this is the one gesture the user already has for "this click is
|
||||
* for the terminal, not for the program", so it is the gesture that may open a
|
||||
* link. xterm's rule is
|
||||
* `isMac ? e.altKey && macOptionClickForcesSelection : e.shiftKey`, and this
|
||||
* view sets `macOptionClickForcesSelection`, so the Mac branch is live rather
|
||||
* than theoretical.
|
||||
*
|
||||
* The gate and the hint below both call this. A hint that names a key the gate
|
||||
* does not accept is worse than no hint — the user concludes the link is
|
||||
* broken — and that is a bug this branch has already shipped once, so the two
|
||||
* are not allowed separate answers.
|
||||
*/
|
||||
function forcesSelection(event: { altKey: boolean; shiftKey: boolean }): boolean {
|
||||
return isMacPlatform() ? event.altKey : event.shiftKey;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the card tells the user to do, for the state the terminal is in *now*.
|
||||
*
|
||||
* Conditional because the gesture is: with no program tracking the mouse a
|
||||
* plain click opens the link, and naming a modifier then would send the user
|
||||
* hunting for a key that changes nothing.
|
||||
*/
|
||||
function openHintLabel(mouseTracking: boolean): string {
|
||||
if (!mouseTracking) return "Click to open";
|
||||
return isMacPlatform() ? "Option+click to open" : "Shift+click to open";
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this click is a request to leave the app for the host browser.
|
||||
*
|
||||
* Two refusals, for two different mistakes:
|
||||
*
|
||||
* - Anything but the primary button. xterm's `Linkifier._handleMouseUp`
|
||||
* checks neither the button nor the mouse mode, so without this a
|
||||
* *right*-click activates the link as well as opening this pane's context
|
||||
* menu, and a middle-click paste opens it too.
|
||||
* - A plain click while a program is tracking the mouse. That is the
|
||||
* dangerous one: OSC 8 lets the container wrap any clickable TUI widget —
|
||||
* a menu row, a "1. Yes", a file chip — in a link to anywhere, and because
|
||||
* the mouse report still reaches the program the widget also responds, so
|
||||
* nothing looks wrong. Requiring the force-selection modifier there makes
|
||||
* the two intents distinguishable.
|
||||
*
|
||||
* With nothing tracking the mouse a bare click is correct and expected: it is
|
||||
* what `WebLinksAddon` does for the plain-text URLs in the same buffer.
|
||||
*/
|
||||
function opensOnClick(event: MouseEvent, mouseTracking: boolean): boolean {
|
||||
if (event.button !== 0) return false;
|
||||
return !mouseTracking || forcesSelection(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes OSC 8 hyperlinks clickable, and shows where they actually go.
|
||||
*
|
||||
@@ -152,15 +239,21 @@ function reportOpenFailure(e: unknown) {
|
||||
* stays, because it covers the plain-text URLs in ordinary shell output that
|
||||
* carry no OSC 8 at all.
|
||||
*
|
||||
* ## The gesture is Shift+click (Option+click on macOS)
|
||||
* ## xterm applies no gate of its own, so this one does
|
||||
*
|
||||
* Claude holds mouse tracking (`?1000`/`?1002`/`?1003`), and xterm's mousedown
|
||||
* handler cancels the event before the link layer whenever tracking is on —
|
||||
* *unless* `shouldForceSelection(e)` is true, which is `e.shiftKey`, or
|
||||
* `e.altKey` on macOS with `macOptionClickForcesSelection` set (this view sets
|
||||
* it). So the modifier that already exists for selecting text is the one that
|
||||
* reaches a link, and no new key handling is involved. A plain click keeps
|
||||
* going to the program, which is what a TUI needs.
|
||||
* There is a tempting story in which xterm's mouse-reporting mousedown cancels
|
||||
* the event before the link layer sees it, leaving only the force-selection
|
||||
* modifier a way through. It is false in both halves. That branch calls
|
||||
* `cancel(e)`, which is a no-op unless `cancelEvents` is set and it defaults to
|
||||
* false; and the mouse-reporting listeners are bound on `Terminal.element`
|
||||
* while the Linkifier is bound on `screenElement`, a descendant, so bubbling
|
||||
* reaches the link first no matter what. `Linkifier._handleMouseUp` then
|
||||
* activates the link with no check on the button, the modifier or the mouse
|
||||
* mode.
|
||||
*
|
||||
* So the gate is {@link opensOnClick}, applied in `activate`, and the mouse
|
||||
* mode it asks about is read from the terminal on every click rather than
|
||||
* captured — the container changes it whenever it likes.
|
||||
*
|
||||
* ## The hover card is the security half, not a nicety
|
||||
*
|
||||
@@ -179,31 +272,14 @@ function reportOpenFailure(e: unknown) {
|
||||
*
|
||||
* @param getHost returns `Terminal.element`, which does not exist until
|
||||
* `term.open()` has run — hence a getter rather than the element.
|
||||
* @param isMouseTracking answers "is a program holding the mouse right now?"
|
||||
* — a getter for the same reason, and the *only* reason: the answer
|
||||
* changes under us.
|
||||
*/
|
||||
/**
|
||||
* The modifier that opens an OSC 8 link, named the way the user's platform
|
||||
* names it.
|
||||
*
|
||||
* This is not our choice and it must not drift: xterm only lets a click reach
|
||||
* the link layer while a program holds the mouse when its own
|
||||
* `shouldForceSelection` says so, and that is
|
||||
* `isMac ? e.altKey && macOptionClickForcesSelection : e.shiftKey`. The list
|
||||
* below is xterm's `isMac` verbatim (`common/Platform.ts`) so the hint cannot
|
||||
* disagree with the behaviour it describes — a hint that names the wrong key
|
||||
* is worse than no hint, because the user concludes the link is broken.
|
||||
*
|
||||
* `macOptionClickForcesSelection` is set on the terminal, so the Mac branch is
|
||||
* live rather than theoretical.
|
||||
*/
|
||||
function openModifierLabel(): string {
|
||||
const platform = typeof navigator === "undefined" ? "" : navigator.platform;
|
||||
const isMac = ["Macintosh", "MacIntel", "MacPPC", "Mac68K"].includes(platform);
|
||||
return isMac ? "Option+click to open" : "Shift+click to open";
|
||||
}
|
||||
|
||||
export function createOsc8LinkHandler(
|
||||
getHost: () => HTMLElement | null,
|
||||
): ILinkHandler {
|
||||
isMouseTracking: () => boolean,
|
||||
): Osc8LinkHandler {
|
||||
let card: HTMLDivElement | null = null;
|
||||
|
||||
const clear = () => {
|
||||
@@ -219,7 +295,10 @@ export function createOsc8LinkHandler(
|
||||
};
|
||||
|
||||
return {
|
||||
activate(_event, text) {
|
||||
activate(event, text) {
|
||||
// Opening the host browser is the one thing in this pane the container
|
||||
// must not be able to provoke on its own. See `opensOnClick`.
|
||||
if (!opensOnClick(event, isMouseTracking())) return;
|
||||
// Same sink and same rule as the WebLinksAddon branch: this came off the
|
||||
// container's output, so it is validated before it reaches the OS
|
||||
// opener. One implementation — `sanitizeRelayUrl` — on purpose.
|
||||
@@ -244,7 +323,16 @@ export function createOsc8LinkHandler(
|
||||
left: "8px",
|
||||
bottom: "8px",
|
||||
maxWidth: "calc(100% - 16px)",
|
||||
boxSizing: "border-box",
|
||||
zIndex: "30",
|
||||
// The card lands under the pointer for a link in the bottom rows, and
|
||||
// it is not a sibling the Linkifier hit-tests around (see
|
||||
// `OSC8_HOVER_CLASS`). Without this, `screenElement` gets `mouseleave`
|
||||
// the moment the card appears — card removed, pointer back on the
|
||||
// link, card back: a flicker loop — and worse, the `mouseup` that
|
||||
// activates the link lands on the card, so the link cannot be opened
|
||||
// at all. Nothing here is interactive, so nothing is lost.
|
||||
pointerEvents: "none",
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
gap: "6px",
|
||||
@@ -253,6 +341,9 @@ export function createOsc8LinkHandler(
|
||||
fontFamily: "monospace",
|
||||
background: "var(--bg-secondary)",
|
||||
border: "1px solid var(--border-color)",
|
||||
// The origin wraps, so the card grows downward rather than sideways;
|
||||
// this is the backstop for anything that still cannot fit.
|
||||
overflow: "hidden",
|
||||
borderRadius: "6px",
|
||||
boxShadow: "var(--shadow-overlay)",
|
||||
color: "var(--text-primary)",
|
||||
@@ -263,9 +354,12 @@ export function createOsc8LinkHandler(
|
||||
if (!safe || !origin) {
|
||||
// Nothing of the rejected target is echoed into the DOM — it is
|
||||
// untrusted text, and the only useful thing to say is that the click
|
||||
// will not do anything.
|
||||
// will not do anything. Deliberately not "it is not a web address":
|
||||
// `https://claude.ai@evil.tld/` and an over-length URL both are one,
|
||||
// and a card that explains a refusal wrongly teaches the user to
|
||||
// distrust the card.
|
||||
card.appendChild(
|
||||
span("This link will not be opened — it is not a web address", {
|
||||
span("This link will not be opened — it failed the URL safety check", {
|
||||
color: "var(--text-secondary)",
|
||||
}),
|
||||
);
|
||||
@@ -273,10 +367,19 @@ export function createOsc8LinkHandler(
|
||||
const rest = safe.startsWith(origin) ? safe.slice(origin.length) : safe;
|
||||
const originEl = span(origin, {
|
||||
fontWeight: "700",
|
||||
// The part that decides where the credentials go. Never truncated:
|
||||
// truncating it *is* the spoof.
|
||||
flexShrink: "0",
|
||||
// The part that decides where the credentials go, so all of it is
|
||||
// shown: truncating it *is* the spoof, and so is pushing its tail
|
||||
// off the right edge of the pane. The attacker picks the length —
|
||||
// `https://claude.ai.<300 chars>.evil.tld` parses and passes every
|
||||
// `sanitizeRelayUrl` rule — so "do not shrink" is not enough:
|
||||
// `flex-shrink: 0` pins a flex item at its max-content width and the
|
||||
// text never wraps, it just overflows. It wraps instead, onto as
|
||||
// many lines as it needs, and the truncatable remainder below is the
|
||||
// thing that gives way.
|
||||
flexShrink: "1",
|
||||
minWidth: "0",
|
||||
overflowWrap: "anywhere",
|
||||
whiteSpace: "normal",
|
||||
});
|
||||
originEl.dataset.testid = "osc8-hover-origin";
|
||||
card.appendChild(originEl);
|
||||
@@ -291,7 +394,7 @@ export function createOsc8LinkHandler(
|
||||
restEl.dataset.testid = "osc8-hover-rest";
|
||||
card.appendChild(restEl);
|
||||
|
||||
const hint = span(openModifierLabel(), {
|
||||
const hint = span(openHintLabel(isMouseTracking()), {
|
||||
color: "var(--text-secondary)",
|
||||
flexShrink: "0",
|
||||
marginLeft: "4px",
|
||||
@@ -303,6 +406,8 @@ export function createOsc8LinkHandler(
|
||||
},
|
||||
|
||||
leave: clear,
|
||||
|
||||
dismiss: clear,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -312,6 +417,9 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
const termRef = useRef<Terminal | null>(null);
|
||||
const fitRef = useRef<FitAddon | null>(null);
|
||||
const webglRef = useRef<WebglAddon | null>(null);
|
||||
// Held only so the hover card can be taken down when this pane leaves the
|
||||
// screen — see `Osc8LinkHandler.dismiss`.
|
||||
const osc8LinkHandlerRef = useRef<Osc8LinkHandler | null>(null);
|
||||
const detectorRef = useRef<UrlDetector | null>(null);
|
||||
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
|
||||
const gpuRenderingSetting = useAppState(s => s.appSettings?.terminal_gpu_rendering ?? null);
|
||||
@@ -578,7 +686,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
const syncMouseCapture = useCallback(() => {
|
||||
const term = termRef.current;
|
||||
if (!term) return;
|
||||
const captured = term.modes.mouseTrackingMode !== "none";
|
||||
const captured = terminalTracksMouse(term);
|
||||
if (captured === mouseCapturedRef.current) return;
|
||||
mouseCapturedRef.current = captured;
|
||||
setMouseCaptured(captured);
|
||||
@@ -625,9 +733,15 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
macOptionClickForcesSelection: true,
|
||||
// OSC 8 hyperlinks — the form Claude Code prints its links in, and the
|
||||
// one `WebLinksAddon` structurally cannot match. See
|
||||
// `createOsc8LinkHandler`, including why the gesture is the same
|
||||
// Shift/Option the line above is about.
|
||||
linkHandler: createOsc8LinkHandler(() => term.element ?? null),
|
||||
// `createOsc8LinkHandler`, including why opening one while a program
|
||||
// holds the mouse needs the same Shift/Option the line above is about.
|
||||
// Both arguments are getters because neither answer exists yet: the
|
||||
// element arrives with `term.open()`, and the mouse mode changes
|
||||
// whenever the container prints a DECSET.
|
||||
linkHandler: (osc8LinkHandlerRef.current = createOsc8LinkHandler(
|
||||
() => term.element ?? null,
|
||||
() => terminalTracksMouse(term),
|
||||
)),
|
||||
fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', Menlo, Monaco, monospace",
|
||||
theme: {
|
||||
background: "#0d1117",
|
||||
@@ -968,9 +1082,19 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
webglRef.current = null;
|
||||
term.dispose();
|
||||
termRef.current = null;
|
||||
osc8LinkHandlerRef.current = null;
|
||||
};
|
||||
}, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// A hover card only ever clears on a *pointer* leaving the link, so switching
|
||||
// tabs from the keyboard leaves one hanging over a pane nobody is looking at,
|
||||
// to be found still there on the way back. Hiding the wrapper does not fire
|
||||
// `mouseleave`, so nothing else would.
|
||||
useEffect(() => {
|
||||
if (active) return;
|
||||
osc8LinkHandlerRef.current?.dismiss();
|
||||
}, [active]);
|
||||
|
||||
// Manage WebGL lifecycle and re-fit when tab becomes active.
|
||||
// Only the active terminal holds a WebGL context to avoid exhausting
|
||||
// the browser's limited pool (~8-16 contexts).
|
||||
|
||||
Reference in New Issue
Block a user