feat: auto-copy mouse selection + OSC 52 tmux clipboard bridge

Mouse selection: onSelectionChange auto-copies to system clipboard on
every selection change (last fire on mouse-up captures final text).
Matches iTerm2/WezTerm/ttyd native select-to-copy behavior.

OSC 52: Custom parser.registerOscHandler(52, ...) decodes base64
clipboard data from tmux. When tmux copies text (set-clipboard on
in .tmux.conf), it sends OSC 52 to the terminal. We intercept and
write to system clipboard via _copyToClipboard (with execCommand
fallback for HTTP/LAN contexts).

Together: mouse-select auto-copies, and tmux Ctrl+B [ -> select ->
Enter copies to system clipboard via OSC 52.
This commit is contained in:
Brian Krabach
2026-04-01 10:09:20 -07:00
parent ff989ae920
commit 93a66c0cb9
2 changed files with 58 additions and 0 deletions
+30
View File
@@ -312,6 +312,36 @@ function openTerminal(sessionName, sourceUrl) {
return true; // let xterm handle all other keys normally return true; // let xterm handle all other keys normally
}); });
// Auto-copy: when mouse selection ends, copy to system clipboard.
// Matches terminal emulator conventions (iTerm2, WezTerm, ttyd native).
// onSelectionChange fires whenever selection changes — copy if text is selected.
// When selection is cleared (empty string), we skip the clipboard write.
_term.onSelectionChange(function() {
var sel = _term.getSelection();
if (sel) {
_copyToClipboard(sel);
}
});
// OSC 52 clipboard integration — bridges tmux clipboard to the browser.
// When tmux copies text (with `set-clipboard on` in .tmux.conf), it sends
// an OSC 52 escape sequence to the terminal. xterm.js surfaces this via the
// parser API. We intercept and write the decoded text to the system clipboard
// so that: Ctrl+B [ → select → Enter (tmux copy) → system clipboard receives it.
_term.parser.registerOscHandler(52, function(data) {
// OSC 52 format: Pc ; Pd — Pc = selection target (c/p/q/s/0-7), Pd = base64 text
var parts = data.split(';');
if (parts.length >= 2) {
try {
var text = atob(parts[1]);
_copyToClipboard(text);
} catch (e) {
// Invalid base64 or unsupported — silently ignore
}
}
return true; // Handled — don't pass to xterm's default handler
});
if (_fitAddon) { if (_fitAddon) {
// requestAnimationFrame guarantees one full browser layout pass after the flex // requestAnimationFrame guarantees one full browser layout pass after the flex
// container becomes visible before fit() measures dimensions. // container becomes visible before fit() measures dimensions.
+28
View File
@@ -49,6 +49,8 @@ function loadTerminal() {
focus: () => { focusCallCount++; }, focus: () => { focusCallCount++; },
attachCustomKeyEventHandler: () => {}, attachCustomKeyEventHandler: () => {},
getSelection: () => '', getSelection: () => '',
onSelectionChange: () => {},
parser: { registerOscHandler: () => {} },
}; };
// Capture all messages sent via WebSocket.send() // Capture all messages sent via WebSocket.send()
@@ -335,6 +337,8 @@ function createMultiSessionEnv() {
focus: () => {}, focus: () => {},
attachCustomKeyEventHandler: () => {}, attachCustomKeyEventHandler: () => {},
getSelection: () => '', getSelection: () => '',
onSelectionChange: () => {},
parser: { registerOscHandler: () => {} },
writeMessages: [], writeMessages: [],
}; };
t.write = (data) => t.writeMessages.push(data); t.write = (data) => t.writeMessages.push(data);
@@ -921,4 +925,28 @@ test('_setTerminalFontSize sets _term.options.fontSize and calls _fitAddon.fit()
); );
}); });
// --- Clipboard Issue 1: auto-copy mouse selection via onSelectionChange ---
test('terminal.js auto-copies mouse selection to clipboard via onSelectionChange', () => {
const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
assert.ok(
source.includes('onSelectionChange'),
'must register onSelectionChange handler to auto-copy mouse selection to clipboard',
);
});
// --- Clipboard Issue 2: OSC 52 handler bridges tmux clipboard to browser ---
test('terminal.js registers OSC 52 handler for tmux clipboard bridge', () => {
const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
assert.ok(
source.includes('registerOscHandler'),
'must call parser.registerOscHandler to intercept tmux OSC 52 clipboard sequences',
);
assert.ok(
source.includes('atob'),
'must decode base64 OSC 52 clipboard payload with atob()',
);
});