feat(terminal): copy OSC 52 clipboard writes from the PTY into the browser clipboard

Programs inside the terminal (Claude Code /copy, tmux yanks with
set-clipboard on, vim) set the clipboard with OSC 52. xterm.js registers no
handler for 52, so on a headless host (no xclip, no DISPLAY) those copies
were silently dropped and never reached the remote browser.

Register one handler on the main terminal and on every TerminalTile (split
Pane B, tile grid). It decodes the base64 payload as UTF-8 and copies it
through _copyText, falling back to the manual-copy dialog when the browser
blocks the write. Read queries (`?`) are refused, and nothing is copied
during a buffer replay, so a tab switch or reconnect never re-copies old
OSC 52 sequences.
This commit is contained in:
Zoltan Zombory committed 2026-10-12 20:39:07 +02:00
1 parent aa3730aace
commit 435a56be10
3 files changed
+158

No files matched your search

+3
View File
@@ -243,6 +243,9 @@
this.fitAddon = new FitAddon.FitAddon();
this.terminal.loadAddon(this.fitAddon);
// OSC 52 clipboard writes from this pane's PTY, gated on THIS pane's
// buffer load so a replay never re-copies (terminal-ui.js).
global.app?._installOsc52Clipboard?.(this.terminal, () => this._bufferLoading);
this.terminal.open(this.mountEl);
this.fitAddon.fit();
+30
View File
@@ -612,6 +612,8 @@ Object.assign(CodemanApp.prototype, {
}
}
this._installOsc52Clipboard(this.terminal);
const container = document.getElementById('terminalContainer');
this.terminal.open(container);
this._initMobileImePreview();
@@ -4923,6 +4925,34 @@ Object.assign(CodemanApp.prototype, {
return Number.isInteger(columns) && columns > 0 ? columns : 0;
},
// OSC 52 (`ESC ] 52 ; <sel> ; <base64> BEL`) is how programs inside the PTY —
// Claude Code's /copy, tmux yanks with set-clipboard on, vim — set the
// clipboard. The server has no display, so this is the only route to the
// browser's clipboard. Writes only: a `?` query (clipboard read) is ignored,
// and so is anything arriving during a buffer replay, which would otherwise
// re-copy every old OSC 52 on each tab switch or reconnect. `isReplaying`
// lets a TerminalTile (split Pane B, tile grid) supply its own load flag.
_installOsc52Clipboard(terminal, isReplaying = () => this._isLoadingBuffer) {
terminal.parser?.registerOscHandler?.(52, (data) => {
const sep = data.indexOf(';');
const payload = sep >= 0 ? data.slice(sep + 1) : data;
if (!payload || payload === '?' || isReplaying()) return true;
let text;
try {
const bytes = Uint8Array.from(atob(payload), (c) => c.charCodeAt(0));
text = new TextDecoder().decode(bytes);
} catch {
return true;
}
if (!text) return true;
this._copyText(text).then((ok) => {
if (ok) this.showToast(`Copied to clipboard (${text.length} chars)`, 'success');
else this._showClipboardFallback?.(text);
});
return true;
});
},
// Copy the current terminal selection. Goes through _copyText (Clipboard API,
// then a hidden-textarea + execCommand fallback) because install.sh's LAN
// option serves plain HTTP, where navigator.clipboard is undefined.