// 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(); } } } /** * Local echo DOM overlay — shows typed characters instantly at the visible prompt * position for high-latency connections. Positioned by scanning the terminal buffer * for the ❯ prompt character, not by trusting buffer.cursorY (which reflects Ink's * internal cursor near the status bar). */ class LocalEchoOverlay { constructor(terminal) { this.terminal = terminal; // Container: positioned at prompt row, holds per-line child divs this.overlay = document.createElement('div'); this.overlay.style.cssText = 'position:absolute;z-index:7;pointer-events:none;display:none'; const screen = terminal.element.querySelector('.xterm-screen'); // Cache font/color styles for line divs this._fontFamily = terminal.options.fontFamily; this._fontSize = terminal.options.fontSize + 'px'; this._fontWeight = terminal.options.fontWeight || 'normal'; this._bg = terminal.options?.theme?.background || '#0d0d0d'; this._color = ''; this._letterSpacing = ''; const rows = terminal.element.querySelector('.xterm-rows'); if (rows) { const cs = getComputedStyle(rows); this._letterSpacing = cs.letterSpacing; this._color = cs.color; } screen.appendChild(this.overlay); this.pendingText = ''; this._flushedOffset = 0; // chars flushed to PTY during tab switch (echo may not have arrived yet) this._storageKey = 'claudeman_local_echo_pending'; // Restore unsent input from previous session (survives reload/disconnect) try { const saved = localStorage.getItem(this._storageKey); if (saved) { this.pendingText = saved; this._render(); } } catch {} } _persist() { try { if (this.pendingText) { localStorage.setItem(this._storageKey, this.pendingText); } else { localStorage.removeItem(this._storageKey); } } catch {} } // Scan terminal buffer to find the visible ❯ prompt position (bottom-up) _findPrompt() { try { const buffer = this.terminal.buffer.active; const viewportTop = buffer.viewportY; for (let row = this.terminal.rows - 1; row >= 0; row--) { const line = buffer.getLine(viewportTop + row); if (!line) continue; const text = line.translateToString(true); const idx = text.lastIndexOf('\u276f'); if (idx >= 0) { return { row, col: idx }; } } } catch {} return null; } addChar(char) { this.pendingText += char; this._persist(); this._render(); } removeChar() { if (this.pendingText.length > 0) { this.pendingText = this.pendingText.slice(0, -1); this._persist(); if (this.pendingText.length > 0) { this._render(); } else { this.clear(); } } } appendText(text) { if (!text) return; this.pendingText += text; this._persist(); this._render(); } clear() { this.pendingText = ''; this._flushedOffset = 0; this._persist(); this._lastRenderKey = ''; this._lastPromptPos = null; this.overlay.innerHTML = ''; this.overlay.style.display = 'none'; } // Create a styled line div with per-character grid positioning. // Each char is placed at exact col*cellW to match xterm's canvas renderer // (DOM text flow drifts vs canvas due to sub-pixel glyph width differences). _makeLine(text, leftPx, topPx, widthPx, cellH, cellW) { const el = document.createElement('div'); el.style.cssText = `position:absolute;pointer-events:none`; el.style.backgroundColor = this._bg; 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'; // Place each character at its exact grid position for (let i = 0; i < text.length; i++) { const span = document.createElement('span'); span.style.cssText = `position:absolute;display:inline-block;text-align:center;pointer-events:none`; span.style.left = (i * cellW) + 'px'; span.style.width = cellW + 'px'; span.style.fontFamily = this._fontFamily; span.style.fontSize = this._fontSize; span.style.fontWeight = this._fontWeight; span.style.color = this._color; span.textContent = text[i]; el.appendChild(span); } return el; } _render() { if (!this.pendingText) { this.overlay.style.display = 'none'; return; } try { // Re-scan for prompt on EVERY render — Ink can move it between redraws. // Cache last known position as fallback during Ink redraw flicker. const prompt = this._findPrompt(); if (prompt) { this._lastPromptPos = prompt; } else if (!this._lastPromptPos) { this.overlay.style.display = 'none'; return; } const activePrompt = this._lastPromptPos; const dims = this.terminal._core._renderService.dimensions; const cellW = dims.css.cell.width; const cellH = dims.css.cell.height; const totalCols = this.terminal.cols; // Position after existing text on the prompt line. Normally this is // just "❯ " (col+2), but after a tab switch the flushed text is echoed // by the PTY and sits in the terminal buffer — the overlay must start // AFTER it to avoid covering it with its opaque background. // _flushedOffset provides immediate offset even before echo arrives. let startCol = activePrompt.col + 2 + (this._flushedOffset || 0); try { const buf = this.terminal.buffer.active; const promptLine = buf.getLine(buf.viewportY + activePrompt.row); if (promptLine) { const existingEnd = promptLine.translateToString(true).trimEnd().length; if (existingEnd > startCol) startCol = existingEnd; // Once echo has arrived in the buffer, we can drop the offset if (this._flushedOffset && existingEnd >= activePrompt.col + 2 + this._flushedOffset) { this._flushedOffset = 0; } } } catch {} startCol = Math.min(startCol, totalCols - 1); const firstLineCols = Math.max(1, totalCols - startCol); // Skip redundant re-renders (e.g. from flushPendingWrites at 60fps) const renderKey = `${this.pendingText.length}:${startCol}:${activePrompt.row}:${activePrompt.col}:${totalCols}`; if (renderKey === this._lastRenderKey && this.overlay.style.display !== 'none') return; this._lastRenderKey = renderKey; // Split text into visual lines matching terminal character-wrap behavior // Line 0: starts after prompt, fits (totalCols - startCol) chars // Line 1+: starts at col 0, fits totalCols chars each const lines = []; let remaining = this.pendingText; lines.push(remaining.slice(0, firstLineCols)); remaining = remaining.slice(firstLineCols); while (remaining.length > 0) { lines.push(remaining.slice(0, totalCols)); remaining = remaining.slice(totalCols); } // Position overlay container at prompt row this.overlay.style.left = '0px'; this.overlay.style.top = (activePrompt.row * cellH) + 'px'; // Build line elements — recreate on each render (typically 1-3 divs, negligible cost) this.overlay.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 = this._makeLine(lines[i], leftPx, topPx, widthPx, cellH, cellW); this.overlay.appendChild(lineEl); } // Block cursor at end of last line const lastLine = lines[lines.length - 1]; const lastLineLeft = lines.length === 1 ? startCol : 0; const cursorCol = lastLineLeft + lastLine.length; 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 = this.terminal.options?.theme?.cursor || '#e0e0e0'; this.overlay.appendChild(cursor); } this.overlay.style.display = ''; } catch { // Hide overlay on render error but preserve pendingText — // next rerender() (from flushPendingWrites) will retry when terminal is ready. this.overlay.innerHTML = ''; this.overlay.style.display = 'none'; } } // Re-render overlay at current prompt position without clearing pending text. // Used after terminal resets/redraws that move the prompt. rerender() { if (this.pendingText) this._render(); } get hasPending() { return this.pendingText.length > 0; } dispose() { this.clear(); this.overlay.remove(); } } /** * 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.localEchoTextCache = new Map(); // Map — preserve local echo input across tab switches 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 // 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