/** * @fileoverview Mobile keyboard accessory bar and modal focus trap. * * Defines two exports: * * - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual * keyboard on mobile: arrow up/down, /init, /clear, /compact, paste, and dismiss. * Destructive actions (/clear, /compact) require double-tap confirmation (2s amber state). * Commands are sent as text + Enter separately for Ink compatibility. * Only initializes on touch devices (MobileDetection.isTouchDevice guard). * * - FocusTrap (class) — Traps Tab/Shift+Tab keyboard focus within a modal element. * Saves and restores previously focused element on deactivate. Used by Ralph wizard * and other modal dialogs. * * @globals {object} KeyboardAccessoryBar * @globals {class} FocusTrap * * @dependency mobile-handlers.js (MobileDetection.isTouchDevice) * @dependency app.js (uses global `app` for sendInput, activeSessionId, terminal) * @loadorder 5 of 9 — loaded after notification-manager.js, before app.js */ // Codeman — Keyboard accessory bar and focus trap for modals // Loaded after mobile-handlers.js, before app.js // ═══════════════════════════════════════════════════════════════ // Mobile Keyboard Accessory Bar // ═══════════════════════════════════════════════════════════════ /** * KeyboardAccessoryBar - Quick action buttons shown above keyboard when typing. */ const KeyboardAccessoryBar = { element: null, /** Create and inject the accessory bar */ init() { // Only on mobile if (!MobileDetection.isTouchDevice()) return; // Create accessory bar element this.element = document.createElement('div'); this.element.className = 'keyboard-accessory-bar'; this.element.innerHTML = ` `; // Add click handlers — preventDefault stops event from reaching terminal this.element.addEventListener('click', (e) => { const btn = e.target.closest('.accessory-btn'); if (!btn) return; e.preventDefault(); e.stopPropagation(); const action = btn.dataset.action; this.handleAction(action, btn); // Refocus terminal so keyboard stays open (tap blurs terminal → keyboard dismisses → toolbar shifts) if ((action === 'scroll-up' || action === 'scroll-down') || ((action === 'clear' || action === 'compact') && this._confirmAction)) { if (typeof app !== 'undefined' && app.terminal) { app.terminal.focus(); } } }); // Insert before toolbar const toolbar = document.querySelector('.toolbar'); if (toolbar && toolbar.parentNode) { toolbar.parentNode.insertBefore(this.element, toolbar); } }, _confirmTimer: null, _confirmAction: null, /** Handle accessory button actions */ handleAction(action, btn) { if (typeof app === 'undefined' || !app.activeSessionId) return; switch (action) { case 'scroll-up': this.sendKey('\x1b[A'); break; case 'scroll-down': this.sendKey('\x1b[B'); break; case 'init': this.sendCommand('/init'); break; case 'clear': case 'compact': { // Require double-tap: first tap turns amber, second tap within 2s sends const cmd = action === 'clear' ? '/clear' : '/compact'; if (this._confirmAction === action && this._confirmTimer) { this.clearConfirm(); this.sendCommand(cmd); } else { this.setConfirm(action, btn); } break; } case 'paste': this.pasteFromClipboard(); break; case 'dismiss': // Blur active element to dismiss keyboard document.activeElement?.blur(); break; } }, /** Enter confirm state: button turns amber for 2s waiting for second tap */ setConfirm(action, btn) { this.clearConfirm(); this._confirmAction = action; if (btn) { btn.classList.add('confirming'); btn.dataset.origHtml = btn.innerHTML; btn.textContent = 'Tap again'; } this._confirmTimer = setTimeout(() => this.clearConfirm(), 2000); }, /** Reset confirm state */ clearConfirm() { if (this._confirmTimer) { clearTimeout(this._confirmTimer); this._confirmTimer = null; } if (this._confirmAction && this.element) { const btn = this.element.querySelector(`[data-action="${this._confirmAction}"]`); if (btn && btn.dataset.origHtml) { btn.innerHTML = btn.dataset.origHtml; delete btn.dataset.origHtml; } if (btn) btn.classList.remove('confirming'); } this._confirmAction = null; }, /** Send a slash command to the active session. * Sends text and Enter separately so Ink processes them as distinct events. */ sendCommand(command) { if (!app.activeSessionId) return; // Send command text first (without Enter) app.sendInput(command); // Send Enter separately after a brief delay so Ink has time to process the text. setTimeout(() => app.sendInput('\r'), 120); }, /** Send a special key (arrow, escape, etc.) directly to the PTY. * Bypasses tmux send-keys -l (literal mode) since escape sequences * must be written raw to be interpreted as key presses by Ink. */ sendKey(escapeSequence) { if (!app.activeSessionId) return; fetch(`/api/sessions/${app.activeSessionId}/input`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ input: escapeSequence }) }).catch(() => {}); }, /** Read clipboard and send contents as input */ /** Show a paste overlay with a textarea for iOS compatibility */ pasteFromClipboard() { if (typeof app === 'undefined' || !app.activeSessionId) return; // Create overlay const overlay = document.createElement('div'); overlay.className = 'paste-overlay'; overlay.innerHTML = `