// Claudeman App - Tab-based Terminal UI // ============================================================================ // Constants // ============================================================================ // Default terminal scrollback (can be changed via settings) const DEFAULT_SCROLLBACK = 5000; // Timing constants const STUCK_THRESHOLD_DEFAULT_MS = 600000; // 10 minutes - default for stuck detection const GROUPING_TIMEOUT_MS = 5000; // 5 seconds - notification grouping window const NOTIFICATION_LIST_CAP = 100; // Max notifications in list const TITLE_FLASH_INTERVAL_MS = 1500; // Title flash rate const BROWSER_NOTIF_RATE_LIMIT_MS = 3000; // Rate limit for browser notifications const AUTO_CLOSE_NOTIFICATION_MS = 8000; // Auto-close browser notifications const THROTTLE_DELAY_MS = 100; // General UI throttle delay const TERMINAL_CHUNK_SIZE = 128 * 1024; // 128KB chunks for terminal data const TERMINAL_TAIL_SIZE = 256 * 1024; // 256KB tail for initial load const SYNC_WAIT_TIMEOUT_MS = 50; // Wait timeout for terminal sync const STATS_POLLING_INTERVAL_MS = 2000; // System stats polling // Z-index base values for layered floating windows const ZINDEX_SUBAGENT_BASE = 1000; const ZINDEX_PLAN_SUBAGENT_BASE = 1100; const ZINDEX_LOG_VIEWER_BASE = 2000; const ZINDEX_IMAGE_POPUP_BASE = 3000; // Subagent/floating window layout const WINDOW_INITIAL_TOP_PX = 120; const WINDOW_CASCADE_OFFSET_PX = 30; const WINDOW_MIN_WIDTH_PX = 200; const WINDOW_MIN_HEIGHT_PX = 200; const WINDOW_DEFAULT_WIDTH_PX = 300; // Scheduler API — prioritize terminal writes over background UI updates. // scheduler.postTask('background') defers non-critical work (connection lines, panel renders) // so the main thread stays free for terminal rendering at 60fps. const _hasScheduler = typeof globalThis.scheduler?.postTask === 'function'; function scheduleBackground(fn) { if (_hasScheduler) { scheduler.postTask(fn, { priority: 'background' }); } else { requestAnimationFrame(fn); } } // DEC mode 2026 - Synchronized Output // Wrap terminal writes with these markers to prevent partial-frame flicker. // Terminal buffers all output between markers and renders atomically. // Supported by: WezTerm, Kitty, Ghostty, iTerm2 3.5+, Windows Terminal, VSCode terminal // xterm.js doesn't support DEC 2026 natively, so we implement buffering ourselves. const DEC_SYNC_START = '\x1b[?2026h'; const DEC_SYNC_END = '\x1b[?2026l'; // Pre-compiled regex for stripping DEC 2026 markers (single pass instead of two replaceAll calls) const DEC_SYNC_STRIP_RE = /\x1b\[\?2026[hl]/g; // Built-in respawn configuration presets const BUILTIN_RESPAWN_PRESETS = [ { id: 'solo-work', name: 'Solo', description: 'Claude working alone — fast respawn cycles with context reset', config: { idleTimeoutMs: 3000, updatePrompt: 'summarize your progress so far before the context reset.', interStepDelayMs: 2000, sendClear: true, sendInit: true, kickstartPrompt: 'continue working. Pick up where you left off based on the context above.', autoAcceptPrompts: true, }, durationMinutes: 60, builtIn: true, createdAt: 0, }, { id: 'subagent-workflow', name: 'Subagents', description: 'Lead session with Task tool subagents — longer idle tolerance', config: { idleTimeoutMs: 45000, updatePrompt: 'check on your running subagents and summarize their results before the context reset. If all subagents have finished, note what was completed and what remains.', interStepDelayMs: 3000, sendClear: true, sendInit: true, kickstartPrompt: 'check on your running subagents and continue coordinating their work. If all subagents have finished, summarize their results and proceed with the next step.', autoAcceptPrompts: true, }, durationMinutes: 240, builtIn: true, createdAt: 0, }, { id: 'team-lead', name: 'Team', description: 'Leading an agent team via TeamCreate — tolerates long silences', config: { idleTimeoutMs: 90000, updatePrompt: 'review the task list and teammate progress. Summarize the current state before the context reset.', interStepDelayMs: 5000, sendClear: true, sendInit: true, kickstartPrompt: 'check on your teammates by reviewing the task list and any messages in your inbox. Assign new tasks if teammates are idle, or continue coordinating the team effort.', autoAcceptPrompts: true, }, durationMinutes: 480, builtIn: true, createdAt: 0, }, { id: 'ralph-todo', name: 'Ralph/Todo', description: 'Ralph Loop task list — works through todos with progress tracking', config: { idleTimeoutMs: 8000, updatePrompt: 'update CLAUDE.md with discoveries and progress notes, mark completed tasks in @fix_plan.md, write a brief summary so the next cycle can continue seamlessly.', interStepDelayMs: 3000, sendClear: true, sendInit: true, kickstartPrompt: 'read @fix_plan.md for task status, continue on the next uncompleted task. When ALL tasks are complete, output COMPLETE.', autoAcceptPrompts: true, }, durationMinutes: 480, builtIn: true, createdAt: 0, }, { id: 'overnight-autonomous', name: 'Overnight', description: 'Unattended overnight runs with full context reset between cycles', config: { idleTimeoutMs: 10000, updatePrompt: 'summarize what you accomplished so far and write key progress notes to CLAUDE.md so the next cycle can pick up where you left off.', interStepDelayMs: 3000, sendClear: true, sendInit: true, kickstartPrompt: 'continue working on the task. Pick up where you left off based on the context above.', autoAcceptPrompts: true, }, durationMinutes: 480, builtIn: true, createdAt: 0, }, ]; // ============================================================================ // Mobile Detection // ============================================================================ /** * MobileDetection - Detects device type and touch capability. * Updates body classes for CSS targeting. */ const MobileDetection = { /** Check if device supports touch input */ isTouchDevice() { return 'ontouchstart' in window || navigator.maxTouchPoints > 0 || (window.matchMedia && window.matchMedia('(pointer: coarse)').matches); }, /** Check if device is iOS (iPhone, iPad, iPod) */ isIOS() { return /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1); }, /** Check if browser is Safari */ isSafari() { return /^((?!chrome|android).)*safari/i.test(navigator.userAgent); }, /** Check if screen is small (phone-sized, <430px) */ isSmallScreen() { return window.innerWidth < 430; }, /** Check if screen is medium (tablet-sized, 430-768px) */ isMediumScreen() { return window.innerWidth >= 430 && window.innerWidth < 768; }, /** Get device type based on screen width */ getDeviceType() { const width = window.innerWidth; if (width < 430) return 'mobile'; if (width < 768) return 'tablet'; return 'desktop'; }, /** Update body classes based on device detection */ updateBodyClass() { const body = document.body; const deviceType = this.getDeviceType(); const isTouch = this.isTouchDevice(); // Remove existing device classes body.classList.remove('device-mobile', 'device-tablet', 'device-desktop', 'touch-device', 'ios-device', 'safari-browser'); // Add current device class body.classList.add(`device-${deviceType}`); // Add touch device class if applicable if (isTouch) { body.classList.add('touch-device'); } // Add iOS-specific class for safe area handling if (this.isIOS()) { body.classList.add('ios-device'); } // Add Safari class for browser-specific fixes if (this.isSafari()) { body.classList.add('safari-browser'); } }, /** Initialize mobile detection and set up resize listener */ init() { this.updateBodyClass(); // Debounced resize handler let resizeTimeout; this._resizeHandler = () => { clearTimeout(resizeTimeout); resizeTimeout = setTimeout(() => this.updateBodyClass(), 100); }; window.addEventListener('resize', this._resizeHandler); }, /** Remove event listeners */ cleanup() { if (this._resizeHandler) { window.removeEventListener('resize', this._resizeHandler); this._resizeHandler = null; } } }; /** * Get unified coordinates from mouse or touch event. * @param {MouseEvent|TouchEvent} e - The event * @returns {{ clientX: number, clientY: number }} Coordinates */ function getEventCoords(e) { if (e.touches && e.touches.length > 0) { return { clientX: e.touches[0].clientX, clientY: e.touches[0].clientY }; } if (e.changedTouches && e.changedTouches.length > 0) { return { clientX: e.changedTouches[0].clientX, clientY: e.changedTouches[0].clientY }; } return { clientX: e.clientX, clientY: e.clientY }; } // ============================================================================ // Mobile Keyboard Handler // ============================================================================ /** * KeyboardHandler - Simple handler to scroll inputs into view when keyboard appears. * Uses focusin event and scrollIntoView - keeps it simple and reliable. * Also handles terminal scrolling and toolbar repositioning via visualViewport API. */ const KeyboardHandler = { lastViewportHeight: 0, keyboardVisible: false, initialViewportHeight: 0, /** Initialize keyboard handling */ init() { // Only initialize on touch devices if (!MobileDetection.isTouchDevice()) return; this.initialViewportHeight = window.visualViewport?.height || window.innerHeight; this.lastViewportHeight = this.initialViewportHeight; // Simple focus handler - scroll input into view after keyboard appears this._focusinHandler = (e) => { const target = e.target; if (!this.isInputElement(target)) return; // Wait for keyboard animation, then scroll input into view setTimeout(() => { this.scrollInputIntoView(target); }, 400); }; document.addEventListener('focusin', this._focusinHandler); // Use visualViewport to detect keyboard and reposition toolbar if (window.visualViewport) { this._viewportResizeHandler = () => { this.handleViewportResize(); }; this._viewportScrollHandler = () => { this.updateLayoutForKeyboard(); }; window.visualViewport.addEventListener('resize', this._viewportResizeHandler); // Also handle scroll (iOS scrolls viewport when keyboard appears) window.visualViewport.addEventListener('scroll', this._viewportScrollHandler); } }, /** Remove event listeners */ cleanup() { if (this._focusinHandler) { document.removeEventListener('focusin', this._focusinHandler); this._focusinHandler = null; } if (this._viewportResizeHandler && window.visualViewport) { window.visualViewport.removeEventListener('resize', this._viewportResizeHandler); this._viewportResizeHandler = null; } if (this._viewportScrollHandler && window.visualViewport) { window.visualViewport.removeEventListener('scroll', this._viewportScrollHandler); this._viewportScrollHandler = null; } }, /** Handle viewport resize (keyboard show/hide) */ handleViewportResize() { const currentHeight = window.visualViewport?.height || window.innerHeight; const heightDiff = this.initialViewportHeight - currentHeight; // Keyboard appeared (viewport shrunk by more than 150px) if (heightDiff > 150 && !this.keyboardVisible) { this.keyboardVisible = true; document.body.classList.add('keyboard-visible'); this.onKeyboardShow(); } // Keyboard hidden (viewport grew back close to initial) // Use 100px threshold (not 50) to handle iOS address bar drift, // iOS 26's persistent 24px discrepancy, and Safari bottom bar changes else if (heightDiff < 100 && this.keyboardVisible) { this.keyboardVisible = false; document.body.classList.remove('keyboard-visible'); this.onKeyboardHide(); } // Update baseline when keyboard is not visible — adapts to address bar // state changes, orientation changes, and other viewport shifts if (!this.keyboardVisible) { this.initialViewportHeight = currentHeight; } this.updateLayoutForKeyboard(); this.lastViewportHeight = currentHeight; }, /** Update layout when keyboard shows/hides */ updateLayoutForKeyboard() { if (!window.visualViewport) return; // Only adjust on mobile if (!MobileDetection.isSmallScreen() && !MobileDetection.isMediumScreen()) { this.resetLayout(); return; } const toolbar = document.querySelector('.toolbar'); const accessoryBar = document.querySelector('.keyboard-accessory-bar'); const main = document.querySelector('.main'); if (this.keyboardVisible) { // Calculate keyboard offset const layoutHeight = window.innerHeight; const visualBottom = window.visualViewport.offsetTop + window.visualViewport.height; const keyboardOffset = layoutHeight - visualBottom; // Safety: if keyboard is supposedly visible but offset is 0 or negative, // the keyboard is actually gone — force dismiss. This catches cases where // visualViewport.resize fires late or with intermediate values on iOS. if (keyboardOffset <= 0) { this.keyboardVisible = false; document.body.classList.remove('keyboard-visible'); this.onKeyboardHide(); return; } // Move toolbar up above keyboard if (toolbar) { toolbar.style.transform = `translateY(${-keyboardOffset}px)`; } // Move accessory bar up (it sits above toolbar) if (accessoryBar) { accessoryBar.style.transform = `translateY(${-keyboardOffset}px)`; } // Shrink main content area so terminal doesn't extend behind keyboard // Account for keyboard height + toolbar height (40px) + accessory bar (44px) if (main) { main.style.paddingBottom = `${keyboardOffset + 94}px`; } } else { this.resetLayout(); } }, /** Reset layout to normal (no keyboard) */ resetLayout() { const toolbar = document.querySelector('.toolbar'); const accessoryBar = document.querySelector('.keyboard-accessory-bar'); const main = document.querySelector('.main'); if (toolbar) { toolbar.style.transform = ''; } if (accessoryBar) { accessoryBar.style.transform = ''; } if (main) { main.style.paddingBottom = ''; } }, /** Called when keyboard appears */ onKeyboardShow() { // Show keyboard accessory bar if (typeof KeyboardAccessoryBar !== 'undefined') { KeyboardAccessoryBar.show(); } // Refit terminal locally AND send resize to server so Claude Code (Ink) // knows the actual terminal dimensions. Without this, Ink redraws at the // old (larger) row count when the user types, causing content to scroll // off the visible area with each keystroke. // Note: the throttledResize handler still suppresses ongoing resize events // while keyboard is up — this one-shot resize on open/close is sufficient. setTimeout(() => { if (typeof app !== 'undefined' && app.terminal) { if (app.fitAddon) try { app.fitAddon.fit(); } catch {} app.terminal.scrollToBottom(); // Send resize to server so PTY dimensions match xterm this._sendTerminalResize(); } }, 150); // Reposition subagent windows to stack from bottom (above keyboard) if (typeof app !== 'undefined') app.relayoutMobileSubagentWindows(); }, /** Called when keyboard hides */ onKeyboardHide() { // Hide keyboard accessory bar if (typeof KeyboardAccessoryBar !== 'undefined') { KeyboardAccessoryBar.hide(); } this.resetLayout(); // Refit terminal, scroll to bottom, and send resize to restore original dimensions setTimeout(() => { if (typeof app !== 'undefined' && app.fitAddon) { try { app.fitAddon.fit(); } catch {} if (app.terminal) app.terminal.scrollToBottom(); // Send resize to server to restore full terminal size this._sendTerminalResize(); } }, 100); // Reposition subagent windows to stack from top (below header) if (typeof app !== 'undefined') app.relayoutMobileSubagentWindows(); }, /** Send current terminal dimensions to the server (one-shot, for keyboard open/close) */ _sendTerminalResize() { if (typeof app === 'undefined' || !app.activeSessionId || !app.fitAddon) return; try { const dims = app.fitAddon.proposeDimensions(); if (dims) { const cols = Math.max(dims.cols, 40); const rows = Math.max(dims.rows, 10); app._lastResizeDims = { cols, rows }; fetch(`/api/sessions/${app.activeSessionId}/resize`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cols, rows }) }).catch(() => {}); } } catch {} }, /** Check if element is an input that triggers keyboard (excludes terminal) */ isInputElement(el) { if (!el) return false; // Exclude xterm.js terminal inputs (they handle their own scroll) if (el.closest('.xterm') || el.closest('.terminal-container')) { return false; } const tagName = el.tagName?.toLowerCase(); // Exclude type=range, type=checkbox, type=radio (don't trigger keyboard) if (tagName === 'input') { const type = el.type?.toLowerCase(); if (type === 'checkbox' || type === 'radio' || type === 'range' || type === 'file') { return false; } } return ( tagName === 'input' || tagName === 'textarea' || el.isContentEditable ); }, /** Scroll input into view above the keyboard */ scrollInputIntoView(input) { // Check if input is still focused (user might have tapped away) if (document.activeElement !== input) return; // Find if we're in a modal const modal = input.closest('.modal.active'); const modalBody = modal?.querySelector('.modal-body'); if (modalBody) { // For modals - scroll within the modal body const inputRect = input.getBoundingClientRect(); const modalRect = modalBody.getBoundingClientRect(); // If input is below middle of modal, scroll it up if (inputRect.top > modalRect.top + modalRect.height * 0.4) { const scrollAmount = inputRect.top - modalRect.top - 100; modalBody.scrollBy({ top: scrollAmount, behavior: 'smooth' }); } } else { // For page-level - use scrollIntoView input.scrollIntoView({ block: 'center', behavior: 'smooth' }); } } }; // ============================================================================ // Mobile Swipe Handler // ============================================================================ /** * SwipeHandler - Detects horizontal swipes on terminal to switch sessions. * Only active on mobile/touch devices. */ const SwipeHandler = { startX: 0, startY: 0, startTime: 0, minSwipeDistance: 80, // Minimum pixels for a valid swipe maxSwipeTime: 300, // Maximum ms for a swipe gesture maxVerticalDrift: 100, // Max vertical movement allowed /** Initialize swipe handling */ init() { // Only on touch devices if (!MobileDetection.isTouchDevice()) return; const terminal = document.querySelector('.main'); if (!terminal) return; terminal.addEventListener('touchstart', (e) => this.onTouchStart(e), { passive: true }); terminal.addEventListener('touchend', (e) => this.onTouchEnd(e), { passive: true }); }, onTouchStart(e) { if (!e.touches || e.touches.length !== 1) return; this.startX = e.touches[0].clientX; this.startY = e.touches[0].clientY; this.startTime = Date.now(); }, onTouchEnd(e) { if (!e.changedTouches || e.changedTouches.length !== 1) return; const endX = e.changedTouches[0].clientX; const endY = e.changedTouches[0].clientY; const elapsed = Date.now() - this.startTime; // Check if it's a valid swipe const deltaX = endX - this.startX; const deltaY = Math.abs(endY - this.startY); if (elapsed > this.maxSwipeTime) return; // Too slow if (deltaY > this.maxVerticalDrift) return; // Too much vertical movement if (Math.abs(deltaX) < this.minSwipeDistance) return; // Too short // Valid swipe detected if (deltaX > 0) { // Swipe right -> previous session if (typeof app !== 'undefined') app.prevSession(); } else { // Swipe left -> next session if (typeof app !== 'undefined') app.nextSession(); } } }; // ============================================================================ // Voice Input (Deepgram Nova-3 + Web Speech API fallback) // ============================================================================ /** * DeepgramProvider - Speech-to-text via Deepgram Nova-3 WebSocket API. * Direct browser-to-Deepgram connection (no server proxy). * Uses MediaRecorder to capture audio and streams via WebSocket. */ const DeepgramProvider = { _ws: null, _mediaRecorder: null, _stream: null, _silenceTimeout: null, _onResult: null, _onError: null, _onEnd: null, /** * Start streaming audio to Deepgram. * @param {object} opts - { apiKey, language, keyterms[], onResult(text, isFinal), onError(msg), onEnd(), onStream(stream) } */ async start(opts) { this._onResult = opts.onResult; this._onError = opts.onError; this._onEnd = opts.onEnd; // 1. Get microphone access try { this._stream = await navigator.mediaDevices.getUserMedia({ audio: { noiseSuppression: true, echoCancellation: true, autoGainControl: true } }); } catch (err) { const msg = err.name === 'NotAllowedError' ? 'Microphone access denied. Check browser settings.' : 'Microphone error: ' + err.message; this._onError?.(msg); this._cleanup(); return; } // Notify caller so it can set up audio level meter opts.onStream?.(this._stream); // 2. Build WebSocket URL const params = new URLSearchParams({ model: 'nova-3', smart_format: 'true', punctuate: 'true', interim_results: 'true', utterance_end_ms: '1500', vad_events: 'true', }); if (opts.language && opts.language !== 'multi') { params.set('language', opts.language); } else if (opts.language === 'multi') { params.set('detect_language', 'true'); } if (opts.keyterms?.length) { for (const term of opts.keyterms) { const trimmed = term.trim(); if (trimmed) params.append('keywords', trimmed + ':2'); } } // 3. Connect WebSocket const wsUrl = `wss://api.deepgram.com/v1/listen?${params}`; try { this._ws = new WebSocket(wsUrl, ['token', opts.apiKey]); } catch (err) { this._onError?.('Failed to connect to Deepgram: ' + err.message); this._cleanup(); return; } this._ws.onopen = () => { // 4. Start MediaRecorder once connected this._startRecording(); }; this._ws.onmessage = (event) => { try { const data = JSON.parse(event.data); if (data.type === 'Results' && data.channel?.alternatives?.[0]) { const alt = data.channel.alternatives[0]; const transcript = alt.transcript || ''; if (transcript) { const isFinal = data.is_final === true; this._onResult?.(transcript, isFinal); this._resetSilenceTimeout(); } } } catch (_e) { // Ignore parse errors for non-JSON messages } }; this._ws.onerror = () => { // WebSocket onerror doesn't carry useful info — onclose handles it }; this._ws.onclose = (event) => { if (event.code === 1008) { this._onError?.('Authentication failed. Check your Deepgram API key in Settings > Voice.'); } else if (event.code !== 1000) { this._onError?.('Deepgram connection closed: ' + (event.reason || `code ${event.code}`)); } this._stopRecording(); this._onEnd?.(); }; }, _startRecording() { if (!this._stream || !this._ws || this._ws.readyState !== WebSocket.OPEN) return; // Pick best supported MIME type const mimeTypes = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4']; let mimeType; for (const mt of mimeTypes) { if (typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported(mt)) { mimeType = mt; break; } } const recorderOpts = mimeType ? { mimeType } : {}; try { this._mediaRecorder = new MediaRecorder(this._stream, recorderOpts); } catch (err) { this._onError?.('MediaRecorder failed: ' + err.message); this._cleanup(); return; } this._mediaRecorder.ondataavailable = (event) => { if (event.data.size > 0 && this._ws?.readyState === WebSocket.OPEN) { this._ws.send(event.data); } }; this._mediaRecorder.start(250); // Send chunks every 250ms this._resetSilenceTimeout(); }, _stopRecording() { if (this._mediaRecorder && this._mediaRecorder.state !== 'inactive') { try { this._mediaRecorder.stop(); } catch (_e) { /* already stopped */ } } // Stop all mic tracks if (this._stream) { this._stream.getTracks().forEach(t => t.stop()); } }, _resetSilenceTimeout() { clearTimeout(this._silenceTimeout); this._silenceTimeout = setTimeout(() => { this.stop(); }, 3000); }, stop() { clearTimeout(this._silenceTimeout); this._silenceTimeout = null; this._stopRecording(); if (this._ws && this._ws.readyState === WebSocket.OPEN) { try { this._ws.close(1000); } catch (_e) { /* ignore */ } } }, _cleanup() { this.stop(); this._ws = null; this._mediaRecorder = null; this._stream = null; this._onResult = null; this._onError = null; this._onEnd = null; } }; /** * VoiceInput - Speech-to-text with Deepgram Nova-3 (primary) and Web Speech API (fallback). * Toggle mode: tap mic to start, tap again to stop. Auto-stops after silence. * Shows interim transcription in a floating preview overlay. * Inserts final text into the active session (user presses Enter to submit). */ const VoiceInput = { recognition: null, isRecording: false, supported: false, silenceTimeout: null, previewEl: null, _lastTranscript: '', _stabilityTimer: null, _accumulatedFinal: '', _activeProvider: null, // 'deepgram' | 'webspeech' | null _recordingStartedAt: 0, // timestamp when recording started _retryCount: 0, // auto-retry counter for premature Web Speech API ends _hasReceivedResult: false, // whether any speech result came in this session _durationInterval: null, // timer for updating elapsed time display _analyser: null, // AudioContext analyser for level meter _analyserSource: null, // MediaStreamSource for level meter _audioContext: null, // AudioContext for level meter _levelAnimFrame: null, // rAF handle for level meter init() { this._initRecognition(); // Always show buttons — if unsupported, toggle() shows a toast this._showButtons(); }, // --- Deepgram config (localStorage only, never sent to server) --- _getDeepgramConfig() { try { return JSON.parse(localStorage.getItem('claudeman-voice-settings') || '{}'); } catch (_e) { return {}; } }, _saveDeepgramConfig(config) { localStorage.setItem('claudeman-voice-settings', JSON.stringify(config)); }, _shouldUseDeepgram() { const cfg = this._getDeepgramConfig(); return !!(cfg.apiKey && cfg.apiKey.trim()); }, /** Get the active provider name for display */ getActiveProviderName() { if (this._shouldUseDeepgram()) return 'Deepgram Nova-3'; if (this.supported) return 'Web Speech API'; return 'None'; }, /** Try to create a SpeechRecognition instance */ _initRecognition() { const SR = window.SpeechRecognition || window.webkitSpeechRecognition; this.supported = !!SR; if (!this.supported) return; this.recognition = new SR(); this.recognition.continuous = true; this.recognition.interimResults = true; this.recognition.lang = 'en-US'; this.recognition.maxAlternatives = 1; this.recognition.onresult = (e) => this._onWebSpeechResult(e); this.recognition.onerror = (e) => this._onWebSpeechError(e); this.recognition.onend = () => this._onWebSpeechEnd(); }, toggle() { if (this.isRecording) { this.stop(); } else { this.start(); } }, start() { if (this.isRecording) return; if (!app.activeSessionId) { app.showToast('No active session', 'warning'); return; } this._retryCount = 0; if (this._shouldUseDeepgram()) { this._startDeepgram(); } else { this._startWebSpeech(); } }, _startDeepgram() { const cfg = this._getDeepgramConfig(); this.isRecording = true; this._activeProvider = 'deepgram'; this._accumulatedFinal = ''; this._lastTranscript = ''; this._hasReceivedResult = false; this._recordingStartedAt = Date.now(); this._updateButtons('recording'); this._showPreview('Listening...', 'deepgram'); this._startDurationTimer(); const keyterms = (cfg.keyterms || 'refactor, endpoint, middleware, callback, async, regex, TypeScript, npm, API, deploy, config, linter, env, webhook, schema, CLI, JSON, CSS, DOM, SSE, backend, frontend, localhost, dependencies, repository, merge, rebase, diff, commit, com') .split(',').map(t => t.trim()).filter(Boolean); DeepgramProvider.start({ apiKey: cfg.apiKey, language: cfg.language || 'en-US', keyterms, onStream: (stream) => { this._startLevelMeter(stream); }, onResult: (text, isFinal) => { if (!this.isRecording) return; this._hasReceivedResult = true; if (isFinal) { this._accumulatedFinal += text; this._hidePreview(); this._insertText(this._accumulatedFinal); this.stop(); } else { const display = this._accumulatedFinal + text; this._showPreview(display, 'deepgram'); } }, onError: (msg) => { const wasRecording = this.isRecording; this.stop(); if (wasRecording) app.showToast(msg, 'error'); }, onEnd: () => { if (this.isRecording) { if (this._accumulatedFinal) { this._insertText(this._accumulatedFinal); } this.stop(); } } }); // Haptic feedback on mobile if (navigator.vibrate) navigator.vibrate(50); }, _startWebSpeech() { // Lazy-init: retry if recognition was cleaned up or not available at page load if (!this.recognition) this._initRecognition(); if (!this.supported) { if (!this._shouldUseDeepgram()) { app.showToast('Voice input not available. Configure Deepgram in Settings > Voice.', 'warning'); } else { app.showToast('Voice input not supported in this browser', 'warning'); } return; } this.isRecording = true; this._activeProvider = 'webspeech'; this._accumulatedFinal = ''; this._lastTranscript = ''; this._hasReceivedResult = false; this._recordingStartedAt = Date.now(); this._updateButtons('recording'); this._showPreview('Listening...'); this._startDurationTimer(); try { this.recognition.start(); } catch (e) { // InvalidStateError = already started — ignore. Other errors = genuine failure. if (e.name !== 'InvalidStateError') { this.stop(); app.showToast('Voice input failed to start: ' + e.message, 'error'); return; } } this._resetSilenceTimeout(); // Get mic stream for level meter (non-blocking — level meter is cosmetic) navigator.mediaDevices?.getUserMedia({ audio: true }).then(stream => { if (this.isRecording && this._activeProvider === 'webspeech') { this._webSpeechStream = stream; this._startLevelMeter(stream); } else { stream.getTracks().forEach(t => t.stop()); } }).catch(() => { /* level meter just won't show */ }); // Haptic feedback on mobile if (navigator.vibrate) navigator.vibrate(50); }, stop() { if (!this.isRecording) return; this.isRecording = false; clearTimeout(this.silenceTimeout); clearTimeout(this._stabilityTimer); this.silenceTimeout = null; this._stabilityTimer = null; this._retryCount = 0; this._stopDurationTimer(); this._stopLevelMeter(); this._updateButtons('idle'); this._hidePreview(); if (this._activeProvider === 'deepgram') { DeepgramProvider.stop(); } else if (this._activeProvider === 'webspeech') { try { this.recognition?.stop(); } catch (_e) { // Already stopped — ignore } // Stop the mic stream we opened for the level meter if (this._webSpeechStream) { this._webSpeechStream.getTracks().forEach(t => t.stop()); this._webSpeechStream = null; } } this._activeProvider = null; // Haptic feedback on mobile if (navigator.vibrate) navigator.vibrate([30, 50, 30]); }, _onWebSpeechResult(event) { if (!this.isRecording) return; this._hasReceivedResult = true; this._resetSilenceTimeout(); let interim = ''; let finalText = ''; for (let i = event.resultIndex; i < event.results.length; i++) { const transcript = event.results[i][0].transcript; if (event.results[i].isFinal) { finalText += transcript; } else { interim += transcript; } } if (finalText) { this._accumulatedFinal += finalText; this._hidePreview(); this._insertText(this._accumulatedFinal); this.stop(); } else if (interim) { const display = this._accumulatedFinal + interim; this._showPreview(display); // iOS Safari workaround: isFinal is always false. // Detect when interim results stop changing for 750ms → treat as final. this._iosStabilityCheck(interim); } }, _onWebSpeechError(event) { // During auto-retry, 'aborted' and 'no-speech' errors are expected — ignore them if (this._retryCount > 0 && (event.error === 'aborted' || event.error === 'no-speech')) return; const wasRecording = this.isRecording; this.stop(); if (!wasRecording) return; switch (event.error) { case 'not-allowed': app.showToast('Microphone access denied. Check browser settings.', 'error'); break; case 'no-speech': // Silent — auto-stop is enough feedback break; case 'network': app.showToast('Voice input requires internet connection.', 'error'); break; case 'aborted': // User cancelled — no message needed break; default: app.showToast('Voice input error: ' + event.error, 'error'); } }, _onWebSpeechEnd() { // Recognition ended (browser auto-stopped or we called stop()) if (!this.isRecording) return; const elapsed = Date.now() - this._recordingStartedAt; // Web Speech API often fires onend prematurely on the first attempt (< 500ms, no results). // Auto-retry up to 2 times to avoid the "needs two clicks" problem. if (elapsed < 500 && !this._hasReceivedResult && this._retryCount < 2) { this._retryCount++; try { this.recognition.start(); } catch (_e) { // If restart fails, fall through to stop if (this._accumulatedFinal) this._insertText(this._accumulatedFinal); this.stop(); } return; } // Genuine end — finalize any accumulated text if (this._accumulatedFinal) { this._insertText(this._accumulatedFinal); } this.stop(); }, _insertText(text) { if (!app.activeSessionId || !text.trim()) return; const trimmed = text.trim(); const mode = this._getDeepgramConfig().insertMode || 'direct'; if (mode === 'compose') { this._showComposeOverlay(trimmed); } else { // Direct mode: inject into local echo overlay if available, else send to PTY if (app._localEchoEnabled && app._localEchoOverlay) { app._localEchoOverlay.appendText(trimmed); } else { app.sendInput(trimmed).catch(() => {}); } setTimeout(() => { if (app.terminal) app.terminal.focus(); }, 150); } }, /** Show an editable compose overlay so the user can review/edit before sending */ _showComposeOverlay(text) { document.querySelector('.voice-compose-overlay')?.remove(); const overlay = document.createElement('div'); overlay.className = 'voice-compose-overlay paste-overlay'; overlay.innerHTML = `
`; const textarea = overlay.querySelector('textarea'); const send = () => { const val = textarea.value.trim(); overlay.remove(); if (val) app.sendInput(val + '\r').catch(() => {}); }; const cancel = () => overlay.remove(); const newInput = () => { textarea.value = ''; textarea.focus(); this.start(); }; overlay.querySelector('.paste-cancel').addEventListener('click', cancel); overlay.querySelector('.paste-new').addEventListener('click', newInput); overlay.querySelector('.paste-send').addEventListener('click', send); overlay.addEventListener('click', (e) => { if (e.target === overlay) cancel(); }); document.body.appendChild(overlay); textarea.focus(); textarea.selectionStart = textarea.selectionEnd = textarea.value.length; }, _resetSilenceTimeout() { clearTimeout(this.silenceTimeout); this.silenceTimeout = setTimeout(() => { if (this.isRecording) { // Finalize any accumulated text before stopping if (this._accumulatedFinal) { this._insertText(this._accumulatedFinal); } this.stop(); } }, 3000); }, _iosStabilityCheck(transcript) { if (transcript !== this._lastTranscript) { this._lastTranscript = transcript; clearTimeout(this._stabilityTimer); this._stabilityTimer = setTimeout(() => { if (this.isRecording) { const finalText = this._accumulatedFinal + transcript; this._hidePreview(); this._insertText(finalText); this.stop(); } }, 750); } }, _startDurationTimer() { this._stopDurationTimer(); this._durationInterval = setInterval(() => { if (!this.isRecording || !this.previewEl) return; const elapsed = Math.floor((Date.now() - this._recordingStartedAt) / 1000); const mins = Math.floor(elapsed / 60); const secs = elapsed % 60; const timeStr = mins > 0 ? `${mins}:${String(secs).padStart(2, '0')}` : `0:${String(secs).padStart(2, '0')}`; const timerEl = this.previewEl.querySelector('.voice-timer'); if (timerEl) timerEl.textContent = timeStr; }, 1000); }, _stopDurationTimer() { if (this._durationInterval) { clearInterval(this._durationInterval); this._durationInterval = null; } }, /** Start audio level meter using AnalyserNode — attaches to the active mic stream */ _startLevelMeter(stream) { this._stopLevelMeter(); try { this._audioContext = new (window.AudioContext || window.webkitAudioContext)(); this._analyserSource = this._audioContext.createMediaStreamSource(stream); this._analyser = this._audioContext.createAnalyser(); this._analyser.fftSize = 256; this._analyserSource.connect(this._analyser); this._drawLevelMeter(); } catch (_e) { // AudioContext not available — level meter just won't show } }, _stopLevelMeter() { if (this._levelAnimFrame) { cancelAnimationFrame(this._levelAnimFrame); this._levelAnimFrame = null; } if (this._analyserSource) { try { this._analyserSource.disconnect(); } catch (_e) { /* */ } this._analyserSource = null; } if (this._audioContext) { try { this._audioContext.close(); } catch (_e) { /* */ } this._audioContext = null; } this._analyser = null; }, _drawLevelMeter() { if (!this._analyser || !this.isRecording) return; const dataArray = new Uint8Array(this._analyser.frequencyBinCount); this._analyser.getByteFrequencyData(dataArray); // Compute RMS level 0-1 let sum = 0; for (let i = 0; i < dataArray.length; i++) sum += dataArray[i] * dataArray[i]; const rms = Math.sqrt(sum / dataArray.length) / 255; // Update the level bars in the preview const barsEl = this.previewEl?.querySelector('.voice-level-bars'); if (barsEl) { const bars = barsEl.children; for (let i = 0; i < bars.length; i++) { const threshold = (i + 1) / bars.length; bars[i].classList.toggle('active', rms >= threshold * 0.7); } } this._levelAnimFrame = requestAnimationFrame(() => this._drawLevelMeter()); }, _showPreview(text, provider) { if (!this.previewEl) { this.previewEl = document.createElement('div'); this.previewEl.className = 'voice-preview'; this.previewEl.setAttribute('aria-live', 'polite'); document.body.appendChild(this.previewEl); } // Build the indicator structure once, then just update the text node if (!this.previewEl.querySelector('.voice-recording-indicator')) { this.previewEl.textContent = ''; // Recording indicator: red dot + level bars + timer const indicator = document.createElement('span'); indicator.className = 'voice-recording-indicator'; indicator.innerHTML = ''; const barsEl = document.createElement('span'); barsEl.className = 'voice-level-bars'; for (let i = 0; i < 5; i++) { const bar = document.createElement('span'); bar.className = 'voice-level-bar'; barsEl.appendChild(bar); } indicator.appendChild(barsEl); const timerEl = document.createElement('span'); timerEl.className = 'voice-timer'; timerEl.textContent = '0:00'; indicator.appendChild(timerEl); this.previewEl.appendChild(indicator); // Provider badge for Deepgram if (provider === 'deepgram') { const badge = document.createElement('span'); badge.className = 'voice-preview-badge'; badge.textContent = 'DG'; this.previewEl.appendChild(badge); this.previewEl.appendChild(document.createTextNode(' ')); } // Text node for transcript this._previewTextNode = document.createTextNode(text || 'Listening...'); this.previewEl.appendChild(this._previewTextNode); } else { // Just update the text content if (this._previewTextNode) { this._previewTextNode.textContent = text || 'Listening...'; } } this.previewEl.style.display = ''; }, _hidePreview() { if (this.previewEl) { this.previewEl.style.display = 'none'; this.previewEl.textContent = ''; } }, _updateButtons(state) { const isRecording = state === 'recording'; // Desktop button const desktopBtn = document.getElementById('voiceInputBtn'); if (desktopBtn) { desktopBtn.classList.toggle('recording', isRecording); desktopBtn.setAttribute('aria-pressed', String(isRecording)); desktopBtn.setAttribute('aria-label', isRecording ? 'Stop voice input' : 'Start voice input'); desktopBtn.title = isRecording ? 'Stop voice input (Ctrl+Shift+V)' : 'Voice input (Ctrl+Shift+V)'; } // Mobile toolbar button (always visible on mobile) const mobileToolbarBtn = document.getElementById('voiceInputBtnMobile'); if (mobileToolbarBtn) { mobileToolbarBtn.classList.toggle('recording', isRecording); mobileToolbarBtn.setAttribute('aria-pressed', String(isRecording)); mobileToolbarBtn.setAttribute('aria-label', isRecording ? 'Stop voice input' : 'Start voice input'); } }, _showButtons() { const desktopBtn = document.getElementById('voiceInputBtn'); if (desktopBtn) desktopBtn.style.display = ''; const mobileToolbarBtn = document.getElementById('voiceInputBtnMobile'); if (mobileToolbarBtn) mobileToolbarBtn.style.display = ''; }, /** Cleanup on SSE reconnect or page unload */ cleanup() { if (this.isRecording) this.stop(); DeepgramProvider._cleanup(); this.recognition = null; this._activeProvider = null; this._stopDurationTimer(); this._stopLevelMeter(); if (this._webSpeechStream) { this._webSpeechStream.getTracks().forEach(t => t.stop()); this._webSpeechStream = null; } if (this.previewEl) { this.previewEl.remove(); this.previewEl = null; } clearTimeout(this.silenceTimeout); clearTimeout(this._stabilityTimer); this.silenceTimeout = null; this._stabilityTimer = null; this._accumulatedFinal = ''; this._lastTranscript = ''; this._retryCount = 0; this._hasReceivedResult = false; } }; // ============================================================================ // 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 = `
`; const textarea = overlay.querySelector('.paste-textarea'); const send = () => { const text = textarea.value; overlay.remove(); if (text) app.sendInput(text); }; overlay.querySelector('.paste-cancel').addEventListener('click', () => overlay.remove()); overlay.querySelector('.paste-send').addEventListener('click', send); overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); 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(); } } } /** * xterm-zerolag-input — Instant keystroke feedback overlay for xterm.js. * Inlined from packages/xterm-zerolag-input (built output). * See packages/xterm-zerolag-input/README.md for full API documentation. */ // --- xterm-zerolag-input library (inlined) --- function _zl_getCellDimensions(terminal) { const t = terminal; if (t.dimensions?.css?.cell) { return { width: t.dimensions.css.cell.width, height: t.dimensions.css.cell.height }; } try { const dims = t._core?._renderService?.dimensions; if (dims?.css?.cell) { return { width: dims.css.cell.width, height: dims.css.cell.height }; } } catch {} return null; } function _zl_findPrompt(terminal, finder) { try { const buffer = terminal.buffer.active; const viewportTop = buffer.viewportY; switch (finder.type) { case 'character': { for (let row = terminal.rows - 1; row >= 0; row--) { const line = buffer.getLine(viewportTop + row); if (!line) continue; const text = line.translateToString(true); const idx = text.lastIndexOf(finder.char); if (idx >= 0) return { row, col: idx }; } return null; } case 'regex': { const pattern = finder.pattern; const safePattern = pattern.global ? new RegExp(pattern.source, pattern.flags.replace('g', '')) : pattern; for (let row = terminal.rows - 1; row >= 0; row--) { const line = buffer.getLine(viewportTop + row); if (!line) continue; const text = line.translateToString(true); const match = text.match(safePattern); if (match) return { row, col: match.index ?? 0 }; } return null; } case 'custom': return finder.find(terminal); default: return null; } } catch { return null; } } function _zl_readTextAfterPrompt(terminal, prompt, offset) { try { const buffer = terminal.buffer.active; const absRow = buffer.viewportY + prompt.row; const line = buffer.getLine(absRow); if (!line) return ''; return line.translateToString(true).slice(prompt.col + offset).trimEnd(); } catch { return ''; } } // Get visual cell width of a character (CJK wide chars = 2, others = 1) function _zl_charCellWidth(ch, terminal) { try { if (terminal?.unicode?.getStringCellWidth) { return terminal.unicode.getStringCellWidth(ch); } } catch {} const code = ch.codePointAt(0); if (code >= 0x1100 && ( (code <= 0x115F) || // Hangul Jamo (code >= 0x2E80 && code <= 0x303E) || // CJK Radicals, Kangxi, Ideographic (code >= 0x3040 && code <= 0x33BF) || // Hiragana, Katakana, Bopomofo, CJK Compat (code >= 0x3400 && code <= 0x4DBF) || // CJK Unified Ext A (code >= 0x4E00 && code <= 0xA4CF) || // CJK Unified, Yi (code >= 0xA960 && code <= 0xA97C) || // Hangul Jamo Extended-A (code >= 0xAC00 && code <= 0xD7A3) || // Hangul Syllables (code >= 0xF900 && code <= 0xFAFF) || // CJK Compat Ideographs (code >= 0xFE30 && code <= 0xFE6F) || // CJK Compat Forms (code >= 0xFF01 && code <= 0xFF60) || // Fullwidth Forms (code >= 0xFFE0 && code <= 0xFFE6) || // Fullwidth Signs (code >= 0x1F000 && code <= 0x1FBFF) || // Mahjong, Domino, Emoji (code >= 0x20000 && code <= 0x2FFFF) || // CJK Unified Ext B-F (code >= 0x30000 && code <= 0x3FFFF) // CJK Unified Ext G+ )) return 2; return 1; } function _zl_stringCellWidth(str, terminal) { let w = 0; for (const ch of str) w += _zl_charCellWidth(ch, terminal); return w; } function _zl_renderOverlay(container, params) { const { lines, startCol, totalCols, cellW, cellH, promptRow, font, showCursor, cursorColor, terminal } = params; container.style.left = '0px'; container.style.top = (promptRow * cellH) + 'px'; container.innerHTML = ''; const fullWidthPx = totalCols * cellW; for (let i = 0; i < lines.length; i++) { const leftPx = i === 0 ? startCol * cellW : 0; const widthPx = i === 0 ? (fullWidthPx - leftPx) : fullWidthPx; const topPx = i * cellH; const lineEl = _zl_makeLine(lines[i], leftPx, topPx, widthPx, cellH, cellW, font, terminal); container.appendChild(lineEl); } if (showCursor) { const lastLine = lines[lines.length - 1]; const lastLineLeft = lines.length === 1 ? startCol : 0; const cursorCol = lastLineLeft + _zl_stringCellWidth(lastLine, terminal); if (cursorCol < totalCols) { const cursor = document.createElement('span'); cursor.style.cssText = 'position:absolute;display:inline-block'; cursor.style.left = (cursorCol * cellW) + 'px'; cursor.style.top = ((lines.length - 1) * cellH) + 'px'; cursor.style.width = cellW + 'px'; cursor.style.height = cellH + 'px'; cursor.style.backgroundColor = cursorColor; container.appendChild(cursor); } } container.style.display = ''; } function _zl_makeLine(text, leftPx, topPx, widthPx, cellH, cellW, font, terminal) { const el = document.createElement('div'); el.style.cssText = 'position:absolute;pointer-events:none'; el.style.backgroundColor = font.backgroundColor; el.style.left = leftPx + 'px'; el.style.top = topPx + 'px'; el.style.width = widthPx + 'px'; el.style.height = (cellH + 1) + 'px'; el.style.lineHeight = cellH + 'px'; let colOffset = 0; for (const ch of text) { const charWidth = _zl_charCellWidth(ch, terminal); const span = document.createElement('span'); span.style.cssText = "position:absolute;display:inline-block;text-align:center;pointer-events:none;font-feature-settings:'liga' 0,'calt' 0"; span.style.left = (colOffset * cellW) + 'px'; span.style.width = (charWidth * cellW) + 'px'; span.style.fontFamily = font.fontFamily; span.style.fontSize = font.fontSize; span.style.fontWeight = font.fontWeight; span.style.color = font.color; span.style.height = cellH + 'px'; span.style.lineHeight = cellH + 'px'; if (font.letterSpacing) span.style.letterSpacing = font.letterSpacing; span.textContent = ch; el.appendChild(span); colOffset += charWidth; } return el; } const _ZL_DEFAULT_PROMPT = { type: 'character', char: '>', offset: 2 }; const _ZL_DEFAULT_BG = '#0d0d0d'; const _ZL_DEFAULT_FG = '#eeeeee'; const _ZL_DEFAULT_CURSOR = '#e0e0e0'; class ZerolagInputAddon { constructor(options) { this._terminal = null; this._overlay = null; this._pendingText = ''; this._flushedOffset = 0; this._flushedText = ''; this._bufferDetectDone = false; this._lastRenderKey = ''; this._lastPromptPos = null; this._font = { fontFamily: 'monospace', fontSize: '14px', fontWeight: 'normal', color: _ZL_DEFAULT_FG, backgroundColor: _ZL_DEFAULT_BG, letterSpacing: '' }; this._scrollTimer = null; this._scrollHandler = null; this._scrollViewport = null; this._options = { prompt: options?.prompt ?? _ZL_DEFAULT_PROMPT, zIndex: options?.zIndex ?? 7, showCursor: options?.showCursor ?? true, scrollDebounceMs: options?.scrollDebounceMs ?? 50, backgroundColor: options?.backgroundColor, foregroundColor: options?.foregroundColor, cursorColor: options?.cursorColor }; } activate(terminal) { this._terminal = terminal; this._overlay = document.createElement('div'); this._overlay.style.cssText = `position:absolute;z-index:${this._options.zIndex};pointer-events:none;display:none`; const screen = terminal.element?.querySelector('.xterm-screen'); if (screen) screen.appendChild(this._overlay); this._cacheFont(); this._scrollHandler = () => { try { const buf = this._terminal.buffer.active; if (buf.viewportY !== buf.baseY) { this._overlay.style.display = 'none'; if (this._scrollTimer) { clearTimeout(this._scrollTimer); this._scrollTimer = null; } } else if (this._pendingText || this._flushedOffset > 0) { if (this._scrollTimer) clearTimeout(this._scrollTimer); this._scrollTimer = setTimeout(() => { this._scrollTimer = null; this._lastRenderKey = ''; this._render(); }, this._options.scrollDebounceMs); } } catch {} }; const viewport = terminal.element?.querySelector('.xterm-viewport'); if (viewport) { viewport.addEventListener('scroll', this._scrollHandler, { passive: true }); this._scrollViewport = viewport; } } dispose() { this.clear(); if (this._scrollTimer) { clearTimeout(this._scrollTimer); this._scrollTimer = null; } if (this._scrollViewport && this._scrollHandler) this._scrollViewport.removeEventListener('scroll', this._scrollHandler); this._overlay?.remove(); this._overlay = null; this._scrollViewport = null; this._scrollHandler = null; this._terminal = null; } addChar(char) { if (!this._pendingText && !this._flushedOffset) this._detectBufferText(); this._pendingText += char; this._render(); } appendText(text) { if (!text) return; if (!this._pendingText && !this._flushedOffset) this._detectBufferText(); this._pendingText += text; this._render(); } removeChar() { if (this._pendingText.length > 0) { this._pendingText = this._pendingText.slice(0, -1); if (this._pendingText.length > 0 || this._flushedOffset > 0) this._render(); else this._hide(); return 'pending'; } if (this._flushedOffset > 0) { this._flushedOffset--; this._flushedText = this._flushedText.slice(0, -1); if (this._flushedOffset > 0) this._render(); else this._hide(); return 'flushed'; } this._detectBufferText(); if (this._flushedOffset > 0) { this._flushedOffset--; this._flushedText = this._flushedText.slice(0, -1); if (this._flushedOffset > 0) this._render(); else this._hide(); return 'flushed'; } return false; } clear() { this._pendingText = ''; this._flushedOffset = 0; this._flushedText = ''; this._bufferDetectDone = false; this._lastRenderKey = ''; this._lastPromptPos = null; this._hide(); } setFlushed(count, text, render = true) { this._flushedOffset = count; this._flushedText = text; if (render) this._render(); } getFlushed() { return { count: this._flushedOffset, text: this._flushedText }; } clearFlushed() { this._flushedOffset = 0; this._flushedText = ''; if (this._pendingText) this._render(); else this._hide(); } rerender() { if (this._pendingText || this._flushedOffset > 0) { this._lastRenderKey = ''; this._render(); } } refreshFont() { this._cacheFont(); this._lastRenderKey = ''; if (this._pendingText || this._flushedOffset > 0) this._render(); } detectBufferText() { return this._detectBufferText(); } resetBufferDetection() { this._bufferDetectDone = false; } undoDetection() { this._flushedOffset = 0; this._flushedText = ''; this._bufferDetectDone = false; } suppressBufferDetection() { this._bufferDetectDone = true; } findPrompt() { if (!this._terminal) return null; return _zl_findPrompt(this._terminal, this._options.prompt ?? _ZL_DEFAULT_PROMPT); } readPromptText() { if (!this._terminal) return null; const prompt = this.findPrompt(); if (!prompt) return null; const offset = (this._options.prompt ?? _ZL_DEFAULT_PROMPT).offset ?? 2; const text = _zl_readTextAfterPrompt(this._terminal, prompt, offset); return text || null; } get pendingText() { return this._pendingText; } get hasPending() { return this._pendingText.length > 0 || this._flushedOffset > 0; } get state() { return { pendingText: this._pendingText, flushedLength: this._flushedOffset, flushedText: this._flushedText, visible: this._overlay !== null && this._overlay.style.display !== 'none', promptPosition: this._lastPromptPos ? { ...this._lastPromptPos } : null }; } _detectBufferText() { if (this._bufferDetectDone || !this._terminal) return null; // Mark as done regardless of result — prevents repeated scans from picking // up PTY-echoed text or Ink redraws that appear between keystrokes. // Explicit resetBufferDetection() is needed to re-enable (e.g., Tab completion). this._bufferDetectDone = true; try { const prompt = this.findPrompt(); if (!prompt) return null; const offset = (this._options.prompt ?? _ZL_DEFAULT_PROMPT).offset ?? 2; const afterPrompt = _zl_readTextAfterPrompt(this._terminal, prompt, offset); if (afterPrompt.length > 0) { this._flushedOffset = afterPrompt.length; this._flushedText = afterPrompt; this._lastPromptPos = prompt; return afterPrompt; } } catch {} return null; } _cacheFont() { if (!this._terminal) return; const t = this._terminal; this._font.fontFamily = t.options.fontFamily || 'monospace'; const baseFontSize = t.options.fontSize || 14; this._font.fontSize = baseFontSize + 'px'; this._font.fontWeight = String(t.options.fontWeight || 'normal'); this._font.backgroundColor = this._options.backgroundColor ?? t.options.theme?.background ?? _ZL_DEFAULT_BG; this._font.color = this._options.foregroundColor ?? t.options.theme?.foreground ?? _ZL_DEFAULT_FG; this._font.letterSpacing = ''; const rows = t.element?.querySelector('.xterm-rows'); if (rows) { const cs = getComputedStyle(rows); this._font.letterSpacing = cs.letterSpacing; if (!this._options.foregroundColor && cs.color) this._font.color = cs.color; } // Correct font size: DOM text renders larger than xterm's canvas/WebGL text // at the same nominal font-size. Measure actual DOM char width and scale to // match the terminal's cell width so the overlay text matches exactly. const dims = _zl_getCellDimensions(t); if (dims && dims.width > 0) { const probe = document.createElement('span'); probe.style.cssText = `position:absolute;visibility:hidden;white-space:pre;font-family:${this._font.fontFamily};font-size:${baseFontSize}px;font-weight:${this._font.fontWeight};font-feature-settings:'liga' 0,'calt' 0`; if (this._font.letterSpacing) probe.style.letterSpacing = this._font.letterSpacing; probe.textContent = 'W'; document.body.appendChild(probe); const domCharW = probe.getBoundingClientRect().width; document.body.removeChild(probe); if (domCharW > 0 && Math.abs(domCharW - dims.width) > 0.1) { const corrected = baseFontSize * (dims.width / domCharW); this._font.fontSize = corrected + 'px'; } } } _hide() { if (!this._overlay) return; this._lastRenderKey = ''; this._lastPromptPos = null; this._overlay.innerHTML = ''; this._overlay.style.display = 'none'; } _render() { if (!this._terminal || !this._overlay) return; if (!this._pendingText && !(this._flushedOffset > 0)) { this._overlay.style.display = 'none'; return; } try { const buf = this._terminal.buffer.active; if (buf.viewportY !== buf.baseY) { this._overlay.style.display = 'none'; return; } const prompt = this.findPrompt(); if (prompt) { if (this._lastPromptPos && this._flushedOffset > 0) { this._lastPromptPos = { row: prompt.row, col: this._lastPromptPos.col }; } else { this._lastPromptPos = prompt; } } else if (!this._lastPromptPos) { this._overlay.style.display = 'none'; return; } const activePrompt = this._lastPromptPos; const dims = _zl_getCellDimensions(this._terminal); if (!dims) { this._overlay.style.display = 'none'; return; } const { width: cellW, height: cellH } = dims; const totalCols = this._terminal.cols; const offset = (this._options.prompt ?? _ZL_DEFAULT_PROMPT).offset ?? 2; const startCol = activePrompt.col + offset; let displayText = this._pendingText; if (this._flushedOffset > 0) { if (this._flushedText && this._flushedText.length === this._flushedOffset) { displayText = this._flushedText + this._pendingText; } else { const absRow = buf.viewportY + activePrompt.row; const line = buf.getLine(absRow); if (line) { displayText = line.translateToString(true).slice(startCol, startCol + this._flushedOffset) + this._pendingText; } } } const renderKey = `${displayText}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}:${this._flushedOffset}`; if (renderKey === this._lastRenderKey && this._overlay.style.display !== 'none') return; this._lastRenderKey = renderKey; const firstLineCols = Math.max(1, totalCols - startCol); const lines = []; const chars = [...displayText]; let ci = 0; { let lineStr = '', lineCols = 0; while (ci < chars.length) { const cw = _zl_charCellWidth(chars[ci], this._terminal); if (lineCols + cw > firstLineCols) break; lineStr += chars[ci]; lineCols += cw; ci++; } lines.push(lineStr); } while (ci < chars.length) { let lineStr = '', lineCols = 0; while (ci < chars.length) { const cw = _zl_charCellWidth(chars[ci], this._terminal); if (lineCols + cw > totalCols) break; lineStr += chars[ci]; lineCols += cw; ci++; } lines.push(lineStr); } const cursorColor = this._options.cursorColor ?? this._terminal.options.theme?.cursor ?? _ZL_DEFAULT_CURSOR; _zl_renderOverlay(this._overlay, { lines, startCol, totalCols, cellW, cellH, promptRow: activePrompt.row, font: this._font, showCursor: this._options.showCursor, cursorColor, terminal: this._terminal }); } catch { if (this._overlay) { this._overlay.innerHTML = ''; this._overlay.style.display = 'none'; } } } } // --- end xterm-zerolag-input library --- // Legacy alias — kept so the rest of app.js references work without renaming const LocalEchoOverlay = class extends ZerolagInputAddon { constructor(terminal) { super({ prompt: { type: 'character', char: '\u276f', offset: 2 } }); this.activate(terminal); } }; /** * Process data containing DEC 2026 sync markers. * Strips markers and returns segments that should be written atomically. * Each returned segment represents content between SYNC_START and SYNC_END. * Content outside sync blocks is returned as-is. * * @param {string} data - Raw terminal data with potential sync markers * @returns {string[]} - Array of content segments to write (markers stripped) */ function extractSyncSegments(data) { const segments = []; let remaining = data; while (remaining.length > 0) { const startIdx = remaining.indexOf(DEC_SYNC_START); if (startIdx === -1) { // No more sync blocks, return rest as-is if (remaining.length > 0) { segments.push(remaining); } break; } // Content before sync block (if any) if (startIdx > 0) { segments.push(remaining.slice(0, startIdx)); } // Find matching end marker const afterStart = remaining.slice(startIdx + DEC_SYNC_START.length); const endIdx = afterStart.indexOf(DEC_SYNC_END); if (endIdx === -1) { // No end marker found - sync block continues in next chunk // Include the start marker so it can be handled when more data arrives segments.push(remaining.slice(startIdx)); break; } // Extract synchronized content (without markers) const syncContent = afterStart.slice(0, endIdx); if (syncContent.length > 0) { segments.push(syncContent); } // Continue with content after end marker remaining = afterStart.slice(endIdx + DEC_SYNC_END.length); } return segments; } // Notification Manager - Multi-layer browser notification system class NotificationManager { constructor(app) { this.app = app; this.notifications = []; this.unreadCount = 0; this.isTabVisible = !document.hidden; this.isDrawerOpen = false; this.originalTitle = document.title; this.titleFlashInterval = null; this.titleFlashState = false; this.lastBrowserNotifTime = 0; this.audioCtx = null; this.renderScheduled = false; // Debounce grouping: Map this.groupingMap = new Map(); // Load preferences this.preferences = this.loadPreferences(); // Visibility tracking document.addEventListener('visibilitychange', () => { this.isTabVisible = !document.hidden; if (this.isTabVisible) { this.onTabVisible(); } }); // iOS Safari: pageshow fires on back-forward cache restore (bfcache) window.addEventListener('pageshow', (e) => { if (e.persisted) { this.isTabVisible = true; this.onTabVisible(); } }); } loadPreferences() { const defaultEventTypes = { permission_prompt: { enabled: true, browser: true, audio: true }, elicitation_dialog: { enabled: true, browser: true, audio: true }, idle_prompt: { enabled: true, browser: true, audio: false }, stop: { enabled: true, browser: false, audio: false }, session_error: { enabled: true, browser: true, audio: false }, respawn_cycle: { enabled: true, browser: false, audio: false }, token_milestone: { enabled: true, browser: false, audio: false }, ralph_complete: { enabled: true, browser: true, audio: true }, subagent_spawn: { enabled: false, browser: false, audio: false }, subagent_complete: { enabled: false, browser: false, audio: false }, }; // Device-specific defaults: mobile has notifications disabled by default const isMobile = MobileDetection.getDeviceType() === 'mobile'; const defaults = { enabled: !isMobile, // Disabled on mobile by default browserNotifications: !isMobile, audioAlerts: false, stuckThresholdMs: STUCK_THRESHOLD_DEFAULT_MS, // Legacy urgency muting (keep for backwards compat) muteCritical: false, muteWarning: false, muteInfo: false, // Per-event-type preferences eventTypes: defaultEventTypes, _version: 3, }; try { const storageKey = this.getStorageKey(); const saved = localStorage.getItem(storageKey); if (saved) { const prefs = JSON.parse(saved); // Migrate: v1 had browserNotifications defaulting to false if (!prefs._version || prefs._version < 2) { prefs.browserNotifications = true; prefs._version = 2; } // Migrate: v2 -> v3 adds eventTypes if (prefs._version < 3) { prefs.eventTypes = defaultEventTypes; prefs._version = 3; localStorage.setItem(storageKey, JSON.stringify(prefs)); } // Merge with defaults to ensure all eventTypes exist return { ...defaults, ...prefs, eventTypes: { ...defaultEventTypes, ...prefs.eventTypes }, }; } } catch (_e) { /* ignore */ } return defaults; } // Get storage key for notification prefs (device-specific) getStorageKey() { const isMobile = MobileDetection.getDeviceType() === 'mobile'; return isMobile ? 'claudeman-notification-prefs-mobile' : 'claudeman-notification-prefs'; } savePreferences() { localStorage.setItem(this.getStorageKey(), JSON.stringify(this.preferences)); } notify({ urgency, category, sessionId, sessionName, title, message }) { if (!this.preferences.enabled) return; // Map notification categories to eventType preference keys const categoryToEventType = { 'hook-permission': 'permission_prompt', 'hook-elicitation': 'elicitation_dialog', 'hook-idle': 'idle_prompt', 'hook-stop': 'stop', 'session-error': 'session_error', 'session-crash': 'session_error', 'session-stuck': 'idle_prompt', 'respawn-blocked': 'respawn_cycle', 'auto-accept': 'respawn_cycle', 'auto-clear': 'respawn_cycle', 'ralph-complete': 'ralph_complete', 'circuit-breaker': 'respawn_cycle', 'exit-gate': 'ralph_complete', 'subagent-spawn': 'subagent_spawn', 'subagent-complete': 'subagent_complete', 'hook-teammate-idle': 'idle_prompt', 'hook-task-completed': 'stop', }; const eventTypeKey = categoryToEventType[category] || category; // Check per-event-type preferences first const eventPref = this.preferences.eventTypes?.[eventTypeKey]; let shouldBrowserNotify = false; let shouldAudioAlert = false; if (eventPref) { // Event type found - use its specific preferences if (!eventPref.enabled) return; shouldBrowserNotify = eventPref.browser && this.preferences.browserNotifications; shouldAudioAlert = eventPref.audio && this.preferences.audioAlerts; } else { // Fall back to urgency-based muting for unknown categories if (urgency === 'critical' && this.preferences.muteCritical) return; if (urgency === 'warning' && this.preferences.muteWarning) return; if (urgency === 'info' && this.preferences.muteInfo) return; // Default browser/audio behavior based on urgency shouldBrowserNotify = this.preferences.browserNotifications && (urgency === 'critical' || urgency === 'warning' || !this.isTabVisible); shouldAudioAlert = urgency === 'critical' && this.preferences.audioAlerts; } // Grouping: same category+session within 5s updates count instead of new entry const groupKey = `${category}:${sessionId || 'global'}`; const existing = this.groupingMap.get(groupKey); if (existing) { existing.notification.count = (existing.notification.count || 1) + 1; existing.notification.message = message; existing.notification.timestamp = Date.now(); clearTimeout(existing.timeout); existing.timeout = setTimeout(() => this.groupingMap.delete(groupKey), GROUPING_TIMEOUT_MS); this.scheduleRender(); return; } const notification = { id: Date.now() + '-' + Math.random().toString(36).slice(2, 7), urgency, category, sessionId, sessionName, title, message, timestamp: Date.now(), read: false, count: 1, }; // Add to log (cap at 100) this.notifications.unshift(notification); if (this.notifications.length > 100) this.notifications.pop(); // Track for grouping const timeout = setTimeout(() => this.groupingMap.delete(groupKey), GROUPING_TIMEOUT_MS); this.groupingMap.set(groupKey, { notification, timeout }); // Update unread this.unreadCount++; this.updateBadge(); this.scheduleRender(); // Layer 2: Tab title (when tab unfocused) if (!this.isTabVisible) { this.updateTabTitle(); } // Layer 3: Browser notification if (shouldBrowserNotify) { this.sendBrowserNotif(title, message, category, sessionId); } // Layer 4: Audio alert if (shouldAudioAlert) { this.playAudioAlert(); } } // Layer 1: Drawer rendering scheduleRender() { if (this.renderScheduled) return; this.renderScheduled = true; requestAnimationFrame(() => { this.renderScheduled = false; this.renderDrawer(); }); } renderDrawer() { const list = document.getElementById('notifList'); const empty = document.getElementById('notifEmpty'); if (!list || !empty) return; if (this.notifications.length === 0) { list.style.display = 'none'; empty.style.display = 'flex'; return; } list.style.display = 'block'; empty.style.display = 'none'; list.innerHTML = this.notifications.map(n => { const urgencyClass = `notif-item-${n.urgency}`; const readClass = n.read ? '' : ' unread'; const countLabel = n.count > 1 ? `×${n.count}` : ''; const sessionChip = n.sessionName ? `${this.escapeHtml(n.sessionName)}` : ''; return `
${this.escapeHtml(n.title)}${countLabel} ${this.relativeTime(n.timestamp)}
${this.escapeHtml(n.message)}
${sessionChip}
`; }).join(''); } // Layer 2: Tab title with unread count updateTabTitle() { if (this.unreadCount > 0 && !this.isTabVisible) { if (!this.titleFlashInterval) { this.titleFlashInterval = setInterval(() => { this.titleFlashState = !this.titleFlashState; document.title = this.titleFlashState ? `\u26A0\uFE0F (${this.unreadCount}) Claudeman` : this.originalTitle; }, TITLE_FLASH_INTERVAL_MS); // Set immediately document.title = `\u26A0\uFE0F (${this.unreadCount}) Claudeman`; } } } stopTitleFlash() { if (this.titleFlashInterval) { clearInterval(this.titleFlashInterval); this.titleFlashInterval = null; this.titleFlashState = false; document.title = this.originalTitle; } } // Layer 3: Web Notification API sendBrowserNotif(title, body, tag, sessionId) { if (!this.preferences.browserNotifications) return; if (typeof Notification === 'undefined') return; if (Notification.permission === 'default') { // Auto-request on first notification attempt Notification.requestPermission().then(result => { if (result === 'granted') { // Re-send this notification now that we have permission this.sendBrowserNotif(title, body, tag, sessionId); } }); return; } if (Notification.permission !== 'granted') return; // Rate limit: max 1 per 3 seconds const now = Date.now(); if (now - this.lastBrowserNotifTime < 3000) return; this.lastBrowserNotifTime = now; const notif = new Notification(`Claudeman: ${title}`, { body, tag, // Groups same-tag notifications icon: '/favicon.ico', silent: true, // We handle audio ourselves }); notif.onclick = () => { window.focus(); if (sessionId && this.app.sessions.has(sessionId)) { this.app.selectSession(sessionId); } notif.close(); }; // Auto-close after 8s setTimeout(() => notif.close(), 8000); } async requestPermission() { if (typeof Notification === 'undefined') { this.app.showToast('Browser notifications not supported', 'warning'); return; } const result = await Notification.requestPermission(); const statusEl = document.getElementById('notifPermissionStatus'); if (statusEl) statusEl.textContent = `Status: ${result}`; if (result === 'granted') { this.preferences.browserNotifications = true; this.savePreferences(); this.app.showToast('Notifications enabled', 'success'); } else { this.app.showToast(`Permission ${result}`, 'warning'); } } // Layer 4: Audio alert via Web Audio API playAudioAlert() { try { if (!this.audioCtx) { this.audioCtx = new (window.AudioContext || window.webkitAudioContext)(); } if (this.audioCtx.state === 'suspended') { this.audioCtx.resume(); } const ctx = this.audioCtx; const oscillator = ctx.createOscillator(); const gain = ctx.createGain(); oscillator.connect(gain); gain.connect(ctx.destination); oscillator.type = 'sine'; oscillator.frequency.setValueAtTime(660, ctx.currentTime); gain.gain.setValueAtTime(0.15, ctx.currentTime); gain.gain.exponentialRampToValueAtTime(0.01, ctx.currentTime + 0.15); oscillator.start(ctx.currentTime); oscillator.stop(ctx.currentTime + 0.15); } catch (_e) { /* Audio not available */ } } // UI interactions toggleDrawer() { const drawer = document.getElementById('notifDrawer'); if (!drawer) return; this.isDrawerOpen = !this.isDrawerOpen; drawer.classList.toggle('open', this.isDrawerOpen); if (this.isDrawerOpen) { this.renderDrawer(); } } clickNotification(notifId) { const notif = this.notifications.find(n => n.id === notifId); if (!notif) return; // Mark as read if (!notif.read) { notif.read = true; this.unreadCount = Math.max(0, this.unreadCount - 1); this.updateBadge(); } // Switch to session if available if (notif.sessionId && this.app.sessions.has(notif.sessionId)) { this.app.selectSession(notif.sessionId); this.toggleDrawer(); } this.scheduleRender(); } markAllRead() { this.notifications.forEach(n => { n.read = true; }); this.unreadCount = 0; this.updateBadge(); this.stopTitleFlash(); this.scheduleRender(); } clearAll() { this.notifications = []; this.unreadCount = 0; this.updateBadge(); this.stopTitleFlash(); this.scheduleRender(); } updateBadge() { const badge = document.getElementById('notifBadge'); if (!badge) return; if (this.unreadCount > 0) { badge.style.display = 'flex'; badge.textContent = this.unreadCount > 99 ? '99+' : String(this.unreadCount); } else { badge.style.display = 'none'; } } onTabVisible() { this.stopTitleFlash(); // If drawer is open, mark all as read if (this.isDrawerOpen) { this.markAllRead(); } // Re-fit terminal and send resize to PTY so this client's dimensions win. // Fixes broken layout when switching between desktop and mobile on the same session. if (this.app?.fitAddon && this.app?.activeSessionId) { this.app.fitAddon.fit(); this.app.sendResize(this.app.activeSessionId); } } // Utilities relativeTime(ts) { const diff = Math.floor((Date.now() - ts) / 1000); if (diff < 60) return 'now'; if (diff < 3600) return `${Math.floor(diff / 60)}m ago`; if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`; return `${Math.floor(diff / 86400)}d ago`; } escapeHtml(text) { if (!text) return ''; return text.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); } } class ClaudemanApp { constructor() { this.sessions = new Map(); this.sessionOrder = []; // Track tab order for drag-and-drop reordering this.draggedTabId = null; // Currently dragged tab session ID this.cases = []; this.currentRun = null; this.totalTokens = 0; this.globalStats = null; // Global token/cost stats across all sessions this.eventSource = null; this.terminal = null; this.fitAddon = null; this.activeSessionId = null; this._initGeneration = 0; // dedup concurrent handleInit calls this._initFallbackTimer = null; // fallback timer if SSE init doesn't arrive this._selectGeneration = 0; // cancel stale selectSession loads this.respawnStatus = {}; this.respawnTimers = {}; // Track timed respawn timers this.respawnCountdownTimers = {}; // { sessionId: { timerName: { endsAt, totalMs, reason } } } this.respawnActionLogs = {}; // { sessionId: [action, action, ...] } (max 20) this.timerCountdownInterval = null; // Interval for updating countdown display this.terminalBuffers = new Map(); // Store terminal content per session this.editingSessionId = null; // Session being edited in options modal this.pendingCloseSessionId = null; // Session pending close confirmation this.muxSessions = []; // Screen sessions for process monitor // Ralph loop/todo state per session this.ralphStates = new Map(); // Map // Subagent (Claude Code background agent) tracking this.subagents = new Map(); // Map this.subagentActivity = new Map(); // Map - recent tool calls/progress this.subagentToolResults = new Map(); // Map> - tool results by toolUseId this.activeSubagentId = null; // Currently selected subagent for detail view this.subagentPanelVisible = false; this.subagentWindows = new Map(); // Map this.subagentWindowZIndex = ZINDEX_SUBAGENT_BASE; this.minimizedSubagents = new Map(); // Map> - minimized to tab this._subagentHideTimeout = null; // Timeout for hover-based dropdown hide // PERSISTENT parent associations - agentId -> sessionId // This is the SINGLE SOURCE OF TRUTH for which tab an agent window connects to. // Once set, never recalculated. Persisted to localStorage and server. this.subagentParentMap = new Map(); // Agent Teams tracking this.teams = new Map(); // Map this.teamTasks = new Map(); // Map this.teammateMap = new Map(); // Map for quick lookup // Teammate tmux pane terminals (Agent Teams feature) this.teammatePanesByName = new Map(); // Map this.teammateTerminals = new Map(); // Map this.terminalBufferCache = new Map(); // Map — client-side cache for instant tab re-visits (max 20) this.ralphStatePanelCollapsed = true; // Default to collapsed this.ralphClosedSessions = new Set(); // Sessions where user explicitly closed Ralph panel // Plan subagent windows (visible agents during plan generation) this.planSubagents = new Map(); // Map this.planSubagentWindowZIndex = ZINDEX_PLAN_SUBAGENT_BASE; this.planGenerationStopped = false; // Flag to ignore SSE events after Stop this.planAgentsMinimized = false; // Whether agent windows are minimized to tab // Wizard dragging state this.wizardDragState = null; // { startX, startY, startLeft, startTop, isDragging } this.wizardDragListeners = null; // { move, up } for cleanup this.wizardPosition = null; // { left, top } - null means centered // Project Insights tracking (active Bash tools with clickable file paths) this.projectInsights = new Map(); // Map this.logViewerWindows = new Map(); // Map this.logViewerWindowZIndex = ZINDEX_LOG_VIEWER_BASE; this.projectInsightsPanelVisible = false; this.currentSessionWorkingDir = null; // Track current session's working dir for path normalization // Image popup windows (auto-open for detected screenshots/images) this.imagePopups = new Map(); // Map this.imagePopupZIndex = ZINDEX_IMAGE_POPUP_BASE; // Tab alert states: Map this.tabAlerts = new Map(); // Pending hooks per session: Map> // Tracks pending hook events that need resolution (permission_prompt, elicitation_dialog, idle_prompt) this.pendingHooks = new Map(); // Terminal write batching with DEC 2026 sync support this.pendingWrites = []; this.writeFrameScheduled = false; this._wasAtBottomBeforeWrite = true; // Default to true for sticky scroll this.syncWaitTimeout = null; // Timeout for incomplete sync blocks this._isLoadingBuffer = false; // true during chunkedTerminalWrite — blocks live SSE writes this._loadBufferQueue = null; // queued SSE events during buffer load // Flicker filter state (buffers output after screen clears) this.flickerFilterBuffer = ''; this.flickerFilterActive = false; this.flickerFilterTimeout = null; // Render debouncing this.renderSessionTabsTimeout = null; this.renderRalphStatePanelTimeout = null; this.renderTaskPanelTimeout = null; this.renderMuxSessionsTimeout = null; // System stats polling this.systemStatsInterval = null; // SSE reconnect timeout (to prevent orphaned timeouts) this.sseReconnectTimeout = null; // SSE event listener cleanup function (to prevent listener accumulation on reconnect) this._sseListenerCleanup = null; // SSE connection status tracking this.reconnectAttempts = 0; this.maxReconnectAttempts = 10; this.isOnline = navigator.onLine; // Offline input queue this._inputQueue = new Map(); // Map this._inputQueueMaxBytes = 64 * 1024; // 64KB cap per session this._connectionStatus = 'connected'; // Sequential input send chain — ensures keystroke ordering across async fetches this._inputSendChain = Promise.resolve(); // Local echo overlay — DOM overlay positioned at the visible ❯ prompt // (not at buffer.cursorY, which reflects Ink's internal cursor position) this._localEchoOverlay = null; // created after terminal.open() this._localEchoEnabled = false; // true when setting on + session active this._restoringFlushedState = false; // true during selectSession buffer load — protects flushed Maps // Accessibility: Focus trap for modals this.activeFocusTrap = null; // Notification system this.notificationManager = new NotificationManager(this); this.idleTimers = new Map(); // Map for stuck detection // DOM element cache for performance (avoid repeated getElementById calls) this._elemCache = {}; this.init(); } // Cached element getter - avoids repeated DOM queries $(id) { if (!this._elemCache[id]) { this._elemCache[id] = document.getElementById(id); } return this._elemCache[id]; } // Format token count: 1000k -> 1m, 1450k -> 1.45m, 500 -> 500 formatTokens(count) { if (count >= 1000000) { const m = count / 1000000; return m >= 10 ? `${m.toFixed(1)}m` : `${m.toFixed(2)}m`; } else if (count >= 1000) { const k = count / 1000; return k >= 100 ? `${k.toFixed(0)}k` : `${k.toFixed(1)}k`; } return String(count); } // Estimate cost from tokens using Claude Opus pricing // Input: $15/M tokens, Output: $75/M tokens estimateCost(inputTokens, outputTokens) { const inputCost = (inputTokens / 1000000) * 15; const outputCost = (outputTokens / 1000000) * 75; return inputCost + outputCost; } // ========== Pending Hooks State Machine ========== // Track pending hook events per session to determine tab alerts. // Action hooks (permission_prompt, elicitation_dialog) take priority over idle_prompt. setPendingHook(sessionId, hookType) { if (!this.pendingHooks.has(sessionId)) { this.pendingHooks.set(sessionId, new Set()); } this.pendingHooks.get(sessionId).add(hookType); this.updateTabAlertFromHooks(sessionId); } clearPendingHooks(sessionId, hookType = null) { const hooks = this.pendingHooks.get(sessionId); if (!hooks) return; if (hookType) { hooks.delete(hookType); } else { hooks.clear(); } if (hooks.size === 0) { this.pendingHooks.delete(sessionId); } this.updateTabAlertFromHooks(sessionId); } updateTabAlertFromHooks(sessionId) { const hooks = this.pendingHooks.get(sessionId); if (!hooks || hooks.size === 0) { this.tabAlerts.delete(sessionId); } else if (hooks.has('permission_prompt') || hooks.has('elicitation_dialog')) { this.tabAlerts.set(sessionId, 'action'); } else if (hooks.has('idle_prompt')) { this.tabAlerts.set(sessionId, 'idle'); } this.renderSessionTabs(); } init() { // Initialize mobile detection first (adds device classes to body) MobileDetection.init(); // Initialize mobile handlers KeyboardHandler.init(); SwipeHandler.init(); VoiceInput.init(); KeyboardAccessoryBar.init(); this.applyHeaderVisibilitySettings(); this.applyTabWrapSettings(); this.applyMonitorVisibility(); // Remove mobile-init class now that JS has applied visibility settings. // The inline