fix: remove custom Ctrl+Shift+V handler — let xterm.js handle paste natively

Our custom paste handler fought xterm.js's built-in paste pipeline.
Every approach (raw send, _term.paste(), hybrid bracketed+send) broke
either multiline behavior or Unicode encoding. The native xterm.js
paste path (browser paste event → handlePasteEvent → prepareTextForTerminal
→ bracketTextForPaste → triggerDataEvent → onData → WebSocket) handles
both correctly. Fix: stop intercepting Ctrl+Shift+V entirely.
This commit is contained in:
Brian Krabach
2026-04-06 20:37:12 -07:00
parent 0a3a20a1b9
commit c71a551b06
2 changed files with 20 additions and 96 deletions
+6 -26
View File
@@ -26,7 +26,7 @@ function _encodePayload(typeChar, str) {
// ─── Clipboard helpers ─────────────────────────────────────────────────────── // ─── Clipboard helpers ───────────────────────────────────────────────────────
// Ctrl+Shift+C: copy terminal selection to system clipboard // Ctrl+Shift+C: copy terminal selection to system clipboard
// Ctrl+Shift+V: paste from system clipboard into terminal // Ctrl+Shift+V: handled natively by xterm.js (browser paste event → xterm → WebSocket)
function _copyToClipboard(text) { function _copyToClipboard(text) {
if (navigator.clipboard && navigator.clipboard.writeText) { if (navigator.clipboard && navigator.clipboard.writeText) {
@@ -359,7 +359,11 @@ 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: paste from system clipboard (Ctrl+V still sends literal bytes) // 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.
_term.attachCustomKeyEventHandler(function(e) { _term.attachCustomKeyEventHandler(function(e) {
if (e.type !== 'keydown') return true; if (e.type !== 'keydown') return true;
@@ -370,30 +374,6 @@ function openTerminal(sessionName, remoteId) {
return false; // prevent xterm from processing return false; // prevent xterm from processing
} }
// Ctrl+Shift+V → paste from clipboard into terminal
if (e.ctrlKey && e.shiftKey && (e.key === 'V' || e.code === 'KeyV')) {
e.preventDefault(); // suppress browser native paste event (prevents double-paste via xterm textarea)
if (navigator.clipboard && navigator.clipboard.readText) {
navigator.clipboard.readText().then(function(text) {
if (text && _ws && _ws.readyState === WebSocket.OPEN) {
// Hybrid paste: manual bracketed paste wrapping + direct UTF-8 WebSocket send.
// The xterm.js paste pipeline garbled Unicode box-drawing chars (â instead of
// ─│┌┐) because its internals process text differently than TextEncoder encoding.
//
// This approach gives us both:
// 1. \r\n→\r conversion (same as xterm.js prepareTextForTerminal)
// 2. Bracketed paste markers (ESC[200~...ESC[201~) so shells with bracketed
// paste mode treat the block as pasted text, not individual Enter-submitted lines
// 3. Direct _encodePayload/TextEncoder path → correct UTF-8 bytes (proven path)
var prepared = text.replace(/\r?\n/g, '\r');
var payload = '\x1b[200~' + prepared + '\x1b[201~';
_ws.send(_encodePayload(0x30, payload));
}
}).catch(function() {});
}
return false; // prevent xterm from processing
}
// 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 -70
View File
@@ -901,7 +901,7 @@ test('terminal.js resets _reconnectAttempts on first message, not on open', () =
// --- Clipboard integration --- // --- Clipboard integration ---
test('terminal.js has clipboard integration with Ctrl+Shift+C and Ctrl+Shift+V', () => { test('terminal.js has clipboard integration with Ctrl+Shift+C (copy) and native paste support', () => {
const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8'); const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
assert.ok(source.includes('attachCustomKeyEventHandler'), 'must register custom key handler'); assert.ok(source.includes('attachCustomKeyEventHandler'), 'must register custom key handler');
assert.ok(source.includes('getSelection'), 'must use getSelection() for copy'); assert.ok(source.includes('getSelection'), 'must use getSelection() for copy');
@@ -991,77 +991,21 @@ 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');
}); });
// --- Clipboard double-paste bug fix --- // --- Native paste: xterm.js handles Ctrl+Shift+V ---
test('terminal.js Ctrl+Shift+V handler calls preventDefault to suppress double paste', () => { test('terminal.js does NOT intercept Ctrl+Shift+V — lets xterm.js handle paste natively', () => {
// Regression: without e.preventDefault(), the browser fires a native paste event on // Regression: every custom paste handler we tried broke either multiline or Unicode.
// xterm.js's hidden textarea after our handler sends via WebSocket, causing double paste. // 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.
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');
// Locate the Ctrl+Shift+V block by finding the key check, then extracting the block const handlerEnd = source.indexOf('// Auto-copy:', handlerStart);
const vIdx = source.indexOf("e.key === 'V'"); const handlerBlock = source.substring(handlerStart, handlerEnd);
assert.ok(vIdx !== -1, "must have a Ctrl+Shift+V key check"); const hasVIntercept = handlerBlock.includes("e.key === 'V'") || handlerBlock.includes("e.code === 'KeyV'");
const blockStart = source.lastIndexOf('if (', vIdx); assert.ok(!hasVIntercept,
const blockEnd = source.indexOf('return false', vIdx); 'attachCustomKeyEventHandler must NOT intercept Ctrl+Shift+V — ' +
const vBlock = source.substring(blockStart, blockEnd); 'xterm.js handles paste natively with correct encoding + bracketed paste');
assert.ok(
vBlock.includes('preventDefault'),
'Ctrl+Shift+V handler must call e.preventDefault() before the async clipboard read ' +
'to suppress the browser native paste event (which causes double-paste via xterm textarea)',
);
});
// --- Multiline paste (bracketed paste mode) ---
test('terminal.js Ctrl+Shift+V uses bracketed paste wrapping (multiline protection)', () => {
// Regression history:
// v1 — raw _ws.send(): no bracketed paste markers, each \n executed as Enter
// v2 — _term.paste(): bracketed paste mode works but garbles Unicode box-drawing chars
// v3 (current) — hybrid: manual bracketed markers + direct UTF-8 WebSocket send
// This test validates the multiline protection aspect of v3.
const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
// Locate the Ctrl+Shift+V block
const vIdx = source.indexOf("e.key === 'V'");
assert.ok(vIdx !== -1, "must have a Ctrl+Shift+V key check");
const blockStart = source.lastIndexOf('if (', vIdx);
const blockEnd = source.indexOf('return false', vIdx);
const vBlock = source.substring(blockStart, blockEnd);
assert.ok(
vBlock.includes('\\x1b[200~') || vBlock.includes('\\u001b[200~') || vBlock.includes('[200~'),
'Ctrl+Shift+V must include bracketed paste markers (ESC[200~...ESC[201~) for multiline protection',
);
});
// --- Hybrid paste: bracketed markers + direct WebSocket send (Unicode fix) ---
test('terminal.js Ctrl+Shift+V uses bracketed paste with direct WebSocket send', () => {
// Regression: _term.paste() garbled Unicode box-drawing characters (â instead of ─│┌┐).
// Fix: manually apply bracketed paste wrapping (ESC[200~...ESC[201~) for multiline
// protection, then send via _encodePayload/TextEncoder for correct UTF-8 encoding.
const source = fs.readFileSync(new URL('../terminal.js', import.meta.url), 'utf8');
const vIdx = source.indexOf("e.key === 'V'");
assert.ok(vIdx !== -1, "must have a Ctrl+Shift+V key check");
const blockStart = source.lastIndexOf('if (', vIdx);
const blockEnd = source.indexOf('return false', vIdx);
const vBlock = source.substring(blockStart, blockEnd);
// Must have bracketed paste markers
assert.ok(
vBlock.includes('\\x1b[200~') || vBlock.includes('\\u001b[200~') || vBlock.includes('[200~'),
'Ctrl+Shift+V must include bracketed paste markers for multiline protection',
);
// Must send via WebSocket (not _term.paste)
assert.ok(
vBlock.includes('_ws.send') || vBlock.includes('_encodePayload'),
'Ctrl+Shift+V must send via WebSocket for correct UTF-8 encoding',
);
// Must NOT use _term.paste (causes Unicode garbling)
assert.ok(
!vBlock.includes('_term.paste'),
'Ctrl+Shift+V must NOT use _term.paste() — it garbles Unicode characters',
);
}); });
// --- Federation reconnect routing --- // --- Federation reconnect routing ---