Skip to content

Clipboard & OSC 52

This doc explains how clipboard sync is wired across this dotfiles setup, with a focus on OSC 52 — the escape sequence that lets a remote SSH session put text on your local machine's clipboard.

TL;DR

You are… Yank in Neovim goes to… Capture pane (prefix+y/Y/C-y) goes to…
Direct local terminal / plain tmux system clipboard (provider binary: pbcopy on macOS / wl-copy / xclip on Linux) same, via tmux load-buffer -w - which emits OSC 52 and sets tmux's paste buffer
SSH / Herdr / Zellij attached client's clipboard (copy-only OSC 52) attached client's clipboard (OSC 52)

No X forwarding or socat listener needed. Over SSH the only hard requirement is a terminal emulator on your local machine that supports OSC 52 (Ghostty, Alacritty, iTerm2, Kitty, WezTerm — all do). On a local Linux desktop, the provider binary is not built in: wl-clipboard + xclip + xsel are installed by the gui_apps_linux ansible role (ubuntu_desktop profile). Server profiles get none — OSC 52 is the whole story there.

The remote/multiplexer path is deliberately one-way. A Neovim yank writes to the attached client's clipboard; ordinary p reads Neovim's own unnamed register and never sends an OSC 52 query. To paste external clipboard text into Neovim, use the terminal's native paste (Cmd+V / Ctrl+Shift+V).

What is OSC 52?

OSC 52 is an ANSI escape sequence:

ESC ] 52 ; c ; <base64-of-data> BEL

Any program that can write to a terminal can emit this sequence; the terminal emulator on the other end decodes the base64 and writes it to the system clipboard. The crucial property: escape sequences travel up the TTY back to the client terminal, so this works transparently over SSH, mosh, and nested tmux ssh chains — no display server, no socket forwarding.

Tradeoffs:

  • Write is ubiquitous. Most modern terminals allow it by default or with a simple opt-in.
  • Read (paste from local clipboard back into remote apps) is much less supported — many terminals refuse for obvious security reasons (a malicious server could exfiltrate your clipboard). Ghostty prompts; Alacritty flat-out ignores it.
  • A terminal multiplexer (tmux, screen, zellij) sits between the inner app and the outer terminal. It must be configured to either emit OSC 52 to its host terminal or pass through OSC 52 from inner apps.
  • Text only — OSC 52 carries base64 text; it cannot move an image (or file object) from your local clipboard into a remote app, and it flows remote→local, the wrong direction for feeding a screenshot to a remote agent. Getting a local screenshot into a remote coding agent (Claude Code / Neovim) is a separate problem — see remote-image-paste.md.

Reference: tmux Clipboard wiki, XTerm control sequences (OSC).

How this repo wires it up

Four layers cooperate. Removing any one silently breaks remote-clipboard sync.

1. Terminal emulator (outer, on your local machine)

Must accept OSC 52 writes from whatever is running inside it.

Ghostty / cmux — dot_config/ghostty/config:

clipboard-write = allow   # let inner apps set the system clipboard
clipboard-read  = ask     # prompt before reads (safer default)

Both match current Ghostty defaults but are pinned explicitly so policy drifts from upstream don't silently break (or weaken) this chain.

Alacritty — native support, no config needed. Paste from OSC 52 not supported.

iTerm2 / Kitty / WezTerm — native support. iTerm2 requires Preferences → General → Selection → Applications in terminal may access clipboard for reads.

2. Multiplexer — tmux

dot_config/tmux/common.conf:

set -g set-clipboard on

set -as terminal-features ",xterm*:clipboard"
set -as terminal-features ",ghostty*:clipboard"
set -as terminal-features ",alacritty*:clipboard"

set -g allow-passthrough on
  • set-clipboard on — tmux both re-emits OSC 52 to the outer terminal when its own copy-mode yanks, and passes OSC 52 from inner apps (Neovim, less, etc.) through. external would only pass through; on is a strict superset and simpler.
  • terminal-features …:clipboard — tells tmux the outer terminal supports OSC 52 without needing a terminfo Ms capability. Many distros ship a tmux-256color entry that lacks Ms, so declaring the feature directly avoids surprises when chezmoi apply is run on a fresh box.
  • allow-passthrough on — lets terminal-image protocols (sixel, kitty graphics) work; also covers OSC passthrough for nested multiplexers.

Capture-pane helpers — dot_config/tmux/keybindings.conf — use the tmux-native idiom rather than shelling out:

bind y   run-shell "tmux capture-pane -p   | tmux load-buffer -w -"
bind Y   run-shell "tmux capture-pane -pS - | tmux load-buffer -w -"
bind C-y run-shell "tmux capture-pane -pS - | fzf-tmux … | tmux load-buffer -w -"

load-buffer -w - reads stdin into tmux's paste buffer and (because of -w) forwards to the system clipboard via OSC 52. Previously these bindings used a pbcopy | xclip | xsel fallback chain that only worked locally — over SSH they copied to the remote machine's clipboard, which is useless. The tmux-native form works identically in both places.

set-buffer -w … offers the same -w flag but takes data as an argument, which introduces shell-quoting landmines on multi-line pane content; load-buffer -w - reads stdin and sidesteps that.

3. Editor — Neovim

dot_config/nvim/lua/config/options.lua:

if use_osc52 then
  vim.opt.clipboard = "" -- normal p stays on Neovim's unnamed register
  vim.g.clipboard = {
    name = "OSC 52 copy-only",
    copy = { ["+"] = osc52_copy("+"), ["*"] = osc52_copy("*") },
    paste = { ["+"] = cached_paste("+"), ["*"] = cached_paste("*") },
  }
  -- TextYankPost copies yanks (not deletes/changes) into +.
else
  vim.opt.clipboard = "unnamedplus"
end
  • Locally, LazyVim's default provider is used (pbcopy/wl-copy/xclip — with working paste). That provider is a runtime binary probe — Neovim has no compile-time +clipboard. On macOS pbcopy is built in; on a Linux desktop the binary comes from wl-clipboard / xclip, installed by the gui_apps_linux role ("Install clipboard CLIs"). Without it, :checkhealth provider.clipboard reports "No clipboard tool found" and yank silently no-ops — see pitfalls/lazygit-ctrl-o-no-clipboard-utilities-nvim-yank-silent.md.
  • Remote / multiplexed — SSH_CONNECTION/SSH_TTY/SSH_CLIENT (an interactive sshd session), or HERDR_ENV/ZELLIJ — Neovim emits OSC 52 only for copy, so yanks reach whatever terminal is attached while ordinary p stays instant and local to the editor. Deletes and changes do not overwrite the attached client's clipboard. herdr and zellij need their own check because their pane environment is frozen at multiplexer-server start (a wl-copy there hits a stale display), while the pane TTY still proxies to the real client. tmux refreshes those vars via update-environment, so a bare tmux session stays on the native provider.
  • The custom + / * paste functions return only content copied earlier by the same Neovim process. They never call osc52.paste(). Before any such copy, explicit "+p warns and no-ops; use terminal paste for inbound clipboard text.
  • X_CLIPBOARD=osc52 forces copy-only OSC 52; X_CLIPBOARD=<any local tool> forces the native provider. It shares x's environment knob and selection predicate, but deliberately not x paste semantics.

Why conditional rather than always-on OSC 52? Local native providers are fast and genuinely bidirectional. In a remote or attachable multiplexer pane, the server-side provider may target the wrong machine and OSC 52 reads are not portable, so only the reliable write direction is enabled.

Downstream consumer — lazygit Ctrl+O

dot_config/lazygit/config.yml sets:

os:
  copyToClipboardCmd: "printf '%s' {{text}} | x copy"

Ctrl+O ("copy to clipboard") is routed through x instead of lazygit's vendored atotto/clipboard. Locally that resolves to wl-copy/xclip; over SSH it falls through to x's OSC 52 path, so the yank lands on the local clipboard from remote hosts too (lazygit's native library would target the remote's X/Wayland clipboard). lazygit shell-quotes {{text}} before substitution, so multi-word commit subjects and paths survive.

4. Shell CLI — x (cross-platform clipboard wrapper)

dot_dotfiles/bin/executable_x (deployed to ~/.dotfiles/bin/x) is a Bash wrapper with three subcommands:

printf "hello" | x copy        # copy stdin to clipboard
x copy path/to/file            # copy file contents
x copy-file path/to/file       # copy file object (paste into Finder / Files)
x paste                        # print clipboard to stdout
x open https://example.com     # open URL/file in default app

Its copy backend tries, in order: clip.exe (WSL) → pbcopy (macOS) → wl-copy (Wayland) → xclip → xsel → OSC 52 fallback writing directly to /dev/tty. This means x copy Just Works on macOS, Linux desktop, Linux SSH server, WSL, and anything else that can reach a terminal — you never have to think about which backend is available. On a Linux desktop the wl-copy / xclip / xsel binaries come from the gui_apps_linux role; without them x copy still works via OSC 52 but x copy-file has no backend.

Under SSH, herdr, or zellij — prefer_osc52(): SSH_CONNECTION/SSH_TTY/SSH_CLIENT, HERDR_ENV, or ZELLIJ — that order flips: OSC 52 is tried first, because a local wl-copy / xclip there targets the wrong machine (SSH) or a frozen display (a herdr/zellij pane inherits the env from when its server started, not the client attached now; the pane TTY, though, always proxies to the real client). If /dev/tty can't be opened (ssh with no PTY), x falls back through the display-server tools for the ssh -X case. x copy-file refuses here — OSC 52 is text-only and a file object can't cross. Same predicate as Neovim's options.lua.

Check the executable first: command -v x should resolve to ~/.dotfiles/bin/x. A legacy ~/.local/bin/x can report copy backend failed: xclip even after cas updates the managed copy. The shared Pi/OMP PATH setup preserves ~/.dotfiles/bin ahead of ~/.local/bin; reload your shell after applying it. Use type -a x to find other copies or overrides.

X_CLIPBOARD overrides autodetect entirely: osc52 | wl-copy | xclip | xsel | pbcopy | clip.exe. Put export X_CLIPBOARD=osc52 in ~/.shellrc.adhoc on a box you always reach through something that hides SSH_* and isn't herdr/zellij (bare mosh, some Remote-SSH setups).

x copy-file PATH... is intentionally separate from x copy PATH: it places file objects on the desktop clipboard so a file manager can paste/copy the selected files, instead of pasting the files' text contents. It uses macOS osascript on Finder-compatible desktops and Linux x-special/gnome-copied-files MIME data via wl-copy / xclip for common GTK file managers. This is local-desktop only; OSC 52 cannot carry file objects over SSH. For remote hosts, copy contents with x copy FILE or copy a path with realpath FILE | x copy.

The OSC 52 fallback in x is also wrapped for tmux:

if [[ -n "${TMUX:-}" ]]; then
  printf '\033Ptmux;\033\033]52;c;%s\033\033\\\033\\' "$data" > /dev/tty
else
  printf '\033]52;c;%s\033\\' "$data" > /dev/tty
fi

The DCS tmux; ... ST wrapper is tmux's passthrough escape — it tells tmux "hand this sequence to the outer terminal as-is", needed because tmux otherwise intercepts OSC 52.

Verifying the chain works

On the remote box where Neovim yank is misbehaving, run:

# tmux: does it believe the terminal can accept OSC 52?
tmux info | grep -Ei 'clipboard|Ms:'
# expect: clipboard: true  and an Ms entry that is NOT [missing]

# shell: can x copy reach the clipboard?
printf "hello from %s" "$(hostname)" | x copy
# then Cmd+V / Ctrl+V into a local app → should paste "hello from <remote-host>"

# Neovim: which provider is active?
nvim -c ':checkhealth provider.clipboard' -c ':only'
# expect: OSC 52 copy-only under SSH/Herdr/Zellij, native under direct local

If tmux info shows clipboard: false after editing common.conf, remember a running tmux server keeps its old capability table — tmux kill-server (losing sessions; tmux-resurrect can restore them) and re-attach.

Common failure modes

Symptom Likely cause Fix
Yank works outside tmux but vanishes inside tmux (remote) set-clipboard off or server running pre-edit binary tmux show -g set-clipboard; tmux kill-server
tmux info shows Ms: [missing] tmux's TERM entry lacks Ms and no terminal-features …:clipboard declared already handled by common.conf; on an unusual outer $TERM, add another set -as terminal-features line
Neovim shows Waiting for OSC 52 response from the terminal. Press Ctrl-C to interrupt... after p A full OSC 52 provider was paired with clipboard=unnamedplus; ordinary paste became a terminal clipboard query, which Herdr does not return fixed by the copy-only provider; use ordinary p for Neovim registers and terminal Cmd+V / Ctrl+Shift+V for external clipboard text. See pitfalls/nvim-p-waits-for-osc52-response-in-herdr.md
Explicit "+p has no external clipboard content under SSH/Herdr/Zellij copy-only mode intentionally never reads the client clipboard; it can only replay this Neovim process's last outbound + copy expected — use the terminal's native paste
prefix+y still copies to remote clipboard shim binary tmux resolves to an old pre-load-buffer -w version tmux -V must be ≥ 3.3 for load-buffer -w; this repo enforces ≥ 3.3 via ansible devtools role
Nested tmux (tmux inside ssh inside tmux) inner tmux eats OSC 52 inner tmux needs set -g allow-passthrough on; already on in this config. Double-nested cases may require Ptmux;… wrapping
x copy errors with "OSC 52 payload too large" (e.g. copying full pane scrollback over SSH) payload exceeds the terminal's OSC 52 cap; x copy now refuses loudly instead of writing an escape sequence the terminal would silently drop copy a smaller selection, or raise the cap: X_OSC52_MAX_BYTES=<n> x copy ... if your terminal supports more (iTerm2 ~1 MB)
x copy / nvim yank inside herdr (or zellij/mosh) puts nothing on the local clipboard, or lands on the remote box's the pane env is frozen at multiplexer-server start: $WAYLAND_DISPLAY set, $SSH_* empty, so x/nvim think "local Wayland" and use wl-copy. See pitfalls/x-copy-over-ssh-writes-remote-clipboard-not-osc52.md fixed — prefer_osc52 now also fires on HERDR_ENV/ZELLIJ. For anything else that hides SSH_*, export X_CLIPBOARD=osc52. If it still fails, the local terminal isn't forwarding OSC 52, or a stale local tmux server (tmux kill-server)

Design notes / non-defaults

  • tmux set-clipboard is on, not external. external disables tmux's own OSC 52 emission from copy-mode; on is a superset and keeps prefix+[ → y working.
  • Neovim OSC 52 is copy-only under SSH_* / HERDR_ENV / ZELLIJ / X_CLIPBOARD=osc52. Never wire vim.ui.clipboard.osc52.paste() into unnamedplus there: a single p can otherwise wait ten seconds for a response the multiplexer or terminal will never deliver. X_CLIPBOARD=<local tool> remains the explicit opt-out when the server-side clipboard is genuinely the desired one.
  • x CLI ordering prefers local backends (pbcopy, wl-copy, etc.) over OSC 52 — except when prefer_osc52() is true (SSH_* / HERDR_ENV / ZELLIJ) or X_CLIPBOARD=osc52, where OSC 52 goes first (a local backend would be the wrong machine or a frozen display). Local-first is intentional: pbcopy/wl-copy is faster, doesn't touch the TTY, and works for very large payloads (OSC 52 is size-limited by the terminal — iTerm2 ~1 MB, many others 64-256 KB). Because OSC 52 has no delivery acknowledgment, osc52_copy() refuses payloads above X_OSC52_MAX_BYTES (default 75000 base64 chars) rather than writing an escape sequence the terminal would silently drop — a plain write(2) to /dev/tty succeeds either way, so without this guard x copy would report success when nothing reached the clipboard. The OSC 52-first branch opens /dev/tty for real (not just [[ -w ]]) before consuming stdin, so a no-PTY ssh still falls through cleanly.

Clipboard history (cb / cbl / cbe)

OSC 52 covers sync — getting yanked text from a remote box to your local clipboard. Clipboard history — recalling something you copied an hour ago — is a separate concern with a separate backend, wrapped behind a unified shell API by dot_config/shell/56_clipboard_history.sh.

Hybrid backend

OS Backend Install CLI surface
macOS Maccy brew install --cask maccy (signed, healthy on brew, auto-updating, macOS 14+) None shipped — helper queries Maccy's plaintext SQLite directly
Ubuntu desktop CopyQ sudo apt install copyq (stock 22.04 / 24.04 repos; ansible role: gui_apps_linux) copyq read / select / eval / size / copy
Ubuntu server — none helper prints install hint and returns 1

Why hybrid rather than CopyQ everywhere: the CopyQ Homebrew cask is deprecated (disabled 2026-09-01) because CopyQ.app is not signed for macOS Gatekeeper. Only the cask is affected — the upstream project is fine, and the Linux package is unaffected (see hluk/CopyQ#3498). Maccy is the most maintained signed-and-on-brew clipboard manager for macOS; the trade-off is no shipped CLI, so we query the plaintext SQLite at ~/Library/Application Support/Maccy/Storage.sqlite (cask install) or ~/Library/Containers/org.p0deje.Maccy/.../Storage.sqlite (Mac App Store sandboxed).

API

Command Behaviour
cbl [N] List recent N clipboard items (default 20), tab-separated <idx>\t<first-line-preview>. Filters to UTF-8 plain text only.
cb Pipe cbl 100 to fzf → selected item is moved to the top of the stack and copied to the system clipboard. CopyQ uses copyq select; Maccy uses pbcopy (Maccy then re-records, adding a duplicate at index 0 — known quirk, not a bug).
cbe Same fzf flow as cb, but reads the item into a tempfile, opens $EDITOR, then writes the edited contents back to the clipboard.

The first column of cbl's output is: - For CopyQ: the position index (0 = top of stack) - For Maccy: the SQLite Z_PK primary key

This is intentional — each backend can address its own items round-trip without translation.

Manual backend override

Set CLIP_BACKEND=copyq or CLIP_BACKEND=maccy to skip autodetect (useful if both are installed on the same mac and you want the more-reliable CopyQ CLI):

CLIP_BACKEND=copyq cb     # one-off
export CLIP_BACKEND=copyq  # session

Unknown values print a hint and return 1.

Coexistence with Raycast

On macOS the new helper does not disable Raycast's clipboard module — both will record every ⌘C independently. Two separate histories, no functional conflict, slight storage duplication. To disable: Raycast → Extensions → Clipboard History → toggle off.

Threat model — secrets in plaintext

Both Maccy and CopyQ store unencrypted SQLite. Anything you copy from a terminal / browser / text editor as plain text (e.g. cat .env, an API key pasted from Slack) lands in the DB readable by your own shell.

What is NOT recorded: - macOS pasteboard items marked org.nspasteboard.ConcealedType / TransientType / AutoGeneratedType. 1Password, Bitwarden, KeePassXC, Keychain Access all set these — secrets pasted via those tools never enter the history. - Anything copied while Maccy's "Ignore Events" toggle (⌥-click menu icon) or CopyQ's "Disable Clipboard Storing" is active.

For ad-hoc secret pastes, toggle "Ignore" before pasting and toggle off after.

DB paths (for backup / inspection)

  • Maccy (cask): ~/Library/Application Support/Maccy/Storage.sqlite (+ -wal, -shm)
  • Maccy (Mac App Store): ~/Library/Containers/org.p0deje.Maccy/Data/Library/Application Support/Maccy/Storage.sqlite
  • CopyQ: ~/.config/copyq/ on Linux, ~/Library/Application Support/copyq/ on macOS

The Maccy schema is documented in the helper file's header comment — verified against p0deje/Maccy@master source files (HistoryItem.swift + HistoryItemContent.swift).

External references