You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Make URLs in terminal output clickable — hover to underline, click (or Ctrl+click) to open in the default browser. Two distinct sources need covering: OSC 8 hyperlinks (explicitly marked up by the program) and plain-text URLs (detected by pattern in ordinary output).
Claude Code prints URLs constantly — auth flows, docs links, PR and issue URLs, localhost dev servers — and today every one of them has to be selected and copied by hand.
Current state, precisely
Worth stating exactly, because it is not "nothing works":
OSC 8 already half-works, via an ugly default. The vendored xterm.js carries OscLinkService and registers OscLinkProvider unconditionally, so a program emitting ESC ] 8 ; ; https://… ST already produces a hoverable, clickable link. But linkHandler is left at its default null, so activation falls through to xterm's built-in handler, which:
pops a confirm() dialog reading "Do you want to navigate to <url>?", and
then navigates via the page rather than the OS.
Since CoreWebView2.NewWindowRequested is not handled anywhere, that navigation opens a popup WebView2 window inside CSM instead of the user's browser. So the feature exists, looks broken, and lands in the wrong place.
Plain-text URLs do not work at all. Detecting a bare https://… in output requires the web-links addon, and the only addon bundled in Assets/ is xterm-addon-fit.js. Nothing registers a link provider for unmarked text.
Desired behavior
A URL in output — whether OSC 8 marked-up or plain text — is underlined on hover with a pointer cursor.
Activating it opens the URL in the OS default browser, not in a WebView2 popup and not in the terminal pane.
No confirm() dialog. The activation gesture is the confirmation.
Only http and https open. Anything else is inert — not offered, not launched.
Activation gesture is consistent with the rest of the app and does not eat ordinary terminal interaction. Plain click is what users expect from a link, but it collides with click-to-select-text and with click-to-activate-pane (which is a real mechanism here, see notes). Ctrl+click is the safer convention and matches VS Code. Worth picking one deliberately and documenting it.
Multi-line / wrapped URLs: a URL that xterm wrapped across rows should still activate as one link, or be left inert — never activate a truncated prefix.
Key interfaces
RunInstance.TryGetLaunchableUrl is the existing, tested gate for exactly this decision and should be reused rather than reimplemented. It requires an absolute URI, rejects every scheme but http/https, and returns Uri.AbsoluteUri so the value inspected and the value launched are byte-identical. PostRunUrlTests already covers non-http schemes and scheme-less input. It currently lives on RunInstance; if a second caller appears it likely wants promoting to a shared service, but the logic must not be duplicated.
The page → WPF message channel. The existing pattern is: the page posts {type: "…"} via window.chrome.webview.postMessage, TerminalBridge switches on the type. A new openUrl message fits alongside filesDropped / getClipboard. Activation must not be handled page-side by window.open.
linkHandler on the Terminal options object is the hook for OSC 8 activation (activate / hover / leave). Note its allowNonHttpProtocols flag exists and should stay off — that is xterm's own http/https restriction and it should remain a second line of defence behind our own gate.
A web-links equivalent for plain text. Either vendor @xterm/addon-web-links into Assets/ alongside the fit addon and register it in the csproj Content list, or register a custom link provider via registerLinkProvider. Vendoring is consistent with how xterm.js and the fit addon are already shipped; note the repo's OSS-only dependency constraint — verify the licence before adding (the addon is MIT, but verify at the version vendored).
CoreWebView2.NewWindowRequested should be handled and cancelled regardless of this feature, so no page-initiated navigation can ever open an in-app popup window. That is a small hardening fix that stands on its own.
Security notes
This is the same trust boundary the PostRunUrl work already reasoned about, and the reasoning transfers directly: ShellExecute on an unvalidated string will happily run a local .exe, a .ps1, a UNC path, or any registered protocol handler. The difference in blast radius is worth naming:
PostRunUrl is a value the user stored in their own run-command config (though even that is untrusted, since ImportExportService will deserialize a whole AppState from any JSON file the user points at).
A terminal URL is arbitrary text that any program printed, including a remote host over SSH, a cat of a hostile file, or a compromised dependency's build output. It is strictly less trustworthy.
So: validate through TryGetLaunchableUrl, launch the normalized AbsoluteUri, and never pass the raw matched text to Process.Start. Log rejections to crash.log the way the PostRunUrl path does, and do not pop UI on rejection.
One further consideration specific to detection: a plain-text matcher decides link boundaries itself, so the string it hands over may differ from what the user visually thinks they clicked. Trailing punctuation, wrapped rows, and lookalike characters in the host portion are all ways for the launched URL to differ from the apparent one. Keeping the matcher conservative matters more than catching every possible URL shape.
Acceptance criteria
A plain-text https://… in terminal output underlines on hover and opens in the default browser on activation.
An OSC 8 hyperlink does the same, with noconfirm() dialog and no in-app popup window.
Non-http/https URLs (file:, ftp:, custom protocol handlers) and scheme-less text (localhost:5173) do not activate.
The launched string is the normalized AbsoluteUri from the shared gate, not the raw matched text.
NewWindowRequested is cancelled, verified by confirming no popup appears for a page-initiated navigation.
Clicking a link does not break click-to-activate-pane or click-to-select-text.
Works in both terminal.html and terminal-transparent.html (they share terminal-init.js, so this should be automatic — verify rather than assume).
Any vendored addon is licence-verified and registered in the csproj Content list so it ships.
Notes / gotchas
Click handling in a pane is already load-bearing. Pane activation is driven by a capture-phase mousedown posted from the page, throttled to 300ms, and that handler also calls fitAddon.fit(). A link click will pass through the same path — the two must coexist, and link activation must not suppress activation of the pane it was clicked in.
WebView2 is an HwndHost, so any hover affordance or link tooltip must be drawn inside the page, not as WPF content over the pane. WPF cannot draw over a terminal pane at all, whatever Panel.ZIndex says.
Default context menus are disabled on the WebView (AreDefaultContextMenusEnabled = false), so there is no built-in "Copy link address". If a right-click affordance on links is wanted, it has to be built.
Out of scope
Clickable file paths (click a path in output to open it in an editor). Genuinely useful and a natural follow-up, but it needs an editor-resolution story, and path text is far more ambiguous to detect than a URL — worth its own issue rather than smuggling in here.
Clickable line/column references from compiler and stack-trace output (same reasoning, larger).
Rendering link previews or fetching anything from a URL.
Summary
Make URLs in terminal output clickable — hover to underline, click (or Ctrl+click) to open in the default browser. Two distinct sources need covering: OSC 8 hyperlinks (explicitly marked up by the program) and plain-text URLs (detected by pattern in ordinary output).
Claude Code prints URLs constantly — auth flows, docs links, PR and issue URLs, localhost dev servers — and today every one of them has to be selected and copied by hand.
Current state, precisely
Worth stating exactly, because it is not "nothing works":
OSC 8 already half-works, via an ugly default. The vendored xterm.js carries
OscLinkServiceand registersOscLinkProviderunconditionally, so a program emittingESC ] 8 ; ; https://… STalready produces a hoverable, clickable link. ButlinkHandleris left at its defaultnull, so activation falls through to xterm's built-in handler, which:confirm()dialog reading "Do you want to navigate to <url>?", andSince
CoreWebView2.NewWindowRequestedis not handled anywhere, that navigation opens a popup WebView2 window inside CSM instead of the user's browser. So the feature exists, looks broken, and lands in the wrong place.Plain-text URLs do not work at all. Detecting a bare
https://…in output requires the web-links addon, and the only addon bundled inAssets/isxterm-addon-fit.js. Nothing registers a link provider for unmarked text.Desired behavior
confirm()dialog. The activation gesture is the confirmation.httpandhttpsopen. Anything else is inert — not offered, not launched.Key interfaces
RunInstance.TryGetLaunchableUrlis the existing, tested gate for exactly this decision and should be reused rather than reimplemented. It requires an absolute URI, rejects every scheme buthttp/https, and returnsUri.AbsoluteUriso the value inspected and the value launched are byte-identical.PostRunUrlTestsalready covers non-http schemes and scheme-less input. It currently lives onRunInstance; if a second caller appears it likely wants promoting to a shared service, but the logic must not be duplicated.{type: "…"}viawindow.chrome.webview.postMessage,TerminalBridgeswitches on the type. A newopenUrlmessage fits alongsidefilesDropped/getClipboard. Activation must not be handled page-side bywindow.open.linkHandleron theTerminaloptions object is the hook for OSC 8 activation (activate/hover/leave). Note itsallowNonHttpProtocolsflag exists and should stay off — that is xterm's own http/https restriction and it should remain a second line of defence behind our own gate.@xterm/addon-web-linksintoAssets/alongside the fit addon and register it in the csprojContentlist, or register a custom link provider viaregisterLinkProvider. Vendoring is consistent with how xterm.js and the fit addon are already shipped; note the repo's OSS-only dependency constraint — verify the licence before adding (the addon is MIT, but verify at the version vendored).CoreWebView2.NewWindowRequestedshould be handled and cancelled regardless of this feature, so no page-initiated navigation can ever open an in-app popup window. That is a small hardening fix that stands on its own.Security notes
This is the same trust boundary the
PostRunUrlwork already reasoned about, and the reasoning transfers directly:ShellExecuteon an unvalidated string will happily run a local.exe, a.ps1, a UNC path, or any registered protocol handler. The difference in blast radius is worth naming:PostRunUrlis a value the user stored in their own run-command config (though even that is untrusted, sinceImportExportServicewill deserialize a wholeAppStatefrom any JSON file the user points at).catof a hostile file, or a compromised dependency's build output. It is strictly less trustworthy.So: validate through
TryGetLaunchableUrl, launch the normalizedAbsoluteUri, and never pass the raw matched text toProcess.Start. Log rejections tocrash.logthe way thePostRunUrlpath does, and do not pop UI on rejection.One further consideration specific to detection: a plain-text matcher decides link boundaries itself, so the string it hands over may differ from what the user visually thinks they clicked. Trailing punctuation, wrapped rows, and lookalike characters in the host portion are all ways for the launched URL to differ from the apparent one. Keeping the matcher conservative matters more than catching every possible URL shape.
Acceptance criteria
https://…in terminal output underlines on hover and opens in the default browser on activation.confirm()dialog and no in-app popup window.file:,ftp:, custom protocol handlers) and scheme-less text (localhost:5173) do not activate.AbsoluteUrifrom the shared gate, not the raw matched text.NewWindowRequestedis cancelled, verified by confirming no popup appears for a page-initiated navigation.terminal.htmlandterminal-transparent.html(they shareterminal-init.js, so this should be automatic — verify rather than assume).Contentlist so it ships.Notes / gotchas
mousedownposted from the page, throttled to 300ms, and that handler also callsfitAddon.fit(). A link click will pass through the same path — the two must coexist, and link activation must not suppress activation of the pane it was clicked in.HwndHost, so any hover affordance or link tooltip must be drawn inside the page, not as WPF content over the pane. WPF cannot draw over a terminal pane at all, whateverPanel.ZIndexsays.AreDefaultContextMenusEnabled = false), so there is no built-in "Copy link address". If a right-click affordance on links is wanted, it has to be built.Out of scope