class VirtualKeyboard { constructor(onSendText, onSendKey) { this.onSendText = onSendText; this.onSendKey = onSendKey; 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); setTimeout(() => this.onSendKey('Enter'), 50); }); }); // 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) { // Send the text WITHOUT a trailing newline, then a discrete Enter key // event. cmux delivers send_text as a bracketed paste, so a trailing // "\n" becomes a literal newline in the TUI input box (Claude Code / // Codex) instead of submitting — that's why a single press only filled // the box and a second (empty) press was needed to actually send. // This replicates that two-press sequence in one press. The delay lets // the paste land before Enter; larger for multi-line/big pastes that // take longer to render than the short data-text shortcut keys. this.onSendText(text); this.textInput.value = ''; setTimeout(() => this.onSendKey('Enter'), 100); } 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;