class VirtualKeyboard { constructor(onSendText, onSendKey, onSubmitText) { this.onSendText = onSendText; this.onSendKey = onSendKey; // onSubmitText(text): send text AND submit (server presses Enter after the // paste completes). Falls back to the old text+timed-Enter if not provided. this.onSubmitText = onSubmitText; this.ctrlActive = false; this.ctrlLocked = false; this.altActive = false; this.altLocked = false; this.shiftActive = false; this.shiftLocked = false; this._repeatTimer = null; this._repeatInterval = null; this.textInput = document.getElementById('text-input'); this.sendBtn = document.getElementById('send-btn'); this.ctrlKey = document.getElementById('ctrl-key'); this.altKey = document.getElementById('alt-key'); this.shiftKey = document.getElementById('shift-key'); this.init(); } static get REPEAT_KEYS() { return new Set(['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'PageUp', 'PageDown']); } static get REPEAT_INITIAL_DELAY() { return 400; } static get REPEAT_INTERVAL() { return 80; } init() { // Text input — Enter inserts newline (textarea default); only send button submits. // Height is fixed via CSS so the keyboard doesn't shift; multi-line scrolls internally. // mousedown.preventDefault keeps focus on the textarea so the soft keyboard // stays up; the click event still fires. Without this, on mobile the first // tap blurs the textarea / dismisses the keyboard and the viewport reflow // can swallow the click — the user has to tap a second time to submit. this.sendBtn.addEventListener('mousedown', (e) => e.preventDefault()); this.sendBtn.addEventListener('click', () => this.submitText()); // Desktop: Enter = send, Shift+Enter / Cmd+Enter = newline. // Mobile keeps the default (Enter = newline, send button submits). this.textInput.addEventListener('keydown', (e) => { if (e.key !== 'Enter') return; if (window.innerWidth < 1024) return; if (e.shiftKey || e.metaKey) return; e.preventDefault(); this.submitText(); }); // Ctrl modifier this.ctrlKey.addEventListener('click', () => this.toggleModifier('ctrl')); this.ctrlKey.addEventListener('dblclick', () => this.lockModifier('ctrl')); // Alt modifier this.altKey.addEventListener('click', () => this.toggleModifier('alt')); this.altKey.addEventListener('dblclick', () => this.lockModifier('alt')); // Shift modifier this.shiftKey.addEventListener('click', () => this.toggleModifier('shift')); this.shiftKey.addEventListener('dblclick', () => this.lockModifier('shift')); // Text shortcut keys (e.g. /compact) — type the text, then send Enter // separately so slash-command menus have a tick to populate before submit. document.querySelectorAll('.key[data-text]').forEach((btn) => { btn.addEventListener('click', () => { this._haptic(); this.onSendText(btn.dataset.text); // Slash-command menus need a tick to populate before submit; give the // paste enough time to render (same reason as submitText's larger delay). setTimeout(() => this.onSendKey('Enter'), 200); }); }); // Special keys document.querySelectorAll('.key[data-key]').forEach((btn) => { if (btn.classList.contains('modifier')) return; const key = btn.dataset.key; if (VirtualKeyboard.REPEAT_KEYS.has(key)) { const startRepeat = () => { this._clearRepeat(); this.handleKeyPress(key); this._repeatTimer = setTimeout(() => { this._repeatInterval = setInterval(() => { this.handleKeyPress(key); }, VirtualKeyboard.REPEAT_INTERVAL); }, VirtualKeyboard.REPEAT_INITIAL_DELAY); }; btn.addEventListener('mousedown', startRepeat); btn.addEventListener('touchstart', startRepeat, { passive: true }); btn.addEventListener('mouseup', () => this._clearRepeat()); btn.addEventListener('mouseleave', () => this._clearRepeat()); btn.addEventListener('touchend', () => this._clearRepeat()); btn.addEventListener('touchcancel', () => this._clearRepeat()); } else { btn.addEventListener('click', () => this.handleKeyPress(key)); } }); } _haptic() { if (navigator.vibrate) navigator.vibrate(8); } _clearRepeat() { clearTimeout(this._repeatTimer); clearInterval(this._repeatInterval); this._repeatTimer = null; this._repeatInterval = null; } submitText() { this._haptic(); const text = this.textInput.value; if (text) { // cmux delivers send_text as a bracketed paste, so a trailing "\n" is a // literal newline in the TUI input box (Claude Code / Codex), not a submit. // The submit therefore needs a discrete Enter AFTER the paste lands. this.textInput.value = ''; if (this.onSubmitText) { // Preferred: the server presses Enter only AFTER send_text completes // (awaited ordering), so there is no client-side race between the paste // and the Enter — fixes the "had to press Enter twice" over slow/remote // connections. this.onSubmitText(text); } else { // Fallback for an older server that doesn't understand submit: two // messages with a size-scaled client delay (timing-fragile). this.onSendText(text); const lineCount = (text.match(/\n/g) || []).length + 1; const enterDelay = Math.min(900, 300 + (lineCount - 1) * 80); setTimeout(() => this.onSendKey('Enter'), enterDelay); } } else { // Empty submit = Enter key this.onSendKey('Enter'); } this.textInput.focus(); } handleKeyPress(key) { this._haptic(); // Check for pre-built shortcut keys like Ctrl-c if (key.startsWith('Ctrl-') || key.startsWith('Alt-') || key.startsWith('Shift-')) { this.onSendKey(key); return; } // Apply active modifiers let finalKey = key; if (this.shiftActive) { finalKey = `Shift-${finalKey}`; if (!this.shiftLocked) this.deactivateModifier('shift'); } if (this.ctrlActive) { finalKey = `Ctrl-${finalKey}`; if (!this.ctrlLocked) this.deactivateModifier('ctrl'); } if (this.altActive) { finalKey = `Alt-${finalKey}`; if (!this.altLocked) this.deactivateModifier('alt'); } this.onSendKey(finalKey); } toggleModifier(mod) { this._haptic(); if (mod === 'ctrl') { if (this.ctrlLocked) { this.deactivateModifier('ctrl'); return; } this.ctrlActive = !this.ctrlActive; this.ctrlKey.classList.toggle('active', this.ctrlActive); } else if (mod === 'alt') { if (this.altLocked) { this.deactivateModifier('alt'); return; } this.altActive = !this.altActive; this.altKey.classList.toggle('active', this.altActive); } else if (mod === 'shift') { if (this.shiftLocked) { this.deactivateModifier('shift'); return; } this.shiftActive = !this.shiftActive; this.shiftKey.classList.toggle('active', this.shiftActive); } } lockModifier(mod) { if (mod === 'ctrl') { this.ctrlActive = true; this.ctrlLocked = true; this.ctrlKey.classList.add('active', 'locked'); } else if (mod === 'alt') { this.altActive = true; this.altLocked = true; this.altKey.classList.add('active', 'locked'); } else if (mod === 'shift') { this.shiftActive = true; this.shiftLocked = true; this.shiftKey.classList.add('active', 'locked'); } } deactivateModifier(mod) { if (mod === 'ctrl') { this.ctrlActive = false; this.ctrlLocked = false; this.ctrlKey.classList.remove('active', 'locked'); } else if (mod === 'alt') { this.altActive = false; this.altLocked = false; this.altKey.classList.remove('active', 'locked'); } else if (mod === 'shift') { this.shiftActive = false; this.shiftLocked = false; this.shiftKey.classList.remove('active', 'locked'); } } focus() { this.textInput.focus(); } } window.VirtualKeyboard = VirtualKeyboard;