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:
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¶
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.externalwould only pass through;onis a strict superset and simpler.terminal-features …:clipboard— tells tmux the outer terminal supports OSC 52 without needing a terminfoMscapability. Many distros ship atmux-256colorentry that lacksMs, so declaring the feature directly avoids surprises whenchezmoi applyis 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 macOSpbcopyis built in; on a Linux desktop the binary comes fromwl-clipboard/xclip, installed by thegui_apps_linuxrole ("Install clipboard CLIs"). Without it,:checkhealth provider.clipboardreports "No clipboard tool found" and yank silently no-ops — seepitfalls/lazygit-ctrl-o-no-clipboard-utilities-nvim-yank-silent.md. - Remote / multiplexed —
SSH_CONNECTION/SSH_TTY/SSH_CLIENT(an interactivesshdsession), orHERDR_ENV/ZELLIJ— Neovim emits OSC 52 only for copy, so yanks reach whatever terminal is attached while ordinarypstays 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 (awl-copythere hits a stale display), while the pane TTY still proxies to the real client.tmuxrefreshes those vars viaupdate-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 callosc52.paste(). Before any such copy, explicit"+pwarns and no-ops; use terminal paste for inbound clipboard text. X_CLIPBOARD=osc52forces copy-only OSC 52;X_CLIPBOARD=<any local tool>forces the native provider. It sharesx's environment knob and selection predicate, but deliberately notx pastesemantics.
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:
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-clipboardison, notexternal.externaldisables tmux's own OSC 52 emission from copy-mode;onis a superset and keepsprefix+[→yworking. - Neovim OSC 52 is copy-only under
SSH_*/HERDR_ENV/ZELLIJ/X_CLIPBOARD=osc52. Never wirevim.ui.clipboard.osc52.paste()intounnamedplusthere: a singlepcan 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. xCLI ordering prefers local backends (pbcopy,wl-copy, etc.) over OSC 52 — except whenprefer_osc52()is true (SSH_*/HERDR_ENV/ZELLIJ) orX_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-copyis 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 aboveX_OSC52_MAX_BYTES(default 75000 base64 chars) rather than writing an escape sequence the terminal would silently drop — a plainwrite(2)to/dev/ttysucceeds either way, so without this guardx copywould report success when nothing reached the clipboard. The OSC 52-first branch opens/dev/ttyfor real (not just[[ -w ]]) before consuming stdin, so a no-PTYsshstill 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):
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).
Related docs¶
- Remote image paste (SSH) — the image-shaped sequel: getting a local screenshot into a remote coding agent (OSC 52 can't)
- tmux — "OSC 52 Clipboard (SSH-friendly yank)" section
- Ghostty — terminal-side notes
dot_dotfiles/bin/executable_x— thexwrapper sourcedot_config/shell/56_clipboard_history.sh— clipboard-history backend dispatch