diff --git a/muxplex/frontend/terminal.js b/muxplex/frontend/terminal.js index bc99893..49527c6 100644 --- a/muxplex/frontend/terminal.js +++ b/muxplex/frontend/terminal.js @@ -312,6 +312,36 @@ function openTerminal(sessionName, sourceUrl) { 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) { // requestAnimationFrame guarantees one full browser layout pass after the flex // container becomes visible before fit() measures dimensions. diff --git a/muxplex/frontend/tests/test_terminal.mjs b/muxplex/frontend/tests/test_terminal.mjs index 68f4263..726dd3c 100644 --- a/muxplex/frontend/tests/test_terminal.mjs +++ b/muxplex/frontend/tests/test_terminal.mjs @@ -49,6 +49,8 @@ function loadTerminal() { focus: () => { focusCallCount++; }, attachCustomKeyEventHandler: () => {}, getSelection: () => '', + onSelectionChange: () => {}, + parser: { registerOscHandler: () => {} }, }; // Capture all messages sent via WebSocket.send() @@ -335,6 +337,8 @@ function createMultiSessionEnv() { focus: () => {}, attachCustomKeyEventHandler: () => {}, getSelection: () => '', + onSelectionChange: () => {}, + parser: { registerOscHandler: () => {} }, writeMessages: [], }; 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()', + ); +}); +