/** * @fileoverview Core UI controller for Codeman — tab-based terminal manager with xterm.js. * * This is the main application module (~11,500 lines). It defines the CodemanApp class which * manages the entire frontend: terminal rendering, SSE event handling, session lifecycle, * settings UI, and all panel systems. Additional methods are mixed in from api-client.js, * ralph-wizard.js, and subagent-windows.js via Object.assign on CodemanApp.prototype. * * ═══ Major Sections ═══ * * SSE Handler Map (line ~80) — Event-to-method routing table * CodemanApp Class (line ~189) — Constructor and global state * Pending Hooks (line ~368) — Hook state machine for tab alerts * Init (line ~408) — App bootstrap and mobile setup * Terminal Setup (line ~483) — xterm.js config and input handling * Terminal Rendering (line ~1053) — batchTerminalWrite, flushPendingWrites, chunkedTerminalWrite * Event Listeners (line ~1390) — Keyboard shortcuts, resize, beforeunload * SSE Connection (line ~1474) — connectSSE with exponential backoff (1-30s) * SSE Event Handlers (line ~1570) — ~60 handler methods (_onSessionCreated, _onRespawnStateChanged, etc.) * Connection Status (line ~2553) — Online detection, handleInit (full state sync on reconnect) * Session Tabs (line ~2911) — Tab rendering, selection, drag-and-drop reordering * Tab Order & Drag-and-Drop (~3143) — Persistent tab ordering with drag reorder * Session Lifecycle (line ~3268) — Select, close, navigate sessions * Navigation (line ~3673) — goHome, Ralph wizard stub * Quick Start (line ~3709) — Case loading, session spawning (Claude, Shell, OpenCode) * Respawn Banner (line ~4205) — Respawn state display, countdown timers, action log * Kill Sessions (line ~4640) — Kill active/all sessions * Terminal Controls (line ~4678) — Clear, resize, copy, font size, sendInput * Timer / Tokens (line ~4833) — Session timer, token/cost display * Session Options Modal (line ~4939) — Per-session settings, respawn config, color picker * Respawn Presets (line ~5188) — Preset CRUD, load/save/delete * Run Summary Modal (line ~5506) — Timeline events, filtering, export (JSON/Markdown) * Session Options Tabs (line ~5762) — Ralph config tab within session options * Web Push (line ~5882) — Service worker registration, push subscribe/unsubscribe * App Settings Modal (line ~6024) — Global settings, tunnel management, QR auth, voice config * Session Lifecycle Log (line ~6536) — JSONL audit log viewer * Visibility Settings (line ~6859) — Header/panel visibility, device-specific defaults * Persistent Parent Assoc (line ~7218) — Parent session tracking for subagent windows * Help Modal (line ~7326) — Keyboard shortcuts help * Token Statistics (line ~7363) — Aggregate token/cost stats across sessions * Monitor Panel (line ~7490) — Mux sessions + background tasks, detachable panel * Subagents Panel (line ~7614) — Detachable subagent list panel * Ralph Panel (line ~7799) — Ralph Loop status, @fix_plan.md integration * Plan Versioning (line ~8638) — Plan checkpoint/rollback/diff UI * Subagent Panel (line ~8780) — Agent discovery, window open/close, connection lines * Subagent Parent Tracking (~9101) — Tab-based agent grouping * Agent Teams (line ~9595) — Team tasks panel, teammate badges * Project Insights (line ~9995) — Bash tool tracking with clickable file paths * File Browser (line ~10310) — Directory tree panel with file preview * Log Viewer (line ~10619) — Floating file streamer windows (tail -f) * Image Popups (line ~10768) — Auto-popup windows for detected screenshots * Mux Sessions (line ~10909) — tmux session list in monitor panel * Case Settings (line ~10986) — Case CRUD and link management * Mobile Case Picker (line ~11221) — Touch-friendly case selection modal * Plan Wizard Agents (line ~11480) — Plan orchestrator subagent display in monitor * Toast (line ~11566) — Toast notification popups * System Stats (line ~11618) — CPU/memory polling display * Module Init (line ~11696) — localStorage migration and app start * * After the class: localStorage migration (claudeman-* → codeman-*), app instantiation, * and window.app / window.MobileDetection exports. * * @class CodemanApp * @globals {CodemanApp} app - Singleton instance (also on window.app) * * @dependency constants.js (SSE_EVENTS, timing constants, escapeHtml, extractSyncSegments, DEC sync markers) * @dependency mobile-handlers.js (MobileDetection, KeyboardHandler, SwipeHandler) * @dependency voice-input.js (VoiceInput, DeepgramProvider) * @dependency notification-manager.js (NotificationManager class) * @dependency keyboard-accessory.js (KeyboardAccessoryBar, FocusTrap) * @dependency vendor/xterm.js, vendor/xterm-addon-fit.js, vendor/xterm-addon-webgl.js * @dependency vendor/xterm-zerolag-input.iife.js (LocalEchoOverlay) * @loadorder 6 of 9 — loaded after keyboard-accessory.js, before ralph-wizard.js */ // Codeman App - Tab-based Terminal UI // Constants, utilities, and escapeHtml() are in constants.js (loaded before this file) // MobileDetection, KeyboardHandler, SwipeHandler are in mobile-handlers.js // DeepgramProvider, VoiceInput are in voice-input.js // ═══════════════════════════════════════════════════════════════ // Global Error & Performance Diagnostics // ═══════════════════════════════════════════════════════════════ // Writes breadcrumbs to localStorage so they survive tab freezes. // After a crash, check: localStorage.getItem('codeman-crash-diag') const _crashDiag = { _entries: [], _maxEntries: 50, log(msg) { const entry = `${new Date().toISOString().slice(11,23)} ${msg}`; this._entries.push(entry); if (this._entries.length > this._maxEntries) this._entries.shift(); try { localStorage.setItem('codeman-crash-diag', this._entries.join('\n')); } catch {} } }; // Log previous crash breadcrumbs on startup try { const prev = localStorage.getItem('codeman-crash-diag'); if (prev) console.log('[CRASH-DIAG] Previous session breadcrumbs:\n' + prev); } catch {} _crashDiag.log('PAGE LOAD'); // Heartbeat: send breadcrumbs to server every 2s so they survive tab freezes. setInterval(() => { try { localStorage.setItem('codeman-crash-heartbeat', String(Date.now())); if (_crashDiag._entries.length > 0) { navigator.sendBeacon('/api/crash-diag', JSON.stringify({ data: _crashDiag._entries.join('\n') })); } } catch {} }, 2000); window.addEventListener('error', (e) => { _crashDiag.log(`ERROR: ${e.message} at ${e.filename}:${e.lineno}`); console.error('[CRASH-DIAG] Uncaught error:', e.message, '\n File:', e.filename, ':', e.lineno, ':', e.colno, '\n Stack:', e.error?.stack); }); window.addEventListener('unhandledrejection', (e) => { _crashDiag.log(`UNHANDLED: ${e.reason?.message || e.reason}`); console.error('[CRASH-DIAG] Unhandled promise rejection:', e.reason?.message || e.reason, '\n Stack:', e.reason?.stack); }); // Detect long tasks (>50ms main thread blocks) — these cause "page unresponsive" if (typeof PerformanceObserver !== 'undefined') { try { const longTaskObserver = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { if (entry.duration > 200) { _crashDiag.log(`LONG_TASK: ${entry.duration.toFixed(0)}ms`); console.warn(`[CRASH-DIAG] Long task: ${entry.duration.toFixed(0)}ms (type: ${entry.entryType}, name: ${entry.name})`); } } }); longTaskObserver.observe({ type: 'longtask', buffered: true }); } catch { /* longtask not supported */ } } // Track WebGL context loss/restore events on all canvases const _origGetContext = HTMLCanvasElement.prototype.getContext; HTMLCanvasElement.prototype.getContext = function(type, ...args) { const ctx = _origGetContext.call(this, type, ...args); if (type === 'webgl2' || type === 'webgl') { this.addEventListener('webglcontextlost', (e) => { _crashDiag.log(`WEBGL_LOST: ${this.width}x${this.height}`); console.error('[CRASH-DIAG] WebGL context LOST on canvas', this.width, 'x', this.height, '— prevented:', e.defaultPrevented); }); this.addEventListener('webglcontextrestored', () => { _crashDiag.log('WEBGL_RESTORED'); console.warn('[CRASH-DIAG] WebGL context restored'); }); } return ctx; }; // ═══════════════════════════════════════════════════════════════ // SSE Handler Map — event-to-method routing table // ═══════════════════════════════════════════════════════════════ // connectSSE() iterates this array to register all listeners in a single loop. // Omitted no-op events (registered by server but unused in UI): // respawn:stepSent, respawn:aiCheckStarted, respawn:aiCheckCompleted, // respawn:aiCheckFailed, respawn:aiCheckCooldown const _SSE_HANDLER_MAP = [ // Core [SSE_EVENTS.INIT, '_onInit'], // Session lifecycle [SSE_EVENTS.SESSION_CREATED, '_onSessionCreated'], [SSE_EVENTS.SESSION_UPDATED, '_onSessionUpdated'], [SSE_EVENTS.SESSION_DELETED, '_onSessionDeleted'], [SSE_EVENTS.SESSION_TERMINAL, '_onSessionTerminal'], [SSE_EVENTS.SESSION_NEEDS_REFRESH, '_onSessionNeedsRefresh'], [SSE_EVENTS.SESSION_CLEAR_TERMINAL, '_onSessionClearTerminal'], [SSE_EVENTS.SESSION_COMPLETION, '_onSessionCompletion'], [SSE_EVENTS.SESSION_ERROR, '_onSessionError'], [SSE_EVENTS.SESSION_EXIT, '_onSessionExit'], [SSE_EVENTS.SESSION_IDLE, '_onSessionIdle'], [SSE_EVENTS.SESSION_WORKING, '_onSessionWorking'], [SSE_EVENTS.SESSION_AUTO_CLEAR, '_onSessionAutoClear'], [SSE_EVENTS.SESSION_CLI_INFO, '_onSessionCliInfo'], // Scheduled runs [SSE_EVENTS.SCHEDULED_CREATED, '_onScheduledCreated'], [SSE_EVENTS.SCHEDULED_UPDATED, '_onScheduledUpdated'], [SSE_EVENTS.SCHEDULED_COMPLETED, '_onScheduledCompleted'], [SSE_EVENTS.SCHEDULED_STOPPED, '_onScheduledStopped'], // Respawn [SSE_EVENTS.RESPAWN_STARTED, '_onRespawnStarted'], [SSE_EVENTS.RESPAWN_STOPPED, '_onRespawnStopped'], [SSE_EVENTS.RESPAWN_STATE_CHANGED, '_onRespawnStateChanged'], [SSE_EVENTS.RESPAWN_CYCLE_STARTED, '_onRespawnCycleStarted'], [SSE_EVENTS.RESPAWN_BLOCKED, '_onRespawnBlocked'], [SSE_EVENTS.RESPAWN_AUTO_ACCEPT_SENT, '_onRespawnAutoAcceptSent'], [SSE_EVENTS.RESPAWN_DETECTION_UPDATE, '_onRespawnDetectionUpdate'], [SSE_EVENTS.RESPAWN_TIMER_STARTED, '_onRespawnTimerStarted'], [SSE_EVENTS.RESPAWN_TIMER_CANCELLED, '_onRespawnTimerCancelled'], [SSE_EVENTS.RESPAWN_TIMER_COMPLETED, '_onRespawnTimerCompleted'], [SSE_EVENTS.RESPAWN_ERROR, '_onRespawnError'], [SSE_EVENTS.RESPAWN_ACTION_LOG, '_onRespawnActionLog'], // Tasks [SSE_EVENTS.TASK_CREATED, '_onTaskCreated'], [SSE_EVENTS.TASK_COMPLETED, '_onTaskCompleted'], [SSE_EVENTS.TASK_FAILED, '_onTaskFailed'], [SSE_EVENTS.TASK_UPDATED, '_onTaskUpdated'], // Mux (tmux) [SSE_EVENTS.MUX_CREATED, '_onMuxCreated'], [SSE_EVENTS.MUX_KILLED, '_onMuxKilled'], [SSE_EVENTS.MUX_DIED, '_onMuxDied'], [SSE_EVENTS.MUX_STATS_UPDATED, '_onMuxStatsUpdated'], // Ralph [SSE_EVENTS.SESSION_RALPH_LOOP_UPDATE, '_onRalphLoopUpdate'], [SSE_EVENTS.SESSION_RALPH_TODO_UPDATE, '_onRalphTodoUpdate'], [SSE_EVENTS.SESSION_RALPH_COMPLETION_DETECTED, '_onRalphCompletionDetected'], [SSE_EVENTS.SESSION_RALPH_STATUS_UPDATE, '_onRalphStatusUpdate'], [SSE_EVENTS.SESSION_CIRCUIT_BREAKER_UPDATE, '_onCircuitBreakerUpdate'], [SSE_EVENTS.SESSION_EXIT_GATE_MET, '_onExitGateMet'], // Bash tools [SSE_EVENTS.SESSION_BASH_TOOL_START, '_onBashToolStart'], [SSE_EVENTS.SESSION_BASH_TOOL_END, '_onBashToolEnd'], [SSE_EVENTS.SESSION_BASH_TOOLS_UPDATE, '_onBashToolsUpdate'], // Hooks (Claude Code hook events) [SSE_EVENTS.HOOK_IDLE_PROMPT, '_onHookIdlePrompt'], [SSE_EVENTS.HOOK_PERMISSION_PROMPT, '_onHookPermissionPrompt'], [SSE_EVENTS.HOOK_ELICITATION_DIALOG, '_onHookElicitationDialog'], [SSE_EVENTS.HOOK_STOP, '_onHookStop'], [SSE_EVENTS.HOOK_TEAMMATE_IDLE, '_onHookTeammateIdle'], [SSE_EVENTS.HOOK_TASK_COMPLETED, '_onHookTaskCompleted'], // Subagents (Claude Code background agents) [SSE_EVENTS.SUBAGENT_DISCOVERED, '_onSubagentDiscovered'], [SSE_EVENTS.SUBAGENT_UPDATED, '_onSubagentUpdated'], [SSE_EVENTS.SUBAGENT_TOOL_CALL, '_onSubagentToolCall'], [SSE_EVENTS.SUBAGENT_PROGRESS, '_onSubagentProgress'], [SSE_EVENTS.SUBAGENT_MESSAGE, '_onSubagentMessage'], [SSE_EVENTS.SUBAGENT_TOOL_RESULT, '_onSubagentToolResult'], [SSE_EVENTS.SUBAGENT_COMPLETED, '_onSubagentCompleted'], // Images [SSE_EVENTS.IMAGE_DETECTED, '_onImageDetected'], // Tunnel [SSE_EVENTS.TUNNEL_STARTED, '_onTunnelStarted'], [SSE_EVENTS.TUNNEL_STOPPED, '_onTunnelStopped'], [SSE_EVENTS.TUNNEL_PROGRESS, '_onTunnelProgress'], [SSE_EVENTS.TUNNEL_ERROR, '_onTunnelError'], [SSE_EVENTS.TUNNEL_QR_ROTATED, '_onTunnelQrRotated'], [SSE_EVENTS.TUNNEL_QR_REGENERATED, '_onTunnelQrRegenerated'], [SSE_EVENTS.TUNNEL_QR_AUTH_USED, '_onTunnelQrAuthUsed'], // Plan orchestration [SSE_EVENTS.PLAN_SUBAGENT, '_onPlanSubagent'], [SSE_EVENTS.PLAN_PROGRESS, '_onPlanProgress'], [SSE_EVENTS.PLAN_STARTED, '_onPlanStarted'], [SSE_EVENTS.PLAN_CANCELLED, '_onPlanCancelled'], [SSE_EVENTS.PLAN_COMPLETED, '_onPlanCompleted'], ]; // ═══════════════════════════════════════════════════════════════ // CodemanApp Class — constructor and global state // ═══════════════════════════════════════════════════════════════ class CodemanApp { constructor() { this.sessions = new Map(); this._shortIdCache = new Map(); // Cache session ID .slice(0, 8) results this.sessionOrder = []; // Track tab order for drag-and-drop reordering this.draggedTabId = null; // Currently dragged tab session ID this.cases = []; this.currentRun = null; this.totalTokens = 0; this.globalStats = null; // Global token/cost stats across all sessions this.eventSource = null; this.terminal = null; this.fitAddon = null; this.activeSessionId = null; this._initGeneration = 0; // dedup concurrent handleInit calls this._initFallbackTimer = null; // fallback timer if SSE init doesn't arrive this._selectGeneration = 0; // cancel stale selectSession loads this.respawnStatus = {}; this.respawnTimers = {}; // Track timed respawn timers this.respawnCountdownTimers = {}; // { sessionId: { timerName: { endsAt, totalMs, reason } } } this.respawnActionLogs = {}; // { sessionId: [action, action, ...] } (max 20) this.timerCountdownInterval = null; // Interval for updating countdown display this.terminalBuffers = new Map(); // Store terminal content per session this.editingSessionId = null; // Session being edited in options modal this.pendingCloseSessionId = null; // Session pending close confirmation this.muxSessions = []; // Screen sessions for process monitor // Ralph loop/todo state per session this.ralphStates = new Map(); // Map // Subagent (Claude Code background agent) tracking this.subagents = new Map(); // Map this.subagentActivity = new Map(); // Map - recent tool calls/progress this.subagentToolResults = new Map(); // Map> - tool results by toolUseId this.activeSubagentId = null; // Currently selected subagent for detail view this.subagentPanelVisible = false; this.subagentWindows = new Map(); // Map this.subagentWindowZIndex = ZINDEX_SUBAGENT_BASE; this.minimizedSubagents = new Map(); // Map> - minimized to tab this._subagentHideTimeout = null; // Timeout for hover-based dropdown hide // PERSISTENT parent associations - agentId -> sessionId // This is the SINGLE SOURCE OF TRUTH for which tab an agent window connects to. // Once set, never recalculated. Persisted to localStorage and server. this.subagentParentMap = new Map(); // Agent Teams tracking this.teams = new Map(); // Map this.teamTasks = new Map(); // Map this.teammateMap = new Map(); // Map for quick lookup // Teammate tmux pane terminals (Agent Teams feature) this.teammatePanesByName = new Map(); // Map this.teammateTerminals = new Map(); // Map this.terminalBufferCache = new Map(); // Map — client-side cache for instant tab re-visits (max 20) this.ralphStatePanelCollapsed = true; // Default to collapsed this.ralphClosedSessions = new Set(); // Sessions where user explicitly closed Ralph panel // Plan subagent windows (visible agents during plan generation) this.planSubagents = new Map(); // Map this.planSubagentWindowZIndex = ZINDEX_PLAN_SUBAGENT_BASE; this.planGenerationStopped = false; // Flag to ignore SSE events after Stop this.planAgentsMinimized = false; // Whether agent windows are minimized to tab // Wizard dragging state this.wizardDragState = null; // { startX, startY, startLeft, startTop, isDragging } this.wizardDragListeners = null; // { move, up } for cleanup this.wizardPosition = null; // { left, top } - null means centered // Project Insights tracking (active Bash tools with clickable file paths) this.projectInsights = new Map(); // Map this.logViewerWindows = new Map(); // Map this.logViewerWindowZIndex = ZINDEX_LOG_VIEWER_BASE; this.projectInsightsPanelVisible = false; this.currentSessionWorkingDir = null; // Track current session's working dir for path normalization // Image popup windows (auto-open for detected screenshots/images) this.imagePopups = new Map(); // Map this.imagePopupZIndex = ZINDEX_IMAGE_POPUP_BASE; // Tunnel indicator state this._tunnelUrl = null; // Tab alert states: Map this.tabAlerts = new Map(); // Pending hooks per session: Map> // Tracks pending hook events that need resolution (permission_prompt, elicitation_dialog, idle_prompt) this.pendingHooks = new Map(); // Terminal write batching with DEC 2026 sync support this.pendingWrites = []; this.writeFrameScheduled = false; this._wasAtBottomBeforeWrite = true; // Default to true for sticky scroll this.syncWaitTimeout = null; // Timeout for incomplete sync blocks this._isLoadingBuffer = false; // true during chunkedTerminalWrite — blocks live SSE writes this._loadBufferQueue = null; // queued SSE events during buffer load // Flicker filter state (buffers output after screen clears) this.flickerFilterBuffer = ''; this.flickerFilterActive = false; this.flickerFilterTimeout = null; // Render debouncing this.renderSessionTabsTimeout = null; this.renderRalphStatePanelTimeout = null; this.renderTaskPanelTimeout = null; this.renderMuxSessionsTimeout = null; // System stats polling this.systemStatsInterval = null; // SSE reconnect timeout (to prevent orphaned timeouts) this.sseReconnectTimeout = null; // SSE event listener cleanup function (to prevent listener accumulation on reconnect) this._sseListenerCleanup = null; // SSE connection status tracking this.reconnectAttempts = 0; this.maxReconnectAttempts = 10; this.isOnline = navigator.onLine; // Offline input queue this._inputQueue = new Map(); // Map this._inputQueueMaxBytes = 64 * 1024; // 64KB cap per session this._connectionStatus = 'connected'; // Sequential input send chain — ensures keystroke ordering across async fetches this._inputSendChain = Promise.resolve(); // Local echo overlay — DOM overlay positioned at the visible ❯ prompt // (not at buffer.cursorY, which reflects Ink's internal cursor position) this._localEchoOverlay = null; // created after terminal.open() this._localEchoEnabled = false; // true when setting on + session active this._restoringFlushedState = false; // true during selectSession buffer load — protects flushed Maps // Accessibility: Focus trap for modals this.activeFocusTrap = null; // Notification system this.notificationManager = new NotificationManager(this); this.idleTimers = new Map(); // Map for stuck detection // DOM element cache for performance (avoid repeated getElementById calls) this._elemCache = {}; this.init(); } // Cached element getter - avoids repeated DOM queries $(id) { if (!this._elemCache[id]) { this._elemCache[id] = document.getElementById(id); } return this._elemCache[id]; } // Format token count: 1000k -> 1m, 1450k -> 1.45m, 500 -> 500 formatTokens(count) { if (count >= 1000000) { const m = count / 1000000; return m >= 10 ? `${m.toFixed(1)}m` : `${m.toFixed(2)}m`; } else if (count >= 1000) { const k = count / 1000; return k >= 100 ? `${k.toFixed(0)}k` : `${k.toFixed(1)}k`; } return String(count); } // Estimate cost from tokens using Claude Opus pricing // Input: $15/M tokens, Output: $75/M tokens estimateCost(inputTokens, outputTokens) { const inputCost = (inputTokens / 1000000) * 15; const outputCost = (outputTokens / 1000000) * 75; return inputCost + outputCost; } // ═══════════════════════════════════════════════════════════════ // Pending Hooks State Machine // ═══════════════════════════════════════════════════════════════ // Track pending hook events per session to determine tab alerts. // Action hooks (permission_prompt, elicitation_dialog) take priority over idle_prompt. setPendingHook(sessionId, hookType) { if (!this.pendingHooks.has(sessionId)) { this.pendingHooks.set(sessionId, new Set()); } this.pendingHooks.get(sessionId).add(hookType); this.updateTabAlertFromHooks(sessionId); } clearPendingHooks(sessionId, hookType = null) { const hooks = this.pendingHooks.get(sessionId); if (!hooks) return; if (hookType) { hooks.delete(hookType); } else { hooks.clear(); } if (hooks.size === 0) { this.pendingHooks.delete(sessionId); } this.updateTabAlertFromHooks(sessionId); } updateTabAlertFromHooks(sessionId) { const hooks = this.pendingHooks.get(sessionId); if (!hooks || hooks.size === 0) { this.tabAlerts.delete(sessionId); } else if (hooks.has('permission_prompt') || hooks.has('elicitation_dialog')) { this.tabAlerts.set(sessionId, 'action'); } else if (hooks.has('idle_prompt')) { this.tabAlerts.set(sessionId, 'idle'); } this.renderSessionTabs(); } // ═══════════════════════════════════════════════════════════════ // Init — app bootstrap and mobile setup // ═══════════════════════════════════════════════════════════════ init() { // Initialize mobile detection first (adds device classes to body) MobileDetection.init(); // Initialize mobile handlers KeyboardHandler.init(); SwipeHandler.init(); VoiceInput.init(); KeyboardAccessoryBar.init(); this.applyHeaderVisibilitySettings(); this.applyTabWrapSettings(); this.applyMonitorVisibility(); // Remove mobile-init class now that JS has applied visibility settings. // The inline