Skip to content

Clickable links in terminal output (OSC 8 + plain-text URLs), opened in the default browser #133

Description

@AThraen

This was generated by AI during triage.

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 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 no confirm() 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.
  • The pinnable per-session browser pane (Pinnable browser pane showing a per-session URL #71) — that is about displaying a URL inside CSM; this is about handing a URL off to the OS.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions