Links in Claude's output are now clickable, via Shift+click (Option+click on macOS).
Why the existing link addon could not do it
Claude Code prints links as OSC 8 hyperlinks whose visible text is hard-wrapped into terminal-width pieces. urlDetector.ts's header already recorded the measurement: against Claude Code 2.1.226 a 346-character sign-in URL arrives as five emissions, each carrying the whole URL in its parameter but ~80 characters on screen. WebLinksAddon regex-matches the painted characters row by row, so against Claude it matches a fragment or nothing — which is why the URL toast exists in the first place.
xterm 5.5's linkHandler hands over the exact OSC 8 parameter, so the slicing stops mattering. WebLinksAddon is unchanged and still covers plain-text URLs in ordinary shell output. Both branches now share one failure reporter and one validator (sanitizeRelayUrl) — no second implementation.
No new key handling
Claude holds ?1000/?1002/?1003, so a plain click goes to Claude. xterm's mousedown handler is:
Holding the force-selection modifier means the event is not cancelled and reaches the link layer. This terminal already sets macOptionClickForcesSelection: true for text selection, so the same gesture that lets you select text now also opens a link, and Claude keeps the mouse.
The hover card is the security half, not decoration
OSC 8 decouples the label from the target completely — a container can print https://claude.ai and link it anywhere. That is strictly worse than the userinfo spoofing urlRelay.ts already guards against, and it invalidates the justification for opening a click without confirmation, written at the WebLinksAddon branch: "a click here is a deliberate act on visible text." True of painted text; false of OSC 8.
Hovering now shows the real origin, in full and never truncated — truncating it is the spoof — with the remainder ellipsized, reusing urlOrigin from urlRelay.ts. A target that fails validation says the link will not open and deliberately echoes nothing of itself, since that is untrusted text headed for a DOM node. Text is set via textContent throughout; there is no innerHTML on this path.
The hint names the modifier for the platform, derived from xterm's own isMac list verbatim, so it cannot tell a Mac user to press a key that does nothing.
Verification
Frontend: 787 passed, 0 failed; tsc --noEmit clean. Tests were written before the implementation and cover refusal of javascript:/file:/userinfo-spoofed targets without reaching the sink, a valid target reaching the opener, a claude.ai-labelled link surfacing https://evil.example.com on hover, the refused-target card not echoing its target, opener failure pushing the toast, and the per-platform hint.
createOsc8LinkHandler is exported and tested directly rather than driven through a rendered terminal: xterm decides when to call linkHandler from cell geometry, and jsdom gives every element a zero-sized box, so a mouse-driven test would assert nothing. Same precedent as the already-exported supersedes.
Not runtime-tested. The Shift+click-while-Claude-holds-the-mouse interaction is reasoned from xterm's source, not observed — that needs the real app.
Known gap:app/src-tauri/web_terminal/terminal.html, the standalone embedded terminal, gets none of this and now differs from the desktop pane.
Links in Claude's output are now clickable, via **Shift+click** (Option+click on macOS).
## Why the existing link addon could not do it
Claude Code prints links as **OSC 8 hyperlinks** whose visible text is hard-wrapped into terminal-width pieces. `urlDetector.ts`'s header already recorded the measurement: against Claude Code 2.1.226 a 346-character sign-in URL arrives as five emissions, each carrying the whole URL in its parameter but ~80 characters on screen. `WebLinksAddon` regex-matches the *painted* characters row by row, so against Claude it matches a fragment or nothing — which is why the URL toast exists in the first place.
xterm 5.5's `linkHandler` hands over the exact OSC 8 parameter, so the slicing stops mattering. `WebLinksAddon` is unchanged and still covers plain-text URLs in ordinary shell output. Both branches now share one failure reporter and one validator (`sanitizeRelayUrl`) — no second implementation.
## No new key handling
Claude holds `?1000/?1002/?1003`, so a plain click goes to Claude. xterm's mousedown handler is:
```js
if (areMouseEventsActive && !shouldForceSelection(e)) { ...; return this.cancel(e) }
// shouldForceSelection(e) => isMac ? e.altKey && macOptionClickForcesSelection : e.shiftKey
```
Holding the force-selection modifier means the event is *not* cancelled and reaches the link layer. This terminal already sets `macOptionClickForcesSelection: true` for text selection, so the same gesture that lets you select text now also opens a link, and Claude keeps the mouse.
## The hover card is the security half, not decoration
OSC 8 decouples the label from the target completely — a container can print `https://claude.ai` and link it anywhere. That is strictly worse than the userinfo spoofing `urlRelay.ts` already guards against, and it invalidates the justification for opening a click without confirmation, written at the WebLinksAddon branch: *"a click here is a deliberate act on visible text."* True of painted text; false of OSC 8.
Hovering now shows the **real origin**, in full and never truncated — truncating it is the spoof — with the remainder ellipsized, reusing `urlOrigin` from `urlRelay.ts`. A target that fails validation says the link will not open and deliberately echoes nothing of itself, since that is untrusted text headed for a DOM node. Text is set via `textContent` throughout; there is no `innerHTML` on this path.
The hint names the modifier for the platform, derived from xterm's own `isMac` list verbatim, so it cannot tell a Mac user to press a key that does nothing.
## Verification
Frontend: **787 passed, 0 failed**; `tsc --noEmit` clean. Tests were written before the implementation and cover refusal of `javascript:`/`file:`/userinfo-spoofed targets without reaching the sink, a valid target reaching the opener, a `claude.ai`-labelled link surfacing `https://evil.example.com` on hover, the refused-target card not echoing its target, opener failure pushing the toast, and the per-platform hint.
`createOsc8LinkHandler` is exported and tested directly rather than driven through a rendered terminal: xterm decides when to call `linkHandler` from cell geometry, and jsdom gives every element a zero-sized box, so a mouse-driven test would assert nothing. Same precedent as the already-exported `supersedes`.
**Not runtime-tested.** The Shift+click-while-Claude-holds-the-mouse interaction is reasoned from xterm's source, not observed — that needs the real app.
**Known gap:** `app/src-tauri/web_terminal/terminal.html`, the standalone embedded terminal, gets none of this and now differs from the desktop pane.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Claude Code prints links as OSC 8 hyperlinks whose visible text is
hard-wrapped into terminal-width pieces -- urlDetector's header records a
346-character sign-in URL arriving as five emissions, each carrying the
whole URL in its parameter and about 80 characters on screen. WebLinksAddon
regex-matches the painted characters row by row, so against Claude it
matches a fragment or nothing, which is why the URL toast exists.
xterm 5.5 hands over the exact parameter through `linkHandler`, so the
slicing stops mattering. WebLinksAddon stays for plain-text URLs in
ordinary shell output; the two cover different cases and neither replaces
the other. Both now share one failure reporter and one validator.
No new key handling was needed. xterm's mousedown handler is
`if (areMouseEventsActive && !shouldForceSelection(e)) return cancel(e)`,
so holding the force-selection modifier lets the event reach the link
layer while Claude still holds the mouse -- Shift+click, or Option+click on
macOS, which this terminal already enables for text selection.
The hover card is the security half rather than decoration. OSC 8
decouples the label from the target completely: a container can print
`https://claude.ai` and link it anywhere, which is strictly worse than the
userinfo spoofing already guarded against and which invalidated the
justification for opening a click without confirmation ("a deliberate act
on visible text"). Hovering now shows the real origin, in full and never
truncated, because truncating it is the spoof. A target that fails
validation says so and deliberately echoes nothing of itself.
The hint names the modifier for the platform, from xterm's own `isMac`
list, so it cannot tell a Mac user to press a key that does nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review of this branch found its central premise was false. The claim was
that xterm cancels a mousedown before the link layer while a program holds
the mouse, so only a Shift+click could reach a link. None of that holds:
`cancel()` is `if (this.options.cancelEvents || force)` and `cancelEvents`
defaults to false and is never set here, so it does nothing; the mouse
reporting listeners bind to `.xterm` while the Linkifier is constructed on
`screenElement`, a descendant, so the link layer sees the event first
regardless; and `_handleMouseUp` checks neither the modifier nor the
button before calling `activate`.
So a plain click opened the link, and so did a right-click. That is not a
missing convenience. OSC 8 lets the container wrap any clickable TUI widget
-- a menu row, a "1. Yes", a file chip -- in a link to anywhere, and
because the mouse report still reaches the program afterwards the widget
responds too and nothing looks wrong. The hover card was the only
mitigation, and it assumes a user deliberately reaching for a link.
`opensOnClick` is now a real gate: primary button only, and while a program
tracks the mouse the force-selection modifier is required -- the gesture
the user already has for "this click is for the terminal, not the program".
With nothing tracking, a bare click opens, which is what WebLinksAddon
already does for plain-text URLs in the same buffer. The mode is read per
click through a getter rather than captured, and `syncMouseCapture` and the
gate share one expression, because a gate that disagreed with the badge
would be the hole again.
The gate and the hint also share one modifier predicate, and the hint is
conditional on tracking, so it can never name a key that does nothing.
Three more from the same review. The origin span had `flexShrink: 0`, which
beats `overflowWrap` under flexbox, so an attacker-controlled 600-character
origin ran off the pane and hid the registrable domain -- the same spoof as
an ellipsis, without one; it now wraps and the remainder is what gives way.
The card had no `pointerEvents: none`, and `xterm-hover` is inert at this
placement, so a card under the pointer took `mouseleave` from screenElement
and made bottom-row links flicker and refuse to activate at all. And the
design doc comment had come adrift from its function.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Re-review found the gate did not cover the gesture users actually make.
xterm's `Linkifier._handleMouseUp` has no click-count check, no distance
threshold and no timestamp, so it activates on the mouseup that *ends a
selection* as readily as on a click. Double-clicking a word or dragging
across a few characters inside an OSC 8 link therefore opened the browser.
Worse with a program holding the mouse: the only way to select text there
is Shift/Option+drag, which is byte-identical to the gesture the gate
accepted as a deliberate request to open. A container wrapping each output
row in a link would have harvested every legitimate copy.
`term.hasSelection()` is the load-bearing check: a drag is one press and
one release, so its click count is 1 and `detail` cannot see it. `detail >
1` is belt-and-braces for the case where the selection came out empty, and
for not depending on the selection model being written before the
Linkifier's listener runs -- it is, but the check costs nothing. Drag
distance was rejected rather than forgotten: xterm hands `activate` only
the mouseup, so measuring it means binding our own listener and keeping a
second source of truth about one gesture.
The hover card's promise is now sticky. The hint was computed once at hover
while the gate re-read the mode at mouseup, so a card reading "Shift+click
to open" could be on screen while a bare click opened the link. The gate
now requires the modifier if either the card asked for it or the live mode
does.
The same gate is applied to the WebLinksAddon branch, which had none. That
also closes a real bypass: `OscLinkProvider` drops non-http(s) OSC 8
targets before `linkHandler` sees them, so a `javascript:` target with an
`https://evil.tld` label fell through to WebLinks and opened ungated.
What is not closed, and is now recorded rather than papered over: the mouse
mode is a permission the container grants itself. It can drop tracking
before the pointer arrives and hold it off through the click. The selection
and click-count checks hold either way, so the mass-harvest variant is
gone, but the real fix needs a signal the container cannot write and this
pane does not have one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Third-round review polish; no behaviour change beyond the first item.
`detail > 1` was justified in a comment by noting a synthesised event
carries `detail` 0 -- which is an argument for letting untrusted synthetic
events through the click-count half of the gate. A mouseup derived from a
real click always carries `detail >= 1`, so the check is now `!== 1`.
Nothing in the container can dispatch a DOM event, so this is hardening
rather than a hole; the comment now says that instead of the reverse.
Three comments claimed more than they hold. The selection check's
paragraph read as though it caught every copy gesture: it sees a drag only
once the drag has spanned a cell, so a press and release inside one
character cell -- or a drag walked back to its start -- still opens the
link. That is the gap the rejected mousedown/mouseup distance check would
have closed, and it is now recorded beside the reason for rejecting it.
`?1002l` was described as taking effect synchronously with the write; it
takes effect when xterm parses it, on its queued write task. And
`modifierPromised` was described as written on every hover, when `hover()`
clears and returns early with no host element -- which leaves it false, the
stricter direction.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The previous commit tightened the gate's click-count check from `> 1` to
`!== 1`, which two tests in the wiring block did not survive: they built
`new MouseEvent("click", { button: 0 })` directly rather than through the
`click()` helper, so `detail` defaulted to 0 and the gate refused them.
The gate is right and the tests were wrong -- a mouseup derived from a real
click always carries `detail >= 1`, and 0 is exactly the synthetic-event
shape the tightening was for. Both now pass `detail: 1`.
I pushed the previous commit without noticing this, having read a truncated
test summary that hid the failure.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jknapp
merged commit 3537b234d8 into main2026-09-19 03:20:15 +00:00
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Links in Claude's output are now clickable, via Shift+click (Option+click on macOS).
Why the existing link addon could not do it
Claude Code prints links as OSC 8 hyperlinks whose visible text is hard-wrapped into terminal-width pieces.
urlDetector.ts's header already recorded the measurement: against Claude Code 2.1.226 a 346-character sign-in URL arrives as five emissions, each carrying the whole URL in its parameter but ~80 characters on screen.WebLinksAddonregex-matches the painted characters row by row, so against Claude it matches a fragment or nothing — which is why the URL toast exists in the first place.xterm 5.5's
linkHandlerhands over the exact OSC 8 parameter, so the slicing stops mattering.WebLinksAddonis unchanged and still covers plain-text URLs in ordinary shell output. Both branches now share one failure reporter and one validator (sanitizeRelayUrl) — no second implementation.No new key handling
Claude holds
?1000/?1002/?1003, so a plain click goes to Claude. xterm's mousedown handler is:Holding the force-selection modifier means the event is not cancelled and reaches the link layer. This terminal already sets
macOptionClickForcesSelection: truefor text selection, so the same gesture that lets you select text now also opens a link, and Claude keeps the mouse.The hover card is the security half, not decoration
OSC 8 decouples the label from the target completely — a container can print
https://claude.aiand link it anywhere. That is strictly worse than the userinfo spoofingurlRelay.tsalready guards against, and it invalidates the justification for opening a click without confirmation, written at the WebLinksAddon branch: "a click here is a deliberate act on visible text." True of painted text; false of OSC 8.Hovering now shows the real origin, in full and never truncated — truncating it is the spoof — with the remainder ellipsized, reusing
urlOriginfromurlRelay.ts. A target that fails validation says the link will not open and deliberately echoes nothing of itself, since that is untrusted text headed for a DOM node. Text is set viatextContentthroughout; there is noinnerHTMLon this path.The hint names the modifier for the platform, derived from xterm's own
isMaclist verbatim, so it cannot tell a Mac user to press a key that does nothing.Verification
Frontend: 787 passed, 0 failed;
tsc --noEmitclean. Tests were written before the implementation and cover refusal ofjavascript:/file:/userinfo-spoofed targets without reaching the sink, a valid target reaching the opener, aclaude.ai-labelled link surfacinghttps://evil.example.comon hover, the refused-target card not echoing its target, opener failure pushing the toast, and the per-platform hint.createOsc8LinkHandleris exported and tested directly rather than driven through a rendered terminal: xterm decides when to calllinkHandlerfrom cell geometry, and jsdom gives every element a zero-sized box, so a mouse-driven test would assert nothing. Same precedent as the already-exportedsupersedes.Not runtime-tested. The Shift+click-while-Claude-holds-the-mouse interaction is reasoned from xterm's source, not observed — that needs the real app.
Known gap:
app/src-tauri/web_terminal/terminal.html, the standalone embedded terminal, gets none of this and now differs from the desktop pane.🤖 Generated with Claude Code
The previous commit tightened the gate's click-count check from `> 1` to `!== 1`, which two tests in the wiring block did not survive: they built `new MouseEvent("click", { button: 0 })` directly rather than through the `click()` helper, so `detail` defaulted to 0 and the gate refused them. The gate is right and the tests were wrong -- a mouseup derived from a real click always carries `detail >= 1`, and 0 is exactly the synthetic-event shape the tightening was for. Both now pass `detail: 1`. I pushed the previous commit without noticing this, having read a truncated test summary that hid the failure. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>