feat: restore Ctrl+Shift+V via synthetic paste event on xterm 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
triggers xterm.js's built-in handlePasteEvent which handles CR/LF
normalization, bracketed paste mode, and correct UTF-8 encoding — the
same code path as Cmd+V.

Also replaces the old regression test that asserted Ctrl+Shift+V was
NOT intercepted, replacing it with the correct test for the synthetic
paste approach.
This commit is contained in:
Brian Krabach
2026-04-07 02:12:49 -07:00
parent ced0c62dd6
commit e4a4c97eec
2 changed files with 38 additions and 15 deletions
+24 -5
View File
@@ -365,11 +365,12 @@ function openTerminal(sessionName, remoteId) {
// --- Clipboard integration --- // --- Clipboard integration ---
// Ctrl+Shift+C: copy selection to system clipboard (Ctrl+C still sends SIGINT) // 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 // Ctrl+Shift+V: Ctrl+Shift+V does NOT trigger a native browser paste event (unlike Cmd+V
// textarea → xterm.js handlePasteEvent → prepareTextForTerminal → bracketTextForPaste // on macOS). We read the clipboard via navigator.clipboard.readText() and dispatch a
// → triggerDataEvent onData → WebSocket. This path correctly handles both Unicode // synthetic ClipboardEvent on xterm.js's hidden textarea. xterm.js catches this via its
// encoding (TextEncoder) and bracketed paste mode (multiline protection). Do NOT // built-in handlePasteEvent listener → prepareTextForTerminal → bracketTextForPaste
// intercept Ctrl+Shift+V here — every custom handler we tried broke one or the other. // → 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) { _term.attachCustomKeyEventHandler(function(e) {
if (e.type !== 'keydown') return true; if (e.type !== 'keydown') return true;
@@ -380,6 +381,24 @@ function openTerminal(sessionName, remoteId) {
return false; // prevent xterm from processing 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 // Ctrl+F → open search bar
if (e.ctrlKey && !e.shiftKey && (e.key === 'f' || e.key === 'F' || e.code === 'KeyF')) { if (e.ctrlKey && !e.shiftKey && (e.key === 'f' || e.key === 'F' || e.code === 'KeyF')) {
_openSearch(); _openSearch();
+14 -10
View File
@@ -993,21 +993,25 @@ test('terminal.js loads xterm-addon-image for inline graphics', () => {
assert.ok(source.includes('ImageAddon'), 'must reference ImageAddon'); 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', () => { test('terminal.js Ctrl+Shift+V dispatches synthetic paste event on textarea', () => {
// Regression: every custom paste handler we tried broke either multiline or Unicode. // Ctrl+Shift+V does NOT trigger a native browser paste event (unlike Cmd+V on macOS).
// xterm.js's native paste pipeline (browser paste event → handlePasteEvent → // Fix: read clipboard via navigator.clipboard.readText(), then dispatch a synthetic
// prepareTextForTerminal → bracketTextForPaste → triggerDataEvent → onData → WS) // ClipboardEvent on xterm.js's hidden textarea. This goes through xterm.js's built-in
// handles both correctly. Fix: stop intercepting Ctrl+Shift+V entirely. // handlePasteEvent which handles CR/LF normalization, bracketed paste, and correct encoding.
const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8'); const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
const handlerStart = source.indexOf('attachCustomKeyEventHandler'); const handlerStart = source.indexOf('attachCustomKeyEventHandler');
const handlerEnd = source.indexOf('// Auto-copy:', handlerStart); const handlerEnd = source.indexOf('// Auto-copy:', handlerStart);
const handlerBlock = source.substring(handlerStart, handlerEnd); const handlerBlock = source.substring(handlerStart, handlerEnd);
const hasVIntercept = handlerBlock.includes("e.key === 'V'") || handlerBlock.includes("e.code === 'KeyV'"); assert.ok(
assert.ok(!hasVIntercept, handlerBlock.includes("e.key === 'V'") || handlerBlock.includes("e.code === 'KeyV'"),
'attachCustomKeyEventHandler must NOT intercept Ctrl+Shift+V — ' + 'attachCustomKeyEventHandler must intercept Ctrl+Shift+V to trigger paste',
'xterm.js handles paste natively with correct encoding + bracketed 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 --- // --- UTF-8 output decoding via TextDecoder ---