diff --git a/muxplex/frontend/terminal.js b/muxplex/frontend/terminal.js index 8045796..1e0de02 100644 --- a/muxplex/frontend/terminal.js +++ b/muxplex/frontend/terminal.js @@ -365,11 +365,12 @@ function openTerminal(sessionName, remoteId) { // --- Clipboard integration --- // Ctrl+Shift+C: copy selection to system clipboard (Ctrl+C still sends SIGINT) - // Ctrl+Shift+V: handled natively by xterm.js — browser fires paste event on xterm's hidden - // textarea → xterm.js handlePasteEvent → prepareTextForTerminal → bracketTextForPaste - // → triggerDataEvent → onData → WebSocket. This path correctly handles both Unicode - // encoding (TextEncoder) and bracketed paste mode (multiline protection). Do NOT - // intercept Ctrl+Shift+V here — every custom handler we tried broke one or the other. + // Ctrl+Shift+V: Ctrl+Shift+V does NOT trigger a native browser paste event (unlike Cmd+V + // on macOS). We read the clipboard via navigator.clipboard.readText() and dispatch a + // synthetic ClipboardEvent on xterm.js's hidden textarea. xterm.js catches this via its + // built-in handlePasteEvent listener → prepareTextForTerminal → bracketTextForPaste + // → triggerDataEvent → onData → WebSocket. This is the same path as Cmd+V and correctly + // handles CR/LF normalization, bracketed paste mode, and UTF-8 encoding. _term.attachCustomKeyEventHandler(function(e) { if (e.type !== 'keydown') return true; @@ -380,6 +381,24 @@ function openTerminal(sessionName, remoteId) { return false; // prevent xterm from processing } + // Ctrl+Shift+V → paste from system clipboard via synthetic ClipboardEvent on xterm textarea. + // Dispatching on _term.textarea triggers xterm.js's built-in handlePasteEvent, which + // handles CR/LF normalization, bracketed paste mode, and correct UTF-8 encoding — + // the exact same code path as a native Cmd+V paste on macOS. + if (e.ctrlKey && e.shiftKey && (e.key === 'V' || e.code === 'KeyV')) { + if (navigator.clipboard && navigator.clipboard.readText && _term && _term.textarea) { + navigator.clipboard.readText().then(function(text) { + if (!text || !_term || !_term.textarea) return; + var pasteEvent = new ClipboardEvent('paste', { + clipboardData: new DataTransfer(), + }); + pasteEvent.clipboardData.setData('text/plain', text); + _term.textarea.dispatchEvent(pasteEvent); + }).catch(function() {}); + } + return false; // prevent xterm from processing the raw key + } + // Ctrl+F → open search bar if (e.ctrlKey && !e.shiftKey && (e.key === 'f' || e.key === 'F' || e.code === 'KeyF')) { _openSearch(); diff --git a/muxplex/frontend/tests/test_terminal.mjs b/muxplex/frontend/tests/test_terminal.mjs index 92c65ae..d9a0470 100644 --- a/muxplex/frontend/tests/test_terminal.mjs +++ b/muxplex/frontend/tests/test_terminal.mjs @@ -993,21 +993,25 @@ test('terminal.js loads xterm-addon-image for inline graphics', () => { assert.ok(source.includes('ImageAddon'), 'must reference ImageAddon'); }); -// --- Native paste: xterm.js handles Ctrl+Shift+V --- +// --- Ctrl+Shift+V: synthetic paste event on xterm textarea --- -test('terminal.js does NOT intercept Ctrl+Shift+V — lets xterm.js handle paste natively', () => { - // Regression: every custom paste handler we tried broke either multiline or Unicode. - // xterm.js's native paste pipeline (browser paste event → handlePasteEvent → - // prepareTextForTerminal → bracketTextForPaste → triggerDataEvent → onData → WS) - // handles both correctly. Fix: stop intercepting Ctrl+Shift+V entirely. +test('terminal.js Ctrl+Shift+V dispatches synthetic paste event on textarea', () => { + // Ctrl+Shift+V does NOT trigger a native browser paste event (unlike Cmd+V on macOS). + // Fix: read clipboard via navigator.clipboard.readText(), then dispatch a synthetic + // ClipboardEvent on xterm.js's hidden textarea. This goes through xterm.js's built-in + // handlePasteEvent which handles CR/LF normalization, bracketed paste, and correct encoding. const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8'); const handlerStart = source.indexOf('attachCustomKeyEventHandler'); const handlerEnd = source.indexOf('// Auto-copy:', handlerStart); const handlerBlock = source.substring(handlerStart, handlerEnd); - const hasVIntercept = handlerBlock.includes("e.key === 'V'") || handlerBlock.includes("e.code === 'KeyV'"); - assert.ok(!hasVIntercept, - 'attachCustomKeyEventHandler must NOT intercept Ctrl+Shift+V — ' + - 'xterm.js handles paste natively with correct encoding + bracketed paste'); + assert.ok( + handlerBlock.includes("e.key === 'V'") || handlerBlock.includes("e.code === 'KeyV'"), + 'attachCustomKeyEventHandler must intercept Ctrl+Shift+V to trigger paste', + ); + assert.ok( + handlerBlock.includes('ClipboardEvent') || handlerBlock.includes('dispatchEvent'), + 'must dispatch synthetic paste event so xterm.js handlePasteEvent handles encoding correctly', + ); }); // --- UTF-8 output decoding via TextDecoder ---