From ff989ae920549617442a6a2fe6257bb7dc3ba3d4 Mon Sep 17 00:00:00 2001 From: Brian Krabach Date: Wed, 1 Apr 2026 09:51:23 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20native=20clipboard=20support=20?= =?UTF-8?q?=E2=80=94=20Ctrl+Shift+C=20to=20copy,=20Ctrl+Shift+V=20to=20pas?= =?UTF-8?q?te?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Uses xterm.js attachCustomKeyEventHandler to intercept Ctrl+Shift+C/V. Copy: getSelection() → navigator.clipboard.writeText (with execCommand fallback for non-HTTPS contexts). Paste: navigator.clipboard.readText() → send to ttyd via WebSocket binary protocol (0x30 prefix). Ctrl+C/V continue to work as normal terminal keys (SIGINT / literal paste). The Shift modifier distinguishes clipboard from terminal operations. Also hoists encodePayload + TextEncoder to module level (_encodePayload, _encoder) so the clipboard handler can reuse them without duplication. --- muxplex/frontend/terminal.js | 73 ++++++++++++++++++++---- muxplex/frontend/tests/test_terminal.mjs | 15 +++++ 2 files changed, 78 insertions(+), 10 deletions(-) diff --git a/muxplex/frontend/terminal.js b/muxplex/frontend/terminal.js index 38c94c0..bc99893 100644 --- a/muxplex/frontend/terminal.js +++ b/muxplex/frontend/terminal.js @@ -10,6 +10,39 @@ let _currentSession = null; let _vpHandler = null; let _reconnectAttempts = 0; // tracks consecutive failed reconnect attempts for backoff + ttyd respawn +// ─── Module-level encoding helpers ────────────────────────────────────────── +// Hoisted here so the clipboard key handler (in openTerminal) can also use them. +const _encoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null; + +function _encodePayload(typeChar, str) { + // Returns Uint8Array: [typeCharCode, ...utf8bytes] + var strBytes = _encoder ? _encoder.encode(str) : new Uint8Array(Array.from(str).map(function(c) { return c.charCodeAt(0); })); + var payload = new Uint8Array(1 + strBytes.length); + payload[0] = typeChar; + payload.set(strBytes, 1); + return payload; +} + +// ─── Clipboard helpers ─────────────────────────────────────────────────────── +// Ctrl+Shift+C: copy terminal selection to system clipboard +// Ctrl+Shift+V: paste from system clipboard into terminal + +function _copyToClipboard(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { + navigator.clipboard.writeText(text).catch(function() {}); + } else { + // Fallback for non-HTTPS contexts (HTTP over LAN) + var ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.left = '-9999px'; + document.body.appendChild(ta); + ta.select(); + try { document.execCommand('copy'); } catch(e) {} + document.body.removeChild(ta); + } +} + // ─── Forward declarations ───────────────────────────────────────────────────── function connectWebSocket(name, sourceUrl) { @@ -23,16 +56,8 @@ function connectWebSocket(name, sourceUrl) { url = proto + '//' + location.host + '/terminal/ws'; } const reconnectOverlay = document.getElementById('reconnect-overlay'); - const encoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null; - - function encodePayload(typeChar, str) { - // Returns Uint8Array: [typeCharCode, ...utf8bytes] - var strBytes = encoder ? encoder.encode(str) : new Uint8Array(Array.from(str).map(function(c) { return c.charCodeAt(0); })); - var payload = new Uint8Array(1 + strBytes.length); - payload[0] = typeChar; - payload.set(strBytes, 1); - return payload; - } + // Use module-level _encodePayload (hoisted above connectWebSocket) + var encodePayload = _encodePayload; // Register terminal event handlers once on this _term instance. // These handlers read the module-level _ws at call time (not a captured reference), @@ -259,6 +284,34 @@ function openTerminal(sessionName, sourceUrl) { _term.open(container); + // --- Clipboard integration --- + // 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) + _term.attachCustomKeyEventHandler(function(e) { + if (e.type !== 'keydown') return true; + + // Ctrl+Shift+C → copy selection to clipboard + if (e.ctrlKey && e.shiftKey && (e.key === 'C' || e.code === 'KeyC')) { + var sel = _term.getSelection(); + if (sel) _copyToClipboard(sel); + 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')) { + if (navigator.clipboard && navigator.clipboard.readText) { + navigator.clipboard.readText().then(function(text) { + if (text && _ws && _ws.readyState === WebSocket.OPEN) { + _ws.send(_encodePayload(0x30, text)); + } + }).catch(function() {}); + } + return false; // prevent xterm from processing + } + + return true; // let xterm handle all other keys normally + }); + 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 618e700..68f4263 100644 --- a/muxplex/frontend/tests/test_terminal.mjs +++ b/muxplex/frontend/tests/test_terminal.mjs @@ -47,6 +47,8 @@ function loadTerminal() { dispose: () => {}, write: (data) => { termWriteMessages.push(data); }, focus: () => { focusCallCount++; }, + attachCustomKeyEventHandler: () => {}, + getSelection: () => '', }; // Capture all messages sent via WebSocket.send() @@ -331,6 +333,8 @@ function createMultiSessionEnv() { loadAddon: () => {}, dispose: () => {}, focus: () => {}, + attachCustomKeyEventHandler: () => {}, + getSelection: () => '', writeMessages: [], }; t.write = (data) => t.writeMessages.push(data); @@ -882,6 +886,17 @@ test('terminal.js resets _reconnectAttempts on first message, not on open', () = ); }); +// --- Clipboard integration --- + +test('terminal.js has clipboard integration with Ctrl+Shift+C and Ctrl+Shift+V', () => { + 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('getSelection'), 'must use getSelection() for copy'); + assert.ok(source.includes('clipboard'), 'must interact with clipboard API'); + assert.ok(source.includes('Shift'), 'must use Shift modifier to avoid conflict with terminal Ctrl+C/V'); + assert.ok(source.includes('_copyToClipboard') || source.includes('writeText'), 'must have copy mechanism'); +}); + // --- Issue 4: setTerminalFontSize --- test('terminal.js exposes window._setTerminalFontSize function', () => {