feat: native clipboard support — Ctrl+Shift+C to copy, Ctrl+Shift+V to paste

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.
This commit is contained in:
Brian Krabach
2026-04-01 09:51:23 -07:00
parent 1dcf1fc2f3
commit ff989ae920
2 changed files with 78 additions and 10 deletions
+63 -10
View File
@@ -10,6 +10,39 @@ let _currentSession = null;
let _vpHandler = null; let _vpHandler = null;
let _reconnectAttempts = 0; // tracks consecutive failed reconnect attempts for backoff + ttyd respawn 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 ───────────────────────────────────────────────────── // ─── Forward declarations ─────────────────────────────────────────────────────
function connectWebSocket(name, sourceUrl) { function connectWebSocket(name, sourceUrl) {
@@ -23,16 +56,8 @@ function connectWebSocket(name, sourceUrl) {
url = proto + '//' + location.host + '/terminal/ws'; url = proto + '//' + location.host + '/terminal/ws';
} }
const reconnectOverlay = document.getElementById('reconnect-overlay'); const reconnectOverlay = document.getElementById('reconnect-overlay');
const encoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null; // Use module-level _encodePayload (hoisted above connectWebSocket)
var encodePayload = _encodePayload;
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;
}
// Register terminal event handlers once on this _term instance. // Register terminal event handlers once on this _term instance.
// These handlers read the module-level _ws at call time (not a captured reference), // 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); _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) { if (_fitAddon) {
// requestAnimationFrame guarantees one full browser layout pass after the flex // requestAnimationFrame guarantees one full browser layout pass after the flex
// container becomes visible before fit() measures dimensions. // container becomes visible before fit() measures dimensions.
+15
View File
@@ -47,6 +47,8 @@ function loadTerminal() {
dispose: () => {}, dispose: () => {},
write: (data) => { termWriteMessages.push(data); }, write: (data) => { termWriteMessages.push(data); },
focus: () => { focusCallCount++; }, focus: () => { focusCallCount++; },
attachCustomKeyEventHandler: () => {},
getSelection: () => '',
}; };
// Capture all messages sent via WebSocket.send() // Capture all messages sent via WebSocket.send()
@@ -331,6 +333,8 @@ function createMultiSessionEnv() {
loadAddon: () => {}, loadAddon: () => {},
dispose: () => {}, dispose: () => {},
focus: () => {}, focus: () => {},
attachCustomKeyEventHandler: () => {},
getSelection: () => '',
writeMessages: [], writeMessages: [],
}; };
t.write = (data) => t.writeMessages.push(data); 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 --- // --- Issue 4: setTerminalFontSize ---
test('terminal.js exposes window._setTerminalFontSize function', () => { test('terminal.js exposes window._setTerminalFontSize function', () => {