From 9f47f65173f424d4d62b16c3ab2f9e0b27748500 Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Fri, 25 Sep 2026 17:18:11 +0000 Subject: [PATCH 1/2] Fix OSC 8 terminal links with Wave link handling --- frontend/app/view/term/term-links.test.ts | 42 +++++++++++++++++++++++ frontend/app/view/term/term-links.ts | 20 +++++++++++ frontend/app/view/term/termwrap.ts | 41 ++++++---------------- 3 files changed, 73 insertions(+), 30 deletions(-) create mode 100644 frontend/app/view/term/term-links.test.ts create mode 100644 frontend/app/view/term/term-links.ts diff --git a/frontend/app/view/term/term-links.test.ts b/frontend/app/view/term/term-links.test.ts new file mode 100644 index 0000000000..16bca07cde --- /dev/null +++ b/frontend/app/view/term/term-links.test.ts @@ -0,0 +1,42 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; +import { makeTermLinkHandlers } from "./term-links"; + +function linkEvent(metaKey = false, ctrlKey = false): MouseEvent { + return { metaKey, ctrlKey, preventDefault: vi.fn(), clientX: 12, clientY: 34 } as unknown as MouseEvent; +} + +describe("terminal link handlers", () => { + it.each([ + { isMacOS: true, modifier: "metaKey" }, + { isMacOS: false, modifier: "ctrlKey" }, + ])("only opens $modifier-clicked links on the corresponding platform", ({ isMacOS }) => { + const openUri = vi.fn(); + const handlers = makeTermLinkHandlers(isMacOS, openUri, vi.fn()); + const plainClick = linkEvent(); + handlers.activate(plainClick, "https://example.com/first"); + expect(plainClick.preventDefault).toHaveBeenCalledOnce(); + expect(openUri).not.toHaveBeenCalled(); + + const wrongModifier = linkEvent(!isMacOS, isMacOS); + handlers.activate(wrongModifier, "https://example.com/second"); + expect(openUri).not.toHaveBeenCalled(); + + const rightModifier = linkEvent(isMacOS, !isMacOS); + handlers.activate(rightModifier, "https://example.com/third"); + expect(openUri).toHaveBeenCalledExactlyOnceWith("https://example.com/third"); + }); + + it("reports hovered links and clears them on leave", () => { + const onHover = vi.fn(); + const handlers = makeTermLinkHandlers(false, vi.fn(), onHover); + handlers.hover(linkEvent(), "https://example.com"); + handlers.leave(); + expect(onHover.mock.calls).toEqual([ + ["https://example.com", 12, 34], + [null, 0, 0], + ]); + }); +}); diff --git a/frontend/app/view/term/term-links.ts b/frontend/app/view/term/term-links.ts new file mode 100644 index 0000000000..c8f4583997 --- /dev/null +++ b/frontend/app/view/term/term-links.ts @@ -0,0 +1,20 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +export function makeTermLinkHandlers( + isMacOS: boolean, + openUri: (uri: string) => void, + onHover: (uri: string | null, x: number, y: number) => void +) { + return { + activate: (event: MouseEvent, uri: string) => { + event.preventDefault(); + if (!(isMacOS ? event.metaKey : event.ctrlKey)) { + return; + } + openUri(uri); + }, + hover: (event: MouseEvent, uri: string) => onHover(uri, event.clientX, event.clientY), + leave: () => onHover(null, 0, 0), + }; +} diff --git a/frontend/app/view/term/termwrap.ts b/frontend/app/view/term/termwrap.ts index d10b600459..8cd2cccdfd 100644 --- a/frontend/app/view/term/termwrap.ts +++ b/frontend/app/view/term/termwrap.ts @@ -36,6 +36,7 @@ import { isClaudeCodeCommand, type ShellIntegrationStatus, } from "./osc-handlers"; +import { makeTermLinkHandlers } from "./term-links"; import { bufferLinesToText, createTempFileFromBlob, @@ -143,42 +144,22 @@ export class TermWrap { this.lastCommandAtom = jotai.atom(null) as jotai.PrimitiveAtom; this.claudeCodeActiveAtom = jotai.atom(false); this.webglEnabledAtom = jotai.atom(false) as jotai.PrimitiveAtom; - this.terminal = new Terminal(options); + const linkHandlers = makeTermLinkHandlers( + PLATFORM === PlatformMacOS, + (uri) => fireAndForget(() => openLink(uri)), + (uri, x, y) => { + this.hoveredLinkUri = uri; + this.onLinkHover?.(uri, x, y); + } + ); + this.terminal = new Terminal({ ...options, linkHandler: linkHandlers }); this.fitAddon = new FitAddon(); this.serializeAddon = new SerializeAddon(); this.searchAddon = new SearchAddon(); this.terminal.loadAddon(this.searchAddon); this.terminal.loadAddon(this.fitAddon); this.terminal.loadAddon(this.serializeAddon); - this.terminal.loadAddon( - new WebLinksAddon( - (e, uri) => { - e.preventDefault(); - switch (PLATFORM) { - case PlatformMacOS: - if (e.metaKey) { - fireAndForget(() => openLink(uri)); - } - break; - default: - if (e.ctrlKey) { - fireAndForget(() => openLink(uri)); - } - break; - } - }, - { - hover: (e, uri) => { - this.hoveredLinkUri = uri; - this.onLinkHover?.(uri, e.clientX, e.clientY); - }, - leave: () => { - this.hoveredLinkUri = null; - this.onLinkHover?.(null, 0, 0); - }, - } - ) - ); + this.terminal.loadAddon(new WebLinksAddon(linkHandlers.activate, linkHandlers)); this.setTermRenderer(WebGLSupported && waveOptions.useWebGl ? "webgl" : "dom"); // Register OSC handlers this.terminal.parser.registerOscHandler(7, (data: string) => { From 825e2e19a9a54919eca2d13c3989df6840eda0eb Mon Sep 17 00:00:00 2001 From: Mike Sawka Date: Fri, 25 Sep 2026 18:23:59 +0000 Subject: [PATCH 2/2] Show OSC 8 link destinations in terminal hover tooltips --- frontend/app/view/term/term-links.test.ts | 10 +++++---- frontend/app/view/term/term-links.ts | 7 ++++--- frontend/app/view/term/term-tooltip.test.tsx | 22 ++++++++++++++++++++ frontend/app/view/term/term-tooltip.tsx | 20 ++++++++++++++---- frontend/app/view/term/termwrap.ts | 19 ++++++++++++----- 5 files changed, 62 insertions(+), 16 deletions(-) create mode 100644 frontend/app/view/term/term-tooltip.test.tsx diff --git a/frontend/app/view/term/term-links.test.ts b/frontend/app/view/term/term-links.test.ts index 16bca07cde..42a895200a 100644 --- a/frontend/app/view/term/term-links.test.ts +++ b/frontend/app/view/term/term-links.test.ts @@ -29,14 +29,16 @@ describe("terminal link handlers", () => { expect(openUri).toHaveBeenCalledExactlyOnceWith("https://example.com/third"); }); - it("reports hovered links and clears them on leave", () => { + it("shows the destination for OSC 8 links but keeps plain URL hover unchanged", () => { const onHover = vi.fn(); const handlers = makeTermLinkHandlers(false, vi.fn(), onHover); - handlers.hover(linkEvent(), "https://example.com"); + handlers.hover(linkEvent(), "https://example.com/visible"); + handlers.osc8Hover(linkEvent(), "https://example.com/hidden"); handlers.leave(); expect(onHover.mock.calls).toEqual([ - ["https://example.com", 12, 34], - [null, 0, 0], + ["https://example.com/visible", 12, 34, false], + ["https://example.com/hidden", 12, 34, true], + [null, 0, 0, false], ]); }); }); diff --git a/frontend/app/view/term/term-links.ts b/frontend/app/view/term/term-links.ts index c8f4583997..ceaf15c7ea 100644 --- a/frontend/app/view/term/term-links.ts +++ b/frontend/app/view/term/term-links.ts @@ -4,7 +4,7 @@ export function makeTermLinkHandlers( isMacOS: boolean, openUri: (uri: string) => void, - onHover: (uri: string | null, x: number, y: number) => void + onHover: (uri: string | null, x: number, y: number, showUrl: boolean) => void ) { return { activate: (event: MouseEvent, uri: string) => { @@ -14,7 +14,8 @@ export function makeTermLinkHandlers( } openUri(uri); }, - hover: (event: MouseEvent, uri: string) => onHover(uri, event.clientX, event.clientY), - leave: () => onHover(null, 0, 0), + hover: (event: MouseEvent, uri: string) => onHover(uri, event.clientX, event.clientY, false), + osc8Hover: (event: MouseEvent, uri: string) => onHover(uri, event.clientX, event.clientY, true), + leave: () => onHover(null, 0, 0, false), }; } diff --git a/frontend/app/view/term/term-tooltip.test.tsx b/frontend/app/view/term/term-tooltip.test.tsx new file mode 100644 index 0000000000..15213c37e1 --- /dev/null +++ b/frontend/app/view/term/term-tooltip.test.tsx @@ -0,0 +1,22 @@ +// Copyright 2026, Command Line Inc. +// SPDX-License-Identifier: Apache-2.0 + +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, it } from "vitest"; +import { TermLinkTooltipContent } from "./term-tooltip"; + +describe("TermLinkTooltipContent", () => { + it("keeps the existing modifier-click hint for plain URLs", () => { + const markup = renderToStaticMarkup(); + expect(markup).toContain("-click to open link"); + expect(markup).not.toContain("break-all"); + }); + + it("shows and safely escapes the destination for OSC 8 links", () => { + const markup = renderToStaticMarkup(); + expect(markup).toContain("-click to open link"); + expect(markup).toContain("https://example.com/?next=<script>&x=1"); + expect(markup).toContain("break-all"); + expect(markup).not.toContain("