fix(app): open OSC 8 terminal hyperlinks with the external opener - #4472
Open
liujin0506 wants to merge 3 commits into
Open
fix(app): open OSC 8 terminal hyperlinks with the external opener#4472liujin0506 wants to merge 3 commits into
liujin0506 wants to merge 3 commits into
Conversation
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.
Contributor
|
| 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
Reviews (3): Last reviewed commit: "Merge branch 'main' into fix/terminal-os..." | Re-trigger Greptile
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
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,
ghand vite print clickable text with the OSC 8 escape sequence rather than a raw URL, and xterm hands OSC 8 links tooptions.linkHandleronly —WebLinksAddonnever sees them, it just matches bare URLs with a regex.TerminalEmulatorRuntimeinstalled aWebLinksAddonhandler but nolinkHandler, so every OSC 8 link fell through to xterm's built-in fallback:window.openis where the stray window comes from: the desktop main window installs nosetWindowOpenHandler(the one inpackages/desktop/src/main.tsis only attached to browser-tab webviews), so Electron answers it by creating a bareBrowserWindow. The URL never reachesopenExternalUrl, soshell.openExternalis never called.That fallback also skips a security check.
openExternalUrlonly letshttp:/https:through; xterm's fallback filters nothing, so an OSC 8 sequence in terminal output could hand any URI straight towindow.open.Installing a
linkHandlerthat shares theWebLinksAddoncallback puts both kinds of link back on the app's opener, with its allowlist.Goals
shell.openExternalon desktop,Linking.openURLon native,window.openon plain web)Non-goals
Service URLssetting. That setting is read only byopenServiceUrl(), 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.setWindowOpenHandlerto the desktop main window. That gap is real — any straywindow.openin the renderer still becomes a bare window — but it is a separate change; this PR fixes the source instead.WebLinksAddonbehavior. 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:
Clicking
click-meshowsDo 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: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:
mousemoveto resolve the hovered link, thenmousedown/mouseupon the cell the link occupies. It asserts the URL recorded byonOpenExternalUrl.Red/green — with
linkHandler: this.linkHandlerdeleted from theTerminaloptions, 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
getCoordsresolves row 3 while the link is on row 1. Deriving coordinates from the screen element's rect overterminal.rows/colshits the right cell. Andxterm-cursor-pointeron the screen element is xterm's own signal that its async link providers have answered, so the test waits on that instead of sleeping.The two failures are
src/hooks/use-agent-history.test.tsandsrc/composer/draft/input-draft.live.test.tsx, bothHook timed out in 10000ms. They fail the same way on a cleanmaincheckout 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:
TerminalEmulatorRuntimeis the only place the app constructsTerminal, 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
npm run typecheckpassesnpm run lintpassesnpm run formatpasses