Skip to content

fix(tui): modified authorization-link clicks open duplicate tabs #51756

Open

Description

@gszep

Description

A modified click on a TUI authorization link can open two browser tabs: the terminal follows the native OSC 8 hyperlink and the TUI also calls its browser opener. Expected: one tab.

Root cause: the Link component's mouse-up handler opens the URL without checking mouse-button/modifier state.

Plugins

No plugin is required; this is the built-in Link component.

OpenCode version

2.0.15 (V2)

Steps to reproduce

  1. Open an authorization link in the TUI in a terminal with native OSC 8 hyperlink support.
  2. Use the terminal's link-opening modifier and click the link (Ctrl/Cmd/Shift/Alt, depending on the terminal).
  3. When the terminal also reports the modified mouse event to the TUI, two browser tabs open instead of one.

Operating System

macOS and Linux.

Terminal

Terminals with native OSC 8 hyperlinks that also forward modified mouse events. The relevant behavior is terminal-specific; not every modifier reproduces it in every terminal.

Activity

  1. github-actions commented on Sep 28, 2026

    @github-actions
    Contributor

    This issue might overlap with an existing report. Please check:


    For keybind-related issues, please also check our pinned keybinds documentation: #4997

  2. gszep commented on Sep 28, 2026

    @gszep
    Author

    Thanks! #44442 concerns Markdown links not opening under mouse capture; this report concerns authorization links opening twice when both the terminal and TUI handle a modified click. #51757 fixes the duplicate opener, not Markdown link capture.

  3. opencodejr commented on Oct 28, 2026

    @opencodejr
    Contributor

    Thanks for the clear report. I could reproduce this on 2.0.15, and it still happens on the latest dev (86ce19d).

    How I tested it:

    • Opened /pair, which shows connect/authorization links rendered with the shared Link component.
    • Clicked a link six ways: plain left click, Ctrl+click, Shift+click, Alt+click, middle click and right click.
    • Logged every browser launch the TUI makes, using a stub xdg-open.

    Result: every one of the six clicks made the TUI open the browser. The link is also emitted as an OSC 8 hyperlink, so in a terminal that opens it on a modified click and also forwards the mouse event, that open from the TUI is the duplicate tab. The clickedLink() helper used for transcript links already leaves modified clicks to the terminal; Link's mouse-up handler doesn't check the button or modifiers.

    The script below reproduces it. The recording shows the dialog and the clicks, but the browser launches go to a log file, not the screen. I couldn't simulate the terminal's own OSC 8 open here, only the TUI's.

    Related: #51757 (your fix PR), #53903 (added the modifier-aware clickedLink for transcript links), #52754, #56062.

    recording.mp4
    Reproduction script (drive.ts)
    // Reproduces #51756: a modified (Ctrl/Shift/Alt) click on an authorization Link in the TUI
    // still makes the TUI open the browser itself, on top of the terminal's own OSC 8 handling.
    // A fake `xdg-open` is put first on PATH so every browser open the TUI itself performs is logged
    // (the terminal's own OSC 8 handling is outside the TUI and not simulated here).
    // Run: opencode-drive run ./drive.ts   (set OC_DEV=<checkout> to run a source checkout instead)
    import { Effect } from "effect"
    import { readFileSync, existsSync, writeFileSync, mkdirSync, chmodSync } from "node:fs"
    import { OpenCodeDriver } from "opencode-drive"
    
    const DIR = "/tmp/opencode/link-repro"
    const LOG = `${DIR}/opened.log`
    mkdirSync(`${DIR}/bin`, { recursive: true })
    writeFileSync(`${DIR}/bin/xdg-open`, `#!/bin/sh\necho "$(date -u +%T) TUI opened browser: $*" >> ${LOG}\n`)
    chmodSync(`${DIR}/bin/xdg-open`, 0o755)
    process.env.PATH = `${DIR}/bin:${process.env.PATH}`
    process.env.BROWSER = `${DIR}/bin/xdg-open`
    delete process.env.DISPLAY
    delete process.env.WAYLAND_DISPLAY
    const opened = () => (existsSync(LOG) ? readFileSync(LOG, "utf8").trim().split("\n").filter(Boolean).length : 0)
    
    export default OpenCodeDriver.use(
      {
        ...(process.env.OC_DEV ? { opencode: { dev: process.env.OC_DEV } } : {}),
        tui: { recording: true, pointerOverlay: true, keypressOverlay: true },
      },
      ({ ui }) =>
        Effect.gen(function* () {
          writeFileSync(LOG, "")
          yield* ui.submit("/pair")
          yield* ui.waitFor("http://localhost")
          // Locate the first authorization link on screen from the captured frame.
          const frame: any = yield* ui.capture()
          let x = -1, y = -1
          frame.lines.forEach((line: any, row: number) => {
            const text = line.spans.map((s: any) => s.text).join("")
            const col = text.indexOf("http://localhost")
            if (y < 0 && col >= 0) { y = row; x = col + 5 }
          })
          yield* Effect.log(`link at x=${x} y=${y}`)
          yield* ui.screenshot("pair-dialog")
          const results: string[] = []
          for (const mods of [{ plain: false } as any, { ctrl: true }, { shift: true }, { alt: true }]) {
            const before = opened()
            yield* ui.mouse({ action: "move", x, y })
            yield* ui.mouse({ action: "down", x, y, button: "left", ...mods } as any)
            yield* ui.mouse({ action: "up", x, y, button: "left", ...mods } as any)
            yield* Effect.sleep("1500 millis")
            results.push(`${Object.keys(mods)[0]}+click -> TUI browser opens: ${opened() - before}`)
          }
          for (const button of ["middle", "right"] as const) {
            const before = opened()
            yield* ui.mouse({ action: "down", x, y, button })
            yield* ui.mouse({ action: "up", x, y, button })
            yield* Effect.sleep("1500 millis")
            results.push(`${button}-click -> TUI browser opens: ${opened() - before}`)
          }
          yield* ui.screenshot("after-clicks")
          yield* Effect.log(results.join("\n"))
          yield* Effect.log(`opened.log:\n${readFileSync(LOG, "utf8")}`)
        }),
    )

    Run it with opencode-drive: opencode-drive run ./drive.ts. It drives opencode from your PATH; set OC_DEV to the path of an OpenCode checkout to run that from source instead.

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

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions