/** * @fileoverview Mobile keyboard accessory bar and modal focus trap. * * Defines three exports: * * - KeyboardAccessoryBar (singleton object) — Quick action buttons shown above the virtual * keyboard on mobile: arrow up/down, /init, /clear, /compact, paste, Esc, and dismiss. * The paste button opens a dialog that handles both text paste and image attach * (native picker + best-effort image paste, routed through app._uploadAndInsertImages). * 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). * - PathPicker (singleton object) — Lazy server-side file/folder browser shared * by Link Existing and the extended mobile keyboard bar. * * - 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 {object} PathPicker * @globals {class} FocusTrap * * @dependency mobile-handlers.js (MobileDetection.isTouchDevice) * @dependency app.js (uses global `app` for sendInput, activeSessionId, terminal) * @loadorder 5 of 15 — 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 // ═══════════════════════════════════════════════════════════════ // Shared Filesystem Path Picker // ═══════════════════════════════════════════════════════════════ const PathPicker = { overlay: null, _options: null, _selectedPath: '', _previousFocus: null, _keydownHandler: null, _loadSequence: 0, _previewOverlay: null, _previewRequestSequence: 0, _previewPreviousFocus: null, /** * Open the lazy filesystem browser. * @param {{sessionId?: string, initialPath?: string, directoriesOnly?: boolean, * title?: string, onSelect: (path: string) => void}} options */ open(options) { this.close(false); this._options = options; this._selectedPath = ''; this._previousFocus = document.activeElement; this._previousFocus?.blur?.(); const overlay = document.createElement('div'); overlay.className = 'path-picker-overlay'; overlay.setAttribute('role', 'dialog'); overlay.setAttribute('aria-modal', 'true'); overlay.setAttribute('aria-label', options.title || 'Select a path'); overlay.innerHTML = `
Loading...
Selected None
`; this.overlay = overlay; overlay.querySelector('.path-picker-title').textContent = options.title || 'Select a Path'; overlay.querySelector('.path-picker-close').addEventListener('click', () => this.close(true)); overlay.querySelector('.path-picker-cancel').addEventListener('click', () => this.close(true)); overlay.querySelector('.path-picker-confirm').addEventListener('click', () => this.confirm()); overlay.querySelector('.path-picker-current-select').addEventListener('click', () => { const current = overlay.querySelector('.path-picker-current').textContent; if (current) this.select(current); }); overlay.querySelector('.path-picker-refresh').addEventListener('click', () => this.load()); overlay.querySelector('.path-picker-up').addEventListener('click', () => { const parent = overlay.querySelector('.path-picker-up').dataset.parent; if (parent) this.load(parent); }); overlay.querySelector('.path-picker-roots').addEventListener('change', (event) => this.load(event.target.value)); overlay.addEventListener('click', (event) => { if (event.target === overlay) this.close(true); }); this._keydownHandler = (event) => { if (event.key === 'Escape') { event.preventDefault(); if (this._previewOverlay) this.closePreview(true); else this.close(true); } }; document.addEventListener('keydown', this._keydownHandler); document.body.appendChild(overlay); this.load(options.initialPath || ''); }, async load(path) { if (!this.overlay || !this._options) return; const loadSequence = ++this._loadSequence; const list = this.overlay.querySelector('.path-picker-list'); const status = this.overlay.querySelector('.path-picker-status'); list.replaceChildren(); status.textContent = 'Loading...'; const params = new URLSearchParams(); if (path) params.set('path', path); if (this._options.sessionId) params.set('sessionId', this._options.sessionId); try { const response = await fetch(`/api/filesystem/browse?${params.toString()}`); const result = await response.json(); if (!response.ok || !result.success) throw new Error(result.error || 'Failed to browse this folder'); if (!this.overlay || loadSequence !== this._loadSequence) return; this.render(result.data); } catch (error) { if (!this.overlay || loadSequence !== this._loadSequence) return; if (path) { this.load(''); return; } status.textContent = error.message || 'Failed to browse this folder'; status.classList.add('error'); } }, render(data) { const rootSelect = this.overlay.querySelector('.path-picker-roots'); rootSelect.replaceChildren(); for (const root of data.roots) { const option = document.createElement('option'); option.value = root.path; option.textContent = `${root.label} — ${root.path}`; option.selected = data.path === root.path || data.root === root.path; rootSelect.appendChild(option); } this.overlay.querySelector('.path-picker-current').textContent = data.path; const up = this.overlay.querySelector('.path-picker-up'); up.dataset.parent = data.parent || ''; up.disabled = !data.parent; const status = this.overlay.querySelector('.path-picker-status'); status.classList.remove('error'); status.textContent = data.entries.length === 0 ? 'This folder is empty' : `${data.entries.length} item${data.entries.length === 1 ? '' : 's'}${data.truncated ? ' (first 500)' : ''}`; const list = this.overlay.querySelector('.path-picker-list'); list.replaceChildren(); for (const entry of data.entries) { const row = document.createElement('div'); row.className = 'path-picker-item'; if (entry.type === 'file' && this._options.directoriesOnly && !entry.previewKind) { row.classList.add('not-selectable'); } row.dataset.path = entry.path; row.dataset.type = entry.type; row.setAttribute('role', 'option'); const open = document.createElement('button'); open.type = 'button'; open.className = 'path-picker-item-main'; const icon = document.createElement('span'); icon.className = 'path-picker-item-icon'; icon.textContent = entry.type === 'directory' ? '\uD83D\uDCC1' : '\uD83D\uDCC4'; const name = document.createElement('span'); name.className = 'path-picker-item-name'; name.textContent = entry.name; open.append(icon, name); if (entry.symlink) { const link = document.createElement('span'); link.className = 'path-picker-item-link'; link.textContent = '\u2197'; open.appendChild(link); } if (entry.type === 'directory') { const chevron = document.createElement('span'); chevron.className = 'path-picker-item-chevron'; chevron.textContent = '\u203A'; open.appendChild(chevron); open.addEventListener('click', () => this.load(entry.path)); } else if (entry.previewKind) { const preview = document.createElement('span'); preview.className = 'path-picker-item-preview'; preview.textContent = '\uD83D\uDC41'; open.appendChild(preview); open.title = `Preview ${entry.name}`; open.setAttribute('aria-label', `Preview ${entry.name}`); open.addEventListener('click', () => this.openPreview(entry)); } else if (!this._options.directoriesOnly) { open.addEventListener('click', () => this.select(entry.path)); } else { open.disabled = true; } row.appendChild(open); if (entry.type === 'directory' || !this._options.directoriesOnly) { const choose = document.createElement('button'); choose.type = 'button'; choose.className = 'path-picker-item-select'; choose.textContent = 'Choose'; choose.addEventListener('click', () => this.select(entry.path)); row.appendChild(choose); } list.appendChild(row); } }, select(path) { if (!this.overlay) return; this._selectedPath = path; this.overlay.querySelector('.path-picker-selection-value').textContent = path; this.overlay.querySelector('.path-picker-confirm').disabled = false; this.overlay.querySelectorAll('.path-picker-item').forEach((row) => { const selected = row.dataset.path === path; row.classList.toggle('selected', selected); row.setAttribute('aria-selected', selected ? 'true' : 'false'); }); }, openPreview(entry) { this.closePreview(false); this._previewPreviousFocus = document.activeElement; const requestSequence = ++this._previewRequestSequence; const params = new URLSearchParams({ path: entry.path }); if (this._options?.sessionId) params.set('sessionId', this._options.sessionId); const previewUrl = `/api/filesystem/preview?${params.toString()}`; const overlay = document.createElement('div'); overlay.className = 'path-preview-overlay'; overlay.setAttribute('role', 'dialog'); overlay.setAttribute('aria-modal', 'true'); overlay.setAttribute('aria-label', `Preview ${entry.name}`); overlay.innerHTML = `
Open
Loading preview...
`; overlay.querySelector('.path-preview-title').textContent = entry.name; overlay.querySelector('.path-preview-path').textContent = entry.path; overlay.querySelector('.path-preview-open').href = previewUrl; overlay.querySelector('.path-preview-close').addEventListener('click', () => this.closePreview(true)); overlay.addEventListener('click', (event) => { if (event.target === overlay) this.closePreview(true); }); document.body.appendChild(overlay); this._previewOverlay = overlay; const body = overlay.querySelector('.path-preview-body'); if (entry.previewKind === 'image') { const image = document.createElement('img'); image.className = 'path-preview-image'; image.alt = entry.name; image.addEventListener('load', () => body.querySelector('.path-preview-loading')?.remove()); image.addEventListener('error', () => this.showPreviewError('Image preview failed to load')); image.src = previewUrl; body.appendChild(image); } else if (entry.previewKind === 'text') { fetch(previewUrl) .then(async (response) => { const content = await response.text(); if (!response.ok) { let message = 'Text preview failed to load'; try { message = JSON.parse(content).error || message; } catch {} throw new Error(message); } return content; }) .then((content) => { if (!this._previewOverlay || requestSequence !== this._previewRequestSequence) return; const pre = document.createElement('pre'); pre.className = 'path-preview-text'; pre.textContent = content; body.replaceChildren(pre); }) .catch((error) => { if (requestSequence === this._previewRequestSequence) this.showPreviewError(error.message); }); } else { const frame = document.createElement('iframe'); frame.className = 'path-preview-frame'; frame.title = entry.name; frame.addEventListener('load', () => body.querySelector('.path-preview-loading')?.remove()); frame.src = previewUrl; body.appendChild(frame); } overlay.querySelector('.path-preview-close').focus(); }, showPreviewError(message) { const body = this._previewOverlay?.querySelector('.path-preview-body'); if (!body) return; const error = document.createElement('div'); error.className = 'path-preview-error'; error.textContent = message || 'Preview failed to load'; body.replaceChildren(error); }, closePreview(restoreFocus = true) { this._previewRequestSequence += 1; this._previewOverlay?.remove(); this._previewOverlay = null; const previousFocus = this._previewPreviousFocus; this._previewPreviousFocus = null; if (restoreFocus) previousFocus?.focus?.(); }, confirm() { if (!this._selectedPath || !this._options) return; const selectedPath = this._selectedPath; const onSelect = this._options.onSelect; this.close(false); onSelect(selectedPath); }, close(restoreFocus = true) { if (this._keydownHandler) document.removeEventListener('keydown', this._keydownHandler); this._keydownHandler = null; this._loadSequence += 1; this.closePreview(false); this.overlay?.remove(); this.overlay = null; const previousFocus = this._previousFocus; this._previousFocus = null; this._options = null; this._selectedPath = ''; if (restoreFocus) previousFocus?.focus?.(); }, }; // ═══════════════════════════════════════════════════════════════ // Mobile Keyboard Accessory Bar // ═══════════════════════════════════════════════════════════════ /** * KeyboardAccessoryBar - Quick action buttons shown above keyboard when typing. */ const KeyboardAccessoryBar = { element: null, _mode: 'simple', // 'simple' or 'extended' /** HTML for simple mode: arrows, commands, paste, Esc, dismiss */ _simpleButtons: ` `, /** HTML for extended mode: all keys including arrows, Tab, Esc, etc. */ _extendedButtons: ` `, /** 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 = this._simpleButtons; // 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) const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'ctrl-o', 'opt-enter', 'esc', 'effort-max', 'clear-input']); if (refocusActions.has(action) || ((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); } }, /** Switch between 'simple' and 'extended' button layouts */ setMode(mode) { if (mode === this._mode || !this.element) return; this._mode = mode; this.clearConfirm(); this.element.innerHTML = mode === 'extended' ? this._extendedButtons : this._simpleButtons; }, _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 'arrow-left': this.sendKey('\x1b[D'); break; case 'arrow-right': this.sendKey('\x1b[C'); break; case 'esc': this.sendKey('\x1b'); break; case 'opt-enter': this.sendKey('\x1b\r'); break; case 'tab': this.sendKey('\t'); break; case 'shift-tab': this.sendKey('\x1b[Z'); break; case 'ctrl-o': this.sendKey('\x0f'); break; case 'effort-max': this.sendCommand('/effort max'); break; case 'init': this.sendCommand('/init'); break; case 'clear': case 'compact': { 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 'pick-path': this.pickPath(); break; case 'clear-input': app.clearTerminalInput?.(); 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(() => {}); }, /** Browse the active session's workspace and insert a selected path without Enter. */ pickPath() { if (!app.activeSessionId) return; const session = app.sessions?.get(app.activeSessionId); PathPicker.open({ title: 'Insert File or Folder Path', sessionId: app.activeSessionId, initialPath: session?.workingDir || '', directoriesOnly: false, onSelect: (path) => { app.insertTerminalText?.(path); setTimeout(() => app.terminal?.focus(), 100); }, }); }, /** Show a paste overlay for iOS compatibility. * Handles three input paths from one dialog: * - Text: long-press the textarea → Paste → Send (unchanged). * - Image (picker): the "Image" button opens a native file picker * (accept=image/* → camera / photo library / files), the most reliable * way to attach a photo on mobile. * - Image (paste): if the browser exposes image blobs on the textarea's * paste event, we intercept them and upload directly. Support is spotty * on mobile, so it is a best-effort enhancement layered on the picker. * All image paths reuse app._uploadAndInsertImages() (image-input.js), which * uploads to /api/sessions/:id/paste-image and inserts the saved path. */ pasteFromClipboard() { if (typeof app === 'undefined' || !app.activeSessionId) return; // Create overlay const overlay = document.createElement('div'); overlay.className = 'paste-overlay'; overlay.innerHTML = `
`; const textarea = overlay.querySelector('.paste-textarea'); const fileInput = overlay.querySelector('.paste-file-input'); const close = () => overlay.remove(); const sendText = () => { const text = textarea.value; close(); if (text) { app.sendInput(text); setTimeout(() => app.sendInput('\r'), 80); } }; // Filter to images, close the dialog, and hand off to the shared // upload+insert pipeline. Returns true if any image was handled. const handleImages = (files) => { const images = Array.from(files || []).filter((f) => f.type.startsWith('image/')); if (images.length === 0) return false; close(); if (typeof app._uploadAndInsertImages === 'function') app._uploadAndInsertImages(images); return true; }; // Image picker (camera / photo library) — the reliable mobile path. overlay.querySelector('.paste-image').addEventListener('click', () => fileInput.click()); fileInput.addEventListener('change', () => handleImages(fileInput.files)); // Best-effort: capture images pasted straight into the textarea. textarea.addEventListener('paste', (e) => { const items = e.clipboardData && e.clipboardData.items; if (!items) return; const imageFiles = []; for (let i = 0; i < items.length; i++) { if (items[i].type.startsWith('image/')) { const blob = items[i].getAsFile(); if (blob) imageFiles.push(blob); } } if (imageFiles.length > 0) { e.preventDefault(); handleImages(imageFiles); } }); overlay.querySelector('.paste-cancel').addEventListener('click', close); overlay.querySelector('.paste-send').addEventListener('click', sendText); overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); }); document.body.appendChild(overlay); textarea.focus(); }, /** Show the accessory bar */ show() { if (this.element) { this.element.classList.add('visible'); } }, /** Hide the accessory bar */ hide() { if (this.element) { this.element.classList.remove('visible'); } } }; // ═══════════════════════════════════════════════════════════════ // Accessibility: Focus Trap for Modals // ═══════════════════════════════════════════════════════════════ /** * FocusTrap - Traps keyboard focus within an element (typically a modal). * Saves the previously focused element and restores focus when deactivated. */ class FocusTrap { constructor(element) { this.element = element; this.previouslyFocused = null; this.boundHandleKeydown = this.handleKeydown.bind(this); } activate() { this.previouslyFocused = document.activeElement; this.element.addEventListener('keydown', this.boundHandleKeydown); // Focus first focusable element after a brief delay (for CSS transitions) requestAnimationFrame(() => { const focusable = this.getFocusableElements(); if (focusable.length) { focusable[0].focus(); } }); } deactivate() { this.element.removeEventListener('keydown', this.boundHandleKeydown); if (this.previouslyFocused && typeof this.previouslyFocused.focus === 'function') { this.previouslyFocused.focus(); } } getFocusableElements() { const selector = [ 'button:not([disabled]):not([tabindex="-1"])', 'input:not([disabled]):not([tabindex="-1"])', 'select:not([disabled]):not([tabindex="-1"])', 'textarea:not([disabled]):not([tabindex="-1"])', 'a[href]:not([tabindex="-1"])', '[tabindex]:not([tabindex="-1"]):not([disabled])' ].join(', '); return [...this.element.querySelectorAll(selector)].filter( el => el.offsetParent !== null // Exclude hidden elements ); } handleKeydown(e) { if (e.key !== 'Tab') return; const focusable = this.getFocusableElements(); if (focusable.length === 0) return; const first = focusable[0]; const last = focusable[focusable.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } } }