Dev

How I Fixed OSC 52 Clipboard in xterm.js Through tmux

OSC 52 clipboard doesn't work in xterm.js through tmux. The fix isn't set-clipboard or terminal-features — it's allow-passthrough. Here's the full debugging story with working code.

I'm building a web dashboard that gives browser-based terminal access to tmux sessions via xterm.js + WebSocket + PTY. Claude Code has a /copy command that uses OSC 52 to copy text to the system clipboard. Works perfectly in iTerm2, Windows Terminal, Ghostty. Through my web terminal — nothing.

Took me two hours and three wrong turns to figure out why.

xterm.js doesn't handle OSC 52

First problem: xterm.js ignores OSC 52 by default. The escape sequence arrives, the parser sees it, nothing happens. I had to register a handler manually:

javascript
term.parser.registerOscHandler(52, (data) => {
  const idx = data.indexOf(';')
  if (idx === -1) return false
 
  const b64 = data.substring(idx + 1)
  if (!b64) return false
 
  try {
    const decoded = atob(b64)
    const bytes = new Uint8Array(decoded.length)
    for (let i = 0; i < decoded.length; i++) {
      bytes[i] = decoded.charCodeAt(i)
    }
    const text = new TextDecoder().decode(bytes)
 
    navigator.clipboard.writeText(text)
      .catch(() => {
        const el = document.createElement('textarea')
        el.value = text
        document.body.appendChild(el)
        el.select()
        document.execCommand('copy')
        document.body.removeChild(el)
      })
  } catch (e) {}
 
  return true
})

The format is \x1b]52;c;<base64>\x07c is the clipboard selection, the payload is base64-encoded text. Important: atob returns a binary string, not UTF-8. You need TextDecoder to handle non-ASCII characters correctly.

Should have been the whole fix. Wasn't.

tmux set-clipboard — the red herring

After adding the handler, /copy in Claude Code still didn't work. I went down the tmux configuration rabbit hole.

tmux has set-clipboard which controls OSC 52 handling:

bash
set -g set-clipboard external  # pass OSC 52 through to outer terminal

Didn't help. Then I found that tmux checks the Ms terminfo capability to decide if the outer terminal supports OSC 52. My xterm-256color didn't have it. tmux 3.2+ has a workaround:

bash
set -g terminal-features "xterm-256color:clipboard"

Still nothing. I checked tmux list-buffers after /copy — empty. tmux wasn't seeing OSC 52 at all. None of this was relevant.

Here's why: set-clipboard controls how tmux handles raw OSC 52. But Claude Code doesn't send raw OSC 52 in tmux — it wraps it in DCS passthrough (next section). DCS passthrough is a completely different code path that bypasses tmux's clipboard handling entirely. So set-clipboard external and terminal-features clipboard were solving the wrong problem.

The breakthrough came from a different test: I ran printf '\e]52;c;dGVzdA==\a' directly inside tmux. It worked. My xterm.js handler fired, "test" appeared in the clipboard. Raw OSC 52 passes through tmux just fine.

So why doesn't Claude Code's /copy work?

DCS passthrough — the real problem

Claude Code detects $TMUX and wraps OSC 52 in a DCS passthrough envelope:

\ePtmux;\e\e]52;c;<base64>\a\e\\

This tells tmux "pass this escape sequence directly to the outer terminal." It's the standard approach — Neovim, tmux-yank, and most modern CLI tools do the same.

The catch: tmux 3.3+ blocks DCS passthrough by default. allow-passthrough defaults to off. The DCS envelope is silently dropped. My xterm.js handler never sees anything.

bash
tmux set-option -t <session> allow-passthrough on

One option. Two hours.

The full solution

Backend (Python/FastAPI) — enable passthrough before attach, disable on disconnect:

python
async def attach_session(session_name: str):
    subprocess.run([
        'tmux', 'set-option', '-t', session_name,
        'allow-passthrough', 'on'
    ])
    # ... attach via PTY ...
 
async def detach_session(session_name: str):
    subprocess.run([
        'tmux', 'set-option', '-t', session_name,
        '-u', 'allow-passthrough'  # restore default
    ])

Per-session, not global. Unset on disconnect to minimize the security surface.

Frontend (xterm.js) — register the handler on every Terminal instance:

javascript
function setupClipboard(term) {
  term.parser.registerOscHandler(52, (data) => {
    const idx = data.indexOf(';')
    if (idx === -1) return false
    const b64 = data.substring(idx + 1)
    if (!b64) return false
    try {
      const decoded = atob(b64)
      const bytes = new Uint8Array(decoded.length)
      for (let i = 0; i < decoded.length; i++)
        bytes[i] = decoded.charCodeAt(i)
      navigator.clipboard.writeText(new TextDecoder().decode(bytes))
    } catch (e) {}
    return true
  })
}

If you have split panes, each Terminal instance needs its own handler.

Debugging timeline

StepWhat I triedResult
1OSC 52 handler in xterm.js/copy still broken
2set-clipboard externalNothing (wrong code path)
3terminal-features clipboardNothing (same reason)
4tmux list-buffersEmpty — tmux never sees the sequence
5Raw printf '\e]52;c;dGVzdA==\a'Works! Handler fires
6console.log + server-side PTY log on /copyNeither fires
7Realized Claude Code wraps in DCS passthroughSequence blocked by tmux
8allow-passthrough onFixed

Step 5 was the key — raw OSC 52 worked, Claude Code's didn't. Same sequence, different envelope.

The checklist

If you're building a web terminal with xterm.js and tmux, you need two things:

  1. xterm.js registerOscHandler(52, ...) — xterm.js doesn't ship one
  2. tmux allow-passthrough on — so DCS-wrapped sequences from Claude Code, Neovim, and other modern CLI tools reach your handler

That's it. You don't need set-clipboard external or terminal-features clipboard — those control raw OSC 52 handling, but modern tools use DCS passthrough which bypasses that entirely.

Miss either one and clipboard silently fails. No error, no warning.

Discussion

No comment section here — all discussions happen on X.