// 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.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