[BUG] Links open twice in Windows Terminal: Claude Code opens OSC 8 regions the terminal already activates

Status Open
Reported on v2.1.220
Maintainer reply None cached
Activity 5 comments · opened Jul 30, 2026

Summary

In Windows Terminal, ctrl+clicking any link in Claude Code's TUI opens it twice. The cause is
two independent openers acting on one click — not a duplicated click event. Windows Terminal
activates the OSC 8 hyperlink itself, and Claude Code also opens it from the forwarded mouse
report. Both are gated on ctrl.

Environment

  • Claude Code 2.1.220
  • Windows Terminal 1.24.11911.0 (Store package Microsoft.WindowsTerminal_8wekyb3d8bbwe, only install on the machine)
  • PowerShell 7.6.3 (C:\Program Files\PowerShell\7\pwsh.exe)
  • Windows 11 Home 10.0.26200
  • Process chain: claude.exepwsh.exeWindowsTerminal.exe

Isolation

| # | Test | Tabs | Conclusion |
|---|---|---|---|
| 1 | Start-Process "https://example.com" from pwsh | 1 | Chrome / default-browser association is clean |
| 2 | echo https://example.com at bare pwsh prompt, ctrl+click | 1 | WT's URL auto-detection fires once |
| 3 | OSC 8 hyperlink emitted at bare pwsh prompt, ctrl+click | 1 | WT's OSC 8 handler fires exactly once |
| 4 | Link in Claude Code TUI, plain left-click | 0 | Claude Code requires the ctrl modifier |
| 5 | Link in Claude Code TUI, ctrl+click | 2 | two openers |
| 6 | Same as 5 with "experimental.detectHyperlinks": false | 2 | it is the OSC 8 path, not auto-detection |
| 7 | Link in Claude Code TUI, ctrl+shift+click | 1 | shift bypasses mouse reporting → Claude Code never sees the click → WT opens alone |

Test 3 establishes Windows Terminal opens OSC 8 links once on its own. Test 7 removes Claude
Code from the path and yields one tab. Together they place the second open inside Claude Code.

Reproduction for test 3:

$ESC = [char]0x1B
$osc8 = "{0}]8;;https://example.com{0}\OSC8 test link{0}]8;;{0}\" -f $ESC
Write-Host $osc8

Why this is not #68568 / warpdotdev/warp#13512

Those are the duplicated mouse-event class: the terminal delivers one physical click as two
click events and Claude Code acts on both. The published mitigation is de-duplicating events
within a time+coordinate window.

That mitigation would not fix this. Here exactly one click event arrives. The duplicate
opener is the terminal's own correct OSC 8 activation. Corroborating: click-to-expand on tool
results does not double in Windows Terminal, and plain left-click (test 4) produces zero opens
rather than two.

Expected

When Claude Code emits a region as an OSC 8 hyperlink, the terminal owns activation of that
region. Claude Code should not also open it.

Suggested fix

Either suppress Claude Code's own click-to-open over regions it wrapped in OSC 8, or add a
setting to disable click-to-open entirely — the latter was already requested in #18717 with no
flag available today.

Workaround

ctrl+shift+click opens exactly one tab.

View original on GitHub ↗

3 Comments

shahaanf · 1 month ago

Confirming this on Windows Terminal 1.24.11911.0 / Claude Code 2.1.220 / pwsh 7 / Win 11 26200, and I think I can name the exact mechanism — it's a press/release asymmetry in Windows Terminal that no report here has cited yet.

It is not a double dispatch. It's two different openers firing on two different phases of one click.

All line numbers below are src/cascadia/TerminalControl/ControlInteractivity.cpp on release-1.24.

Phase 1 — mouse-down, Windows Terminal opens the link. PointerPressed (:252) checks for a hyperlink under the cursor first, deliberately:

// GH#9396: we prioritize hyper-link over VT mouse events          // :265
auto hyperlink = _core->GetHyperlink(terminalPosition.to_core_point());
if (WI_IsFlagSet(buttonState, MouseButtonState::IsLeftButtonDown) &&
    ctrlEnabled && !hyperlink.empty())
{
    if (clickCount == 1) { _hyperlinkHandler(hyperlink); }          // :275  -> ShellExecuteW, tab 1
}
else if (_canSendVTMouseInput(modifiers))                           // :278  <- never reached
{
    _sendMouseEventHelper(...);
}

Because the VT-forward sits in the else if, the press is swallowed and never reaches the TUI. That part is working as designed.

Phase 2 — mouse-up, Claude Code opens it again. PointerReleased (:470) has no hyperlink guard at all:

if (!_core->IsInReadOnlyMode() && _canSendVTMouseInput(modifiers))  // :477
{
    _sendMouseEventHelper(terminalPosition, pointerUpdateKind, modifiers, 0, buttonState);
    return;
}

So the ctrl+mouse-up SGR report is forwarded with the Ctrl modifier bit intact. In the 2.1.220 bundle the guard on that path reads:

if (s && process.env.TERM_PROGRAM !== "vscode" && !dA() &&
    ((t.button & 24) !== 0 || MU.macCmdClickArrivesWithoutSgrModifierBit() || oFu())) {
  e.pendingHyperlinkTimer = setTimeout((a, l) => { ... a.props.onOpenHyperlink(l) }, rBu, e, s)
}                                                          // rBu = 500

t.button & 24 is the Alt|Ctrl mask, so a ctrl+mouse-up matches. 500 ms later it calls its own opener (rundll32 url,OpenURL) → tab 2.

The only terminals excluded here are VS Code (TERM_PROGRAM === "vscode") and whatever dA() covers. Windows Terminal is excluded by neither, so under "tui": "fullscreen" (full mouse tracking, ?1000h ?1002h ?1003h ?1006h) the release always gets through.

Net effect: the app receives a button-release for a button-press it never received, and treats it as a complete click.

---

Workaround that works today: Ctrl+Shift+Click. _canSendVTMouseInput (:681) short-circuits on Shift before consulting IsVtMouseModeEnabled():

if (modifiers.IsShiftPressed()) { return false; }
return _core->IsVtMouseModeEnabled();

The release is therefore never forwarded, while the press-side hyperlink branch doesn't test Shift and still opens the link. Exactly one tab.

CLAUDE_CODE_DISABLE_MOUSE_CLICKS=1 in settings.json also fixes it, at the cost of in-TUI click targets and drag-select.

experimental.detectURLs: false does not help, and can't. It only gates Windows Terminal's regex pattern tree (_getPatterns() early-returns {} when unset). Terminal::GetHyperlinkAtBufferPosition checks attr.IsHyperlink() for an explicit OSC 8 region first and unconditionally, never consulting the flag. Since Claude Code emits real OSC 8 hyperlinks, that setting was never in this code path. Worth stating explicitly because several reports here recommend it as a fix.

Isolation check, in case it helps triage — emitting a raw OSC 8 hyperlink at a bare pwsh prompt in the same Windows Terminal profile opens one tab. The same link inside Claude Code opens two. So Windows Terminal's opener is firing exactly once; the second open is Claude Code's.

---

Suggested fix, in preference order:

  1. Don't act on a release whose press was never delivered. Track whether the app received the matching MouseDown for a button before treating its MouseUp as a click. This is terminal-agnostic and fixes the whole class of "host swallowed the press" cases rather than special-casing one emulator.
  2. Add Windows Terminal (detectable via $WT_SESSION) to the existing bypass list alongside VS Code and Ghostty. Narrower, but a one-liner.

Happy to test a build against this repro.

iappwebdev · 26 days ago

I can confirm this and have some measurements that may help narrow it down. I ran a local Node HTTP server that logs every incoming request with a millisecond timestamp, then clicked links pointing at it.

Environment: Windows 11 Home (26100), Windows Terminal 1.24.11911.0, Claude Code CLI running directly in Windows Terminal (no WSL), Chrome as default browser.

Result: A single Ctrl+click on a link rendered by Claude Code produces exactly 2 GET requests, consistently ~650 ms apart (measured gaps: 646 ms, 647 ms, 654 ms across three clicks). This happens for all three link styles: a markdown link with plain text, a markdown link whose visible text is the URL itself, and a bare URL.

HIT | 10:35:43.161 | GET /variante-a-nur-text      (markdown link, plain text)
HIT | 10:35:43.807 | GET /variante-a-nur-text      (+646 ms)
HIT | 10:35:49.807 | GET /variante-b-url-als-text  (markdown link, URL as text)
HIT | 10:35:50.454 | GET /variante-b-url-als-text  (+647 ms)
HIT | 10:36:00.910 | GET /variante-c-nackt         (bare URL)
HIT | 10:36:01.564 | GET /variante-c-nackt         (+654 ms)

Control tests (same machine, same session):

  • Start-Process <url> from PowerShell → exactly 1 request, so the OS-level http association and browser are clean.
  • Ctrl+click on the same URL echoed in a plain PowerShell tab of the same Windows Terminal (no Claude Code running) → exactly 1 request per click, so Windows Terminal alone is clean.
  • Plain click (no Ctrl) on a link inside Claude Code → 0 requests, nothing opens.

So the duplication only occurs when Claude Code and Windows Terminal both handle the same Ctrl+click: the terminal activates the OSC 8 hyperlink and Claude Code additionally opens the URL from the forwarded mouse event. The fact that a plain click does nothing suggests Claude Code's own handler is also gated on the Ctrl modifier, which is why both openers always fire together and there is no click-style workaround.

hugo-borba · 10 days ago

Independent corroboration, same platform:

  • Claude Code 2.1.228 (newer than the versions reported above)
  • Windows Terminal (confirmed via $WT_SESSION being set), PowerShell 7, Windows 11
  • Reproduces exactly as described: Ctrl+click on a link opens 2 tabs; plain click does nothing

One additional data point that isn't explicitly called out above but is consistent with @shahaanf's root-cause analysis: the bug only reproduces under "tui": "fullscreen". Toggling to /tui default in the same session, same window, same link, eliminates it entirely — exactly one tab, every time. That matches the theory that full VT mouse tracking (?1000h ?1002h ?1003h ?1006h) is what's enabled in fullscreen mode, which is the precondition for the mouse-up release event to get forwarded to Claude Code in the first place per the press/release asymmetry described above.

Didn't file a new issue since this one already has the mechanism nailed down with source references from both sides (Windows Terminal's ControlInteractivity.cpp and Claude Code's bundled pendingHyperlinkTimer logic) plus independent timing confirmation. Flagging mainly to add a data point on the fullscreen/default boundary, and to note it's still open on a fairly recent build.

Showing cached comments. Read the full discussion on GitHub ↗