Skip to content

fix(app): open OSC 8 terminal hyperlinks with the external opener - #4472

Open
liujin0506 wants to merge 3 commits into
getpaseo:mainfrom
liujin0506:fix/terminal-osc8-link-handler
Open

fix(app): open OSC 8 terminal hyperlinks with the external opener#4472
liujin0506 wants to merge 3 commits into
getpaseo:mainfrom
liujin0506:fix/terminal-osc8-link-handler

Conversation

@liujin0506

@liujin0506 liujin0506 commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Linked issue

No issue filed — I hit this myself on the desktop app and went straight to the repro below. Happy to open one if you'd rather track it separately.

Type of change

  • Bug fix

Reasoning

Clicking a link that a CLI printed in the Paseo terminal pops a Do you want to navigate to https://…? confirm dialog and then opens the page in a stray, chromeless Electron window — not in my browser. Clicking a bare URL one line above behaves correctly and opens the system browser. Same terminal, same session, two different behaviors.

The difference is how the link got into the buffer. Tools like Claude Code, gh and vite print clickable text with the OSC 8 escape sequence rather than a raw URL, and xterm hands OSC 8 links to options.linkHandler only — WebLinksAddon never sees them, it just matches bare URLs with a regex. TerminalEmulatorRuntime installed a WebLinksAddon handler but no linkHandler, so every OSC 8 link fell through to xterm's built-in fallback:

// @xterm/xterm, OscLinkProvider
linkHandler ? linkHandler.activate(event, text, range) : defaultActivate(event, text);
// defaultActivate: confirm("Do you want to navigate to …") then window.open()

window.open is where the stray window comes from: the desktop main window installs no setWindowOpenHandler (the one in packages/desktop/src/main.ts is only attached to browser-tab webviews), so Electron answers it by creating a bare BrowserWindow. The URL never reaches openExternalUrl, so shell.openExternal is never called.

That fallback also skips a security check. openExternalUrl only lets http:/https: through; xterm's fallback filters nothing, so an OSC 8 sequence in terminal output could hand any URI straight to window.open.

Installing a linkHandler that shares the WebLinksAddon callback puts both kinds of link back on the app's opener, with its allowlist.

Goals

  • OSC 8 hyperlinks open through the same opener as plain URLs (shell.openExternal on desktop, Linking.openURL on native, window.open on plain web)
  • The opener's http(s) allowlist applies to OSC 8 links too
  • One handler for both link kinds, so they cannot drift apart again
  • A regression test that fails if the handler stops being installed

Non-goals

  • Does not make terminal links respect the Service URLs setting. That setting is read only by openServiceUrl(), which is only called from the workspace scripts menu; terminal links have never consulted it and wiring them up is a product decision, not part of this bug.
  • Does not add a setWindowOpenHandler to the desktop main window. That gap is real — any stray window.open in the renderer still becomes a bare window — but it is a separate change; this PR fixes the source instead.
  • Does not change WebLinksAddon behavior. Plain URLs took the correct path before and after; they just share the callback now.

QA

Linux x86_64, Node v24.14.0. Branch on main @ 229f3cd.

Before (desktop app, 0.7.x). In a Paseo terminal:

printf '\e]8;;https://example.com\e\\click-me\e]8;;\e\\\n'   # OSC 8 link
echo https://example.com                                     # plain URL

Clicking click-me shows Do you want to navigate to https://example.com? and then opens the page in a separate bare window. Clicking the plain URL on the next line opens the system browser. Confirmed on the shipped desktop app before touching the code.

Root cause, confirmed in the shipped bundle (@getpaseo/server/dist/server/web-ui/.../index-*.js, 0.7.2), the OSC link provider and its fallback:

r?r.activate(e,t,n):V(e,t)   // r = options.linkHandler
function V(e,t){if(confirm(`Do you want to navigate to ${t}?…`)){let e=window.open();…e.location.href=t}}

After. New browser-mode test that mounts the real runtime in real Chromium, writes the OSC 8 sequence, waits for it to render, and then clicks it the way xterm expects: mousemove to resolve the hovered link, then mousedown/mouseup on the cell the link occupies. It asserts the URL recorded by onOpenExternalUrl.

Red/green — with linkHandler: this.linkHandler deleted from the Terminal options, the click records nothing and the test fails waiting for it; restored, the browser suite passes 25/25.

Two notes for anyone else poking at xterm links in tests: the rendered span's own bounding rect is not clickable-by-hover — it reports a rect one cell below the row it belongs to (span top 389 vs. screen top 364, cell height 15), so getCoords resolves row 3 while the link is on row 1. Deriving coordinates from the screen element's rect over terminal.rows/cols hits the right cell. And xterm-cursor-pointer on the screen element is xterm's own signal that its async link providers have answered, so the test waits on that instead of sleeping.

$ npm run build:server && npm run typecheck
exit 0 (all workspaces)

$ npm run lint
Found 0 warnings and 0 errors.  (4100 files)

$ npm run format:check
All matched files use the correct format.  (4375 files)

$ npx vitest run --project browser src/terminal/runtime/terminal-emulator-runtime.browser.test.ts
Test Files  1 passed (1)
     Tests  25 passed (25)

$ npm run test --workspace=@getpaseo/app
Test Files  2 failed | 597 passed (599)
     Tests  5093 passed | 25 skipped (5118)

The two failures are src/hooks/use-agent-history.test.ts and src/composer/draft/input-draft.live.test.tsx, both Hook timed out in 10000ms. They fail the same way on a clean main checkout on this machine, share no code with this change, and passed on an earlier run here — my box is just slow enough to trip the 10s hook timeout. Flagging rather than hiding it; CI is the better judge.

Platforms. Tested on web (Chromium, via the repo's browser-mode suite). Not tested by hand on the packaged desktop app, iOS or Android — I don't have a GUI on the machine I develop on. The three surfaces share this exact code path: TerminalEmulatorRuntime is the only place the app constructs Terminal, and the native webview entry drives the same runtime. The desktop before behavior above is from the shipped app; the after behavior is covered by the automated test rather than a manual click, and I'd appreciate a second pair of eyes on a real desktop build.

Checklist

  • Plugin changes follow the SDK import boundaries (n/a — no plugin changes)
  • One focused change
  • npm run typecheck passes
  • npm run lint passes
  • npm run format passes
  • QA evidence
  • Tests added or updated where it made sense

xterm routes OSC 8 hyperlinks — the escape sequence CLIs like Claude
Code, gh and vite use to print clickable text — through
options.linkHandler, and never through WebLinksAddon. The terminal
runtime only installed a WebLinksAddon handler, so those links fell
through to xterm's built-in fallback: a confirm() dialog followed by
window.open.

On the desktop app that fallback is visible. The main window installs no
window-open handler, so Electron answers window.open with a bare popup
window instead of the user's browser, and the URL never reaches
openExternalUrl. Clicking a link a CLI printed opens a stray Paseo
window; clicking a plain URL on the line above opens the browser.

Installing a linkHandler that shares the WebLinksAddon callback puts
both kinds of link back on the app's opener. That also re-applies the
opener's http(s) allowlist: xterm's fallback has none, so an OSC 8
sequence could hand any URI straight to window.open.
@greptile-apps

greptile-apps Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

Routes OSC 8 terminal hyperlinks through the same external URL callback already used for plain URLs.

  • Installs an xterm linkHandler on TerminalEmulatorRuntime.
  • Reuses one activation handler for OSC 8 and WebLinksAddon links.
  • Adds a real-browser regression test that activates the rendered link through xterm's mouse interaction path.

Confidence Score: 5/5

The PR appears safe to merge with no outstanding findings.

The OSC 8 activation path now uses the existing external URL callback, and the regression test exercises the rendered xterm link through browser mouse events; the previously reported test-interface violation is resolved.

Important Files Changed

Filename Overview
packages/app/src/terminal/runtime/terminal-emulator-runtime.ts Installs a shared xterm link activation handler that sends OSC 8 and plain URLs through the application opener.
packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts Adds a real-Chromium regression test that activates an OSC 8 link using xterm's rendered mouse interaction path.

Sequence Diagram

sequenceDiagram
  participant CLI as Terminal process
  participant XT as xterm
  participant RT as TerminalEmulatorRuntime
  participant OP as External URL opener
  CLI->>XT: OSC 8 hyperlink
  XT->>RT: linkHandler.activate(event, URI)
  RT->>OP: onOpenExternalUrl(URI)
  OP-->>OP: Validate HTTP(S) and open externally
Loading

Reviews (3): Last reviewed commit: "Merge branch 'main' into fix/terminal-os..." | Re-trigger Greptile

Comment thread packages/app/src/terminal/runtime/terminal-emulator-runtime.browser.test.ts Outdated
zenjingliu and others added 2 commits September 8, 2026 04:15
The first version of this test read options.linkHandler off the mounted
terminal and called activate() directly. That proved the handler was
installed, but not that a click reaches it, and it needed a conditional
in the test body to narrow the option.

Dispatch the mouse sequence xterm actually listens for instead —
mousemove to resolve the link, then mousedown/mouseup to activate it —
against the cell the link occupies, and assert the recorded URL
unconditionally.

Cell coordinates come from the screen element's rect divided by the
terminal's rows and cols. The rendered span's own rect sits a cell below
the row it belongs to, which is why hovering that rect never resolved a
link; the pointer-cursor class is what xterm uses to announce it has.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant