// 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(); } } }; // ============================================================================ // 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;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;text-rendering:geometricPrecision;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; 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; 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; this._bufferDetectDone = true; return afterPrompt; } } catch {} return null; } _cacheFont() { if (!this._terminal) return; const t = this._terminal; this._font.fontFamily = t.options.fontFamily || 'monospace'; this._font.fontSize = (t.options.fontSize || 14) + '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; } } _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(); KeyboardAccessoryBar.init(); this.applyHeaderVisibilitySettings(); this.applyTabWrapSettings(); this.applyMonitorVisibility(); // Remove mobile-init class now that JS has applied visibility settings. // The inline