Fullscreen TUI renderer opens hyperlinks twice on click

Status Open
Reported on v2.1.205
Maintainer reply None cached
Activity 4 comments · opened Jul 9, 2026

Summary

In the fullscreen TUI renderer, clicking an OSC-8 hyperlink in the terminal opens the URL twice (two browser tabs). Switching to the default renderer (/tui default) opens it once, as expected.

Repro

  1. /tui fullscreen
  2. Have Claude output any clickable hyperlink (e.g. a URL in a response).
  3. Single left-click the link.
  4. → Browser opens two tabs for the same URL.
  5. /tui default, click the same link → opens one tab.

Expected

One click → one open, regardless of renderer.

Diagnosis

Ruled out the OS/handler layer: gio open <url> opens one tab, and clicking under the default renderer opens one tab. xdg-mime query default x-scheme-handler/https shows a single handler. The double-open only occurs under the fullscreen renderer, so it appears the fullscreen renderer double-emits the OSC-8 hyperlink escape (or double-registers the click region).

Environment

  • Claude Code: 2.1.205
  • OS: Ubuntu 26.04 LTS (GNOME)
  • Terminal: VTE-based (VTE_VERSION=8400), TERM=xterm-256color

Workaround

Use /tui default.

View original on GitHub ↗

3 Comments

sjwoodr · 25 days ago

Confirming this on another terminal, and I have bus-level evidence that narrows the diagnosis a bit.

Environment

  • Claude Code 2.1.222
  • Tilix 1.9.6, VTE 0.76.0
  • Ubuntu 24.04, GNOME on X11
  • TERM=xterm-256color, VTE_VERSION=7600
  • Browser: Brave, single handler

Renderer-gated, not a version regression

Worth stating explicitly because I went looking for a bad release first and there isn't one. I have four Claude Code versions retained locally (2.1.219 through 2.1.222) and they are byte-identical on every marker that would matter here: OSC 8 emission, the ?1000h / ?1006h mouse sequences, and the xdg-open call sites.

What actually changed on my machine was "tui": "fullscreen" landing in settings.json. The mtime on that file matches the day the double tabs started, to the day. So this is gated on the renderer, exactly as the title says, and bisecting versions will not find it.

Two independent openers, captured on the session bus

A single ctrl+click produces two org.gtk.gio.DesktopAppInfo Launched signals for the same URL, about 600ms apart, from two different bus connections:

signal time=...932.358952 sender=:1.81     ... interface=org.gtk.gio.DesktopAppInfo; member=Launched
   string "https://example.com/prr-dbus-test"

signal time=...932.956073 sender=:1.12539  ... interface=org.gtk.gio.DesktopAppInfo; member=Launched
   string "https://example.com/prr-dbus-test"

Two different senders is the useful part. The issue body suggests the fullscreen renderer may "double-emit the OSC-8 hyperlink escape (or double-register the click)". A double-emitted escape would show up as one client launching twice. Two separate bus connections means two independent openers are each launching once, which points at Claude Code opening the URL itself in addition to the terminal activating the hyperlink, rather than at a malformed or duplicated escape sequence.

The roughly 600ms gap fits that too: one in-process GIO call versus a spawned xdg-open shell script, which is the slower path. The bundle does contain Bun.spawn(["xdg-open", url]).

Controls, which rule a few things out

Same terminal, same session, all hand-emitted with printf outside Claude Code:

| What was clicked | Tabs |
|---|---|
| Plain URL text | 1 |
| OSC 8 hyperlink, ST terminator, visible text is the URL | 1 |
| Bare URL rendered by Claude Code (fullscreen) | 2 |
| Markdown link rendered by Claude Code (fullscreen) | 2 |

The second row matters. A hand-emitted OSC 8 hyperlink whose visible text is itself a URL opens exactly one tab in this terminal, so the "OSC 8 region overlapping the terminal's own URL regex" explanation proposed in #72998 does not account for my case. My terminal handles that combination correctly on its own.

I also checked the desktop layer rather than assuming: single clean brave-browser.desktop in xdg-mime and mimeapps.list, no custom Tilix link regexes configured, and no Tilix or VTE upgrade anywhere near when the symptom started.

Modifier does not suppress it

Shift+ctrl+click also opens two tabs. Since Shift is the VTE bypass for mouse reporting, that argues against "app receives the click through mouse reporting and the modifier can gate it", and is consistent with the two-phase model in #81905 where the two opens fire on different phases of the same click rather than being selected by a modifier.

Workaround confirmed

/tui default gives one tab, as reported in #76110. I am staying on fullscreen for now because it fixed separate rendering problems for me (flicker, and needing ctrl+L to repair the terminal), so the duplicate tab is the lesser annoyance. Noting that because if the fix ends up being "stop emitting hyperlinks in fullscreen", losing hyperlinks would be a worse outcome than the current bug for at least one user.

Related, same root cause from other angles: #76110, #81905, #73968, #72998, #68568.

sjwoodr · 25 days ago

Correcting my own measurement above, and the correction makes the case stronger rather than weaker.

Do not count browser tabs, count launches

The table in my previous comment reported browser tab counts. That is an unreliable way to measure this and I should not have used it. Tab counts are intermittent: I have clicked the same link in the same session and gotten one tab, then two tabs, with nothing changed in between.

Counting org.gtk.gio.DesktopAppInfo Launched signals on the session bus instead gives a completely deterministic result. Watch with:

dbus-monitor --session | grep -B6 'your-test-url'

The double launch is deterministic, the tab count is not

Three consecutive ctrl+clicks on one link rendered by Claude Code in the fullscreen renderer, same session, no changes between them:

| Click | Launch A | Launch B | Gap |
|---|---|---|---|
| 1 | t=...025.542 sender=:1.81 | t=...026.125 sender=:1.12550 | 583ms |
| 2 | t=...029.253 sender=:1.81 | t=...029.838 sender=:1.12551 | 585ms |
| 3 | t=...033.673 sender=:1.81 | t=...034.263 sender=:1.12552 | 590ms |

Always exactly two launches, always about 585ms apart. The variation in how many browser tabs appear is downstream of that: the browser sometimes coalesces two launches of the same URL arriving close together into one tab. So the underlying bug does not flake at all, only the symptom does.

This matters for triage and for anyone else reproducing. If you measure by tabs you will get inconsistent results and may conclude the bug is intermittent, environment-specific, or already fixed. It is none of those. Measure at the bus.

Which connection is which

:1.81 is a persistent connection. It is the same connection across all three clicks above, across a separate capture twenty minutes earlier, and it is the sole launcher in the control cases below. That is the terminal.

The second sender is a new connection on every click, with the connection id incrementing (:1.12550, :1.12551, :1.12552). That is a short-lived process being spawned per click. The bundle contains Bun.spawn(["xdg-open", url]), and a spawned shell script is also a natural fit for the consistent 585ms lag behind the terminal's in-process call.

Controls, re-measured by launches

Same terminal, hand-emitted with printf outside Claude Code:

| What was clicked | Launches | Sender |
|---|---|---|
| Plain URL text | 1 | :1.81 |
| OSC 8 hyperlink, ST terminator, visible text is the URL | 1 | :1.81 |
| Bare URL rendered by Claude Code, fullscreen | 2 | :1.81 + fresh |
| Markdown link rendered by Claude Code, fullscreen | 2 | :1.81 + fresh |

The conclusion I drew last time survives the better measurement. A hand-emitted OSC 8 hyperlink whose visible text is itself a URL produces exactly one launch in this terminal, so the "OSC 8 region overlapping the terminal's own URL regex" explanation from #72998 does not account for this case. The terminal handles that combination correctly on its own, and only ever contributes one launch. The second launch appears only when Claude Code is the one rendering the link.

I also retested whether the link's shape matters, since I briefly thought it might. It does not: bare URL, markdown link with short non-URL text, markdown link whose visible text is a URL, a link wrapped in bold, and a link with a long prose label all produce the same two launches. My earlier suggestion that visible-text shape was involved was an artifact of reading tab counts.

guifuchs · 24 days ago

Following up on @sjwoodr's launch-counting method, which is the right way to measure this. I ran a state-dependent control on top of it that isolates which of the two launches belongs to Claude Code.

The second launch disappears while Claude Code is streaming

Same link, same session, same terminal, same rendered element. The only variable is whether Claude Code's render loop is busy generating output at the moment of the click.

| Claude Code state | Clicks | Launches | Senders |
|---|---|---|---|
| Idle (prompt returned) | 5 | 10 | persistent + fresh, every time |
| Streaming (mid-response) | 2 | 2 | persistent only, no fresh connection |

Captured with an unfiltered dbus-monitor --session, counting org.gtk.gio.DesktopAppInfo Launched signals, across three separate captures. Phase boundaries were timestamped so the two click clusters cannot be confused.

Idle: always exactly two launches

Three consecutive clicks from one capture:

| Click | Persistent sender | Fresh sender | Gap |
|---|---|---|---|
| 1 | :1.149 serial 3502 | :1.591 serial 12 | 587.7 ms |
| 2 | :1.149 serial 3521 | :1.592 serial 12 | 590.5 ms |
| 3 | :1.149 serial 3546 | :1.593 serial 12 | 573.1 ms |

Across all five idle clicks the gap is 588 ± 16 ms (sd, n=5), which matches @sjwoodr's 583/585/590 ms on a different machine.

The two connections are distinguishable by serial number, not just by id:

  • :1.149 runs from serial 3393 to 3553 across the whole session, spanning both phases and both captures. Long-lived.
  • Each fresh sender's highest serial is 12. Twelve messages total, then gone. That is a process spawned for the click and exiting immediately, consistent with Bun.spawn(["xdg-open", url]) in the bundle, and consistent with the ~588 ms lag behind the terminal's in-process call.

Each launch also carries a distinct pid for the launched browser (e.g. 262211 and 262396 for one click), so these are two genuine invocations, not one signal observed twice.

Streaming: one launch, from the persistent connection

Clicking the same link while a response is still rendering produces a single Launched, from :1.149 — the persistent connection. No fresh connection is created at all.

This settles which side owns the surplus launch. The persistent launcher fires identically regardless of what Claude Code is doing, and per @sjwoodr's controls it is the sole launcher for plain URL text and for a hand-emitted OSC 8 hyperlink. That is the terminal, acting on the OSC 8 region correctly and on its own. The extra launch appears only when Claude Code's click handler is live, and vanishes when the render loop is busy.

So the emitted escape sequence is not the defect. The defect is that Claude Code additionally shells out to xdg-open for a click the terminal has already actioned. That also explains why link shape is irrelevant, as @sjwoodr found: every shape double-opens because the spawn is unconditional.

Caveats

  • Idle n=5, streaming n=2, single machine, GNOME session, Chrome as default handler, one terminal. The idle result reproduces @sjwoodr's on separate hardware; the streaming control has not been replicated elsewhere yet.
  • I attempted three clicks in each streaming window but only one produced any launch at either sender. I cannot distinguish a swallowed click from a mis-landed one, since the content is scrolling under the cursor during a streaming response. Worth a look separately, but it does not affect the count above: every streaming click that registered at all produced exactly one launch.
  • The bus cannot show whether the click reaches Claude Code and is dropped downstream, or never reaches it. Distinguishing those would need instrumentation inside the renderer.

Showing cached comments. Read the full discussion on GitHub ↗