From dc9799f6fc7e8d518666cf4c33251dd194bc2695 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:54:49 +0200 Subject: [PATCH] Open OSC 8 terminal links the same way as plain URLs (#5388) * Open OSC 8 terminal hyperlinks like plain URLs Clicking a link a CLI printed with OSC 8 (gh, npm, ls --hyperlink) showed xterm's "Do you want to navigate to" prompt and then opened a bare Paseo window, while the same URL printed as plain text opened in the system browser. The terminal runtime now gives xterm a link handler that sends OSC 8 links to the same external opener as plain URLs. Closes #4768 Co-authored-by: liujin0506 * Name the hyperlink and line writes in the terminal link test --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> Co-authored-by: liujin0506 --- .../terminal-emulator-runtime.browser.test.ts | 73 ++++++++++++++++++- .../runtime/terminal-emulator-runtime.ts | 13 ++-- 2 files changed, 78 insertions(+), 8 deletions(-) diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts index 58dfd7ff38..10a2b0b632 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts @@ -1,4 +1,4 @@ -import { page } from "@vitest/browser/context"; +import { page, userEvent } from "@vitest/browser/context"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { TerminalInputModeState } from "@getpaseo/protocol/terminal-input-mode"; import { encodeTerminalOutput, TerminalEmulatorRuntime } from "./terminal-emulator-runtime"; @@ -40,6 +40,7 @@ interface MountedTerminal { sizes: TerminalSize[]; terminalKeys: TerminalKeyRecord[]; inputModeChanges: TerminalInputModeState[]; + openedUrls: string[]; } const mountedTerminals: MountedTerminal[] = []; @@ -95,6 +96,7 @@ function createTerminalHost(input: { const inputs: string[] = []; const terminalKeys: TerminalKeyRecord[] = []; const inputModeChanges: TerminalInputModeState[] = []; + const openedUrls: string[] = []; const runtime = new TerminalEmulatorRuntime(); runtime.setCallbacks({ callbacks: { @@ -110,6 +112,9 @@ function createTerminalHost(input: { onInputModeChange: (state) => { inputModeChanges.push(state); }, + onOpenExternalUrl: (url) => { + openedUrls.push(url); + }, }, }); runtime.mount({ @@ -124,7 +129,16 @@ function createTerminalHost(input: { }, }); - const mounted = { host, root, runtime, inputs, sizes, terminalKeys, inputModeChanges }; + const mounted = { + host, + root, + runtime, + inputs, + sizes, + terminalKeys, + inputModeChanges, + openedUrls, + }; mountedTerminals.push(mounted); return mounted; } @@ -188,7 +202,43 @@ function dispatchTerminalKey(input: { ); } +/** An OSC 8 hyperlink, the escape sequence CLIs such as gh and ls --hyperlink print. */ +function hyperlink(input: { url: string; text: string }): string { + return `\x1b]8;;${input.url}\x1b\\${input.text}\x1b]8;;\x1b\\`; +} + +function writeLines(mounted: MountedTerminal, lines: string[]): Promise { + return new Promise((resolve) => { + mounted.runtime.write({ + data: terminalOutput(lines.map((line) => `${line}\r\n`).join("")), + onCommitted: resolve, + }); + }); +} + +async function clickTerminalLink(input: { + host: HTMLElement; + row: number; + col: number; +}): Promise { + const terminal = getBrowserTerminal(); + const screen = input.host.querySelector(".xterm-screen"); + if (!screen) { + throw new Error("Expected xterm screen to be mounted"); + } + const position = { + x: (input.col + 0.5) * (screen.clientWidth / terminal.cols), + y: (input.row + 0.5) * (screen.clientHeight / terminal.rows), + }; + await userEvent.hover(screen, { position }); + await waitFor({ + predicate: () => input.host.querySelector(".xterm-cursor-pointer") !== null, + }); + await userEvent.click(screen, { position }); +} + afterEach(() => { + vi.restoreAllMocks(); for (const mounted of mountedTerminals.splice(0)) { mounted.runtime.unmount(); mounted.root.remove(); @@ -526,4 +576,23 @@ describe("terminal emulator runtime in a real browser", () => { expect(reset).not.toHaveBeenCalled(); }); + + it("opens OSC 8 hyperlinks the same way as plain-text URLs", async () => { + await page.viewport(900, 600); + const mounted = createTerminalHost({ width: 720, height: 360 }); + const confirm = vi.spyOn(window, "confirm").mockReturnValue(false); + + await waitFor({ predicate: () => mounted.sizes.length > 0 }); + + await writeLines(mounted, [ + hyperlink({ url: "https://example.com/osc8", text: "example" }), + "https://example.com/plain", + ]); + + await clickTerminalLink({ host: mounted.host, row: 1, col: 4 }); + await clickTerminalLink({ host: mounted.host, row: 0, col: 2 }); + + expect(mounted.openedUrls).toEqual(["https://example.com/plain", "https://example.com/osc8"]); + expect(confirm).not.toHaveBeenCalled(); + }); }); diff --git a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts index efa6048dee..4b7afb74c2 100644 --- a/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts +++ b/packages/app/src/terminal/runtime/terminal-emulator-runtime.ts @@ -434,6 +434,10 @@ export class TerminalEmulatorRuntime { this.inputModeTracker.reset(); this.emitInputModeChange(); + const openExternalLink = (event: MouseEvent, uri: string) => { + event.preventDefault(); + void this.callbacks.onOpenExternalUrl?.(uri); + }; const terminal = new Terminal({ allowProposedApi: true, convertEol: false, @@ -441,6 +445,8 @@ export class TerminalEmulatorRuntime { cursorStyle: "bar", fontFamily: resolveTerminalFontFamily(input.fontFamily), fontSize: resolveTerminalFontSize(input.fontSize), + // OSC 8 hyperlinks; without a handler xterm prompts and calls window.open(). + linkHandler: { activate: openExternalLink }, lineHeight: 1.0, macOptionIsMeta: true, minimumContrastRatio: 1, @@ -457,12 +463,7 @@ export class TerminalEmulatorRuntime { let imageAddon: ImageAddon | null = null; terminal.loadAddon(fitAddon); terminal.loadAddon(unicode11Addon); - terminal.loadAddon( - new WebLinksAddon((event, uri) => { - event.preventDefault(); - void this.callbacks.onOpenExternalUrl?.(uri); - }), - ); + terminal.loadAddon(new WebLinksAddon(openExternalLink)); const localFileLinkProvider = terminal.registerLinkProvider( createTerminalLocalFileLinkProvider(terminal, { resolveLink: async (source) => {