UNPKG

claude-code-web

Version:

Web-based interface for Claude Code CLI accessible via browser

1,224 lines (1,049 loc) 87.5 kB
class ClaudeCodeWebInterface { constructor() { this.terminal = null; this.fitAddon = null; this.webLinksAddon = null; this.socket = null; this.connectionId = null; this.currentClaudeSessionId = null; this.currentClaudeSessionName = null; this.reconnectAttempts = 0; this.maxReconnectAttempts = 5; this.reconnectDelay = 1000; this.folderMode = true; // Always use folder mode this.currentFolderPath = null; this.claudeSessions = []; this.isCreatingNewSession = false; this.isMobile = this.detectMobile(); this.currentMode = 'chat'; this.planDetector = null; this.planModal = null; // Aliases for assistants (populated from /api/config) this.aliases = { claude: 'Claude', codex: 'Codex' }; // Initialize the session tab manager this.sessionTabManager = null; // Usage stats this.usageStats = null; this.usageUpdateTimer = null; this.sessionStats = null; this.sessionTimer = null; this.sessionTimerInterval = null; this.splitContainer = null; this.init(); } // Helper method for authenticated fetch calls async authFetch(url, options = {}) { const authHeaders = window.authManager.getAuthHeaders(); const mergedOptions = { ...options, headers: { ...authHeaders, ...(options.headers || {}) } }; const response = await fetch(url, mergedOptions); // If we get a 401, the token might be invalid or missing if (response.status === 401 && window.authManager.authRequired) { // Clear any invalid token window.authManager.token = null; sessionStorage.removeItem('cc-web-token'); // Show login prompt window.authManager.showLoginPrompt(); } return response; } async init() { // Check authentication first const authenticated = await window.authManager.initialize(); if (!authenticated) { // Auth prompt is shown, stop initialization console.log('[Init] Authentication required, waiting for login...'); return; } await this.loadConfig(); this.setupTerminal(); this.setupUI(); this.setupPlanDetector(); this.loadSettings(); this.applyAliasesToUI(); this.disablePullToRefresh(); // Show loading while we initialize this.showOverlay('loadingSpinner'); // Initialize the session tab manager and wait for sessions to load this.sessionTabManager = new SessionTabManager(this); await this.sessionTabManager.init(); // Initialize split container if (window.SplitContainer) { this.splitContainer = new window.SplitContainer(this); this.splitContainer.setupDropZones(); } // Show mode switcher on mobile if (this.isMobile) { this.showModeSwitcher(); } // Check if there are existing sessions console.log('[Init] Checking sessions, tabs.size:', this.sessionTabManager.tabs.size); if (this.sessionTabManager.tabs.size > 0) { console.log('[Init] Found sessions, switching to first tab...'); // Sessions exist - switch to the first one (this will handle connecting) const firstTabId = this.sessionTabManager.tabs.keys().next().value; console.log('[Init] Switching to tab:', firstTabId); await this.sessionTabManager.switchToTab(firstTabId); // Hide overlay completely since we have sessions console.log('[Init] About to hide overlay'); this.hideOverlay(); console.log('[Init] Overlay should be hidden now'); } else { console.log('[Init] No sessions found, showing folder browser'); // No sessions - hide loading overlay and show folder picker to create first session this.hideOverlay(); this.showFolderBrowser(); } window.addEventListener('resize', () => { this.fitTerminal(); }); window.addEventListener('beforeunload', () => { this.disconnect(); }); } async loadConfig() { try { const res = await this.authFetch('/api/config'); if (res.ok) { const cfg = await res.json(); if (cfg?.aliases) { this.aliases = { claude: cfg.aliases.claude || 'Claude', codex: cfg.aliases.codex || 'Codex' }; } if (typeof cfg.folderMode === 'boolean') { this.folderMode = cfg.folderMode; } } } catch (_) { /* best-effort */ } } getAlias(kind) { if (this.aliases && this.aliases[kind]) { return this.aliases[kind]; } // Default aliases if (kind === 'codex') return 'Codex'; if (kind === 'agent') return 'Cursor'; return 'Claude'; } applyAliasesToUI() { // Start prompt buttons const startBtn = document.getElementById('startBtn'); const dangerousSkipBtn = document.getElementById('dangerousSkipBtn'); const startCodexBtn = document.getElementById('startCodexBtn'); const dangerousCodexBtn = document.getElementById('dangerousCodexBtn'); const startAgentBtn = document.getElementById('startAgentBtn'); if (startBtn) startBtn.textContent = `Start ${this.getAlias('claude')}`; if (dangerousSkipBtn) dangerousSkipBtn.textContent = `Dangerous ${this.getAlias('claude')}`; if (startCodexBtn) startCodexBtn.textContent = `Start ${this.getAlias('codex')}`; if (dangerousCodexBtn) dangerousCodexBtn.textContent = `Dangerous ${this.getAlias('codex')}`; if (startAgentBtn) startAgentBtn.textContent = `Start ${this.getAlias('agent')}`; // Plan modal title const planTitle = document.querySelector('#planModal .modal-header h2'); if (planTitle) planTitle.innerHTML = `<span class=\"icon\" aria-hidden=\"true\">${window.icons?.clipboard?.(18) || ''}</span> ${this.getAlias('claude')}'s Plan`; } detectMobile() { // Check for touch capability and common mobile user agents const hasTouchScreen = 'ontouchstart' in window || navigator.maxTouchPoints > 0 || navigator.msMaxTouchPoints > 0; const mobileUserAgent = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); // Also check viewport width for tablets const smallViewport = window.innerWidth <= 1024; return hasTouchScreen && (mobileUserAgent || smallViewport); } disablePullToRefresh() { // Prevent pull-to-refresh on touchmove let lastY = 0; document.addEventListener('touchstart', (e) => { lastY = e.touches[0].clientY; }, { passive: false }); document.addEventListener('touchmove', (e) => { const y = e.touches[0].clientY; const scrollTop = window.pageYOffset || document.documentElement.scrollTop || document.body.scrollTop || 0; // Prevent pull-to-refresh when at the top and trying to scroll up if (scrollTop === 0 && y > lastY) { e.preventDefault(); } lastY = y; }, { passive: false }); // Also prevent overscroll on the terminal element const terminal = document.getElementById('terminal'); if (terminal) { terminal.addEventListener('touchmove', (e) => { e.stopPropagation(); }, { passive: false }); } } showModeSwitcher() { // Create mode switcher button if it doesn't exist if (!document.getElementById('modeSwitcher')) { const modeSwitcher = document.createElement('div'); modeSwitcher.id = 'modeSwitcher'; modeSwitcher.className = 'mode-switcher'; modeSwitcher.innerHTML = ` <button id="escapeBtn" class="escape-btn" title="Send Escape key"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <circle cx="12" cy="12" r="10"/> <line x1="12" y1="8" x2="12" y2="12"/> <line x1="12" y1="16" x2="12.01" y2="16"/> </svg> </button> <button id="modeSwitcherBtn" class="mode-switcher-btn" data-mode="${this.currentMode}" title="Switch mode (Shift+Tab)"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <rect x="3" y="3" width="18" height="18" rx="2" ry="2"/> <line x1="9" y1="9" x2="15" y2="15"/> <line x1="15" y1="9" x2="9" y2="15"/> </svg> </button> `; document.body.appendChild(modeSwitcher); // Add event listener for mode switcher document.getElementById('modeSwitcherBtn').addEventListener('click', () => { this.switchMode(); }); // Add event listener for escape button document.getElementById('escapeBtn').addEventListener('click', () => { this.sendEscape(); }); } } sendEscape() { // Send ESC key to terminal if (this.socket && this.socket.readyState === WebSocket.OPEN) { // Send ESC key (ASCII 27 or \x1b) this.send({ type: 'input', data: '\x1b' }); } // Add visual feedback const btn = document.getElementById('escapeBtn'); if (btn) { btn.classList.add('pressed'); setTimeout(() => { btn.classList.remove('pressed'); }, 200); } } switchMode() { // Toggle between modes const modes = ['chat', 'code', 'plan']; const currentIndex = modes.indexOf(this.currentMode); const nextIndex = (currentIndex + 1) % modes.length; this.currentMode = modes[nextIndex]; // Update button data attribute for styling const btn = document.getElementById('modeSwitcherBtn'); if (btn) { btn.setAttribute('data-mode', this.currentMode); btn.title = `Switch mode (Shift+Tab) - Current: ${this.currentMode.charAt(0).toUpperCase() + this.currentMode.slice(1)}`; } // Send Shift+Tab to terminal to trigger actual mode switch in Claude Code if (this.socket && this.socket.readyState === WebSocket.OPEN) { // Send Shift+Tab key combination (ESC[Z is the terminal sequence for Shift+Tab) this.send({ type: 'input', data: '\x1b[Z' }); } // Add visual feedback if (btn) { btn.classList.add('switching'); setTimeout(() => { btn.classList.remove('switching'); }, 300); } } setupTerminal() { // Adjust font size for mobile devices const isMobile = this.detectMobile(); const fontSize = isMobile ? 12 : 14; this.terminal = new Terminal({ fontSize: fontSize, fontFamily: 'JetBrains Mono, Fira Code, Monaco, Consolas, monospace', theme: { background: 'transparent', foreground: '#f0f6fc', cursor: '#58a6ff', cursorAccent: '#0d1117', selection: 'rgba(88, 166, 255, 0.3)', black: '#484f58', red: '#ff7b72', green: '#7ee787', yellow: '#ffa657', blue: '#79c0ff', magenta: '#d2a8ff', cyan: '#a5f3fc', white: '#b1bac4', brightBlack: '#6e7681', brightRed: '#ffa198', brightGreen: '#56d364', brightYellow: '#ffdf5d', brightBlue: '#79c0ff', brightMagenta: '#d2a8ff', brightCyan: '#a5f3fc', brightWhite: '#f0f6fc' }, allowProposedApi: true, scrollback: 10000, rightClickSelectsWord: false, allowTransparency: true, // Disable focus tracking to prevent ^[[I and ^[[O sequences windowOptions: { reportFocus: false } }); this.fitAddon = new FitAddon.FitAddon(); this.webLinksAddon = new WebLinksAddon.WebLinksAddon(); this.terminal.loadAddon(this.fitAddon); this.terminal.loadAddon(this.webLinksAddon); this.terminal.open(document.getElementById('terminal')); this.fitTerminal(); this.terminal.onData((data) => { if (this.socket && this.socket.readyState === WebSocket.OPEN) { // Filter out focus tracking sequences before sending const filteredData = data.replace(/\x1b\[\[?[IO]/g, ''); if (filteredData) { this.send({ type: 'input', data: filteredData }); } } }); this.terminal.onResize(({ cols, rows }) => { if (this.socket && this.socket.readyState === WebSocket.OPEN) { this.send({ type: 'resize', cols, rows }); } }); } showSessionSelectionModal() { // Create a simple modal to show existing sessions const modal = document.createElement('div'); modal.className = 'session-modal active'; modal.id = 'sessionSelectionModal'; modal.innerHTML = ` <div class="modal-content"> <div class="modal-header"> <h2>Select a Session</h2> <button class="close-btn" id="closeSessionSelection">&times;</button> </div> <div class="modal-body"> <div class="session-list"> ${this.claudeSessions.map(session => { const statusIcon = `<span class=\"dot ${session.active ? 'dot-on' : 'dot-idle'}\"></span>`; const clientsText = session.connectedClients === 1 ? '1 client' : `${session.connectedClients} clients`; return ` <div class="session-item" data-session-id="${session.id}" style="cursor: pointer; padding: 15px; border: 1px solid #333; border-radius: 5px; margin-bottom: 10px;"> <div class="session-info"> <span class="session-status">${statusIcon}</span> <div class="session-details"> <div class="session-name">${session.name}</div> <div class="session-meta">${clientsText} • ${new Date(session.created).toLocaleString()}</div> ${session.workingDir ? `<div class=\"session-folder\" title=\"${session.workingDir}\"><span class=\"icon\" aria-hidden=\"true\">${window.icons?.folder?.(14) || ''}</span> ${session.workingDir}</div>` : ''} </div> </div> </div> `; }).join('')} </div> <div style="margin-top: 20px; text-align: center;"> <button class="btn btn-secondary" id="selectSessionNewFolder">Load a New Folder Instead</button> </div> </div> </div> `; document.body.appendChild(modal); // Add event listeners modal.querySelectorAll('.session-item').forEach(item => { item.addEventListener('click', async () => { const sessionId = item.dataset.sessionId; await this.joinSession(sessionId); modal.remove(); }); }); document.getElementById('closeSessionSelection').addEventListener('click', () => { modal.remove(); this.hideOverlay(); this.showFolderBrowser(); }); document.getElementById('selectSessionNewFolder').addEventListener('click', () => { modal.remove(); this.hideOverlay(); this.showFolderBrowser(); }); // Close on background click modal.addEventListener('click', (e) => { if (e.target === modal) { modal.remove(); this.hideOverlay(); this.showFolderBrowser(); } }); } setupUI() { const startBtn = document.getElementById('startBtn'); const dangerousSkipBtn = document.getElementById('dangerousSkipBtn'); const startCodexBtn = document.getElementById('startCodexBtn'); const dangerousCodexBtn = document.getElementById('dangerousCodexBtn'); const startAgentBtn = document.getElementById('startAgentBtn'); const settingsBtn = document.getElementById('settingsBtn'); const retryBtn = document.getElementById('retryBtn'); // Mobile menu buttons (keeping for mobile support) const closeMenuBtn = document.getElementById('closeMenuBtn'); const settingsBtnMobile = document.getElementById('settingsBtnMobile'); if (startBtn) startBtn.addEventListener('click', () => this.startClaudeSession()); if (dangerousSkipBtn) dangerousSkipBtn.addEventListener('click', () => this.startClaudeSession({ dangerouslySkipPermissions: true })); if (startCodexBtn) startCodexBtn.addEventListener('click', () => this.startCodexSession()); if (dangerousCodexBtn) dangerousCodexBtn.addEventListener('click', () => this.startCodexSession({ dangerouslySkipPermissions: true })); if (startAgentBtn) startAgentBtn.addEventListener('click', () => this.startAgentSession()); if (settingsBtn) settingsBtn.addEventListener('click', () => this.showSettings()); if (retryBtn) retryBtn.addEventListener('click', () => this.reconnect()); // Tile view toggle // Mobile menu event listeners if (closeMenuBtn) closeMenuBtn.addEventListener('click', () => this.closeMobileMenu()); if (settingsBtnMobile) { settingsBtnMobile.addEventListener('click', () => { this.showSettings(); this.closeMobileMenu(); }); } // Mobile sessions button const sessionsBtnMobile = document.getElementById('sessionsBtnMobile'); if (sessionsBtnMobile) { sessionsBtnMobile.addEventListener('click', () => { this.showMobileSessionsModal(); this.closeMobileMenu(); }); } this.setupSettingsModal(); this.setupFolderBrowser(); this.setupNewSessionModal(); this.setupMobileSessionsModal(); // Custom prompts dropdown removed } setupSettingsModal() { const modal = document.getElementById('settingsModal'); const closeBtn = document.getElementById('closeSettingsBtn'); const saveBtn = document.getElementById('saveSettingsBtn'); const fontSizeSlider = document.getElementById('fontSize'); const fontSizeValue = document.getElementById('fontSizeValue'); const showTokenStatsCheckbox = document.getElementById('showTokenStats'); closeBtn.addEventListener('click', () => this.hideSettings()); saveBtn.addEventListener('click', () => this.saveSettings()); fontSizeSlider.addEventListener('input', (e) => { fontSizeValue.textContent = e.target.value + 'px'; }); modal.addEventListener('click', (e) => { if (e.target === modal) { this.hideSettings(); } }); } // setupCommandsMenu removed // populateCommandsDropdown removed // appendCustomCommandItem removed // runCommandFromPath removed // setupCustomCommandModal removed // openCustomCommandModal removed // closeCustomCommandModal removed connect(sessionId = null) { return new Promise((resolve, reject) => { const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; let wsUrl = `${protocol}//${location.host}`; if (sessionId) { wsUrl += `?sessionId=${sessionId}`; } // Add auth token if required wsUrl = window.authManager.getWebSocketUrl(wsUrl); this.updateStatus('Connecting...'); // Only show loading spinner if overlay is already visible // Don't force it to show if we're handling restored sessions if (document.getElementById('overlay').style.display !== 'none') { this.showOverlay('loadingSpinner'); } try { this.socket = new WebSocket(wsUrl); this.socket.onopen = () => { this.reconnectAttempts = 0; this.updateStatus('Connected'); console.log('Connected to server'); // Load available sessions this.loadSessions(); // Only show start prompt if we don't have sessions AND no current session // The init() method will handle showing/hiding overlays for restored sessions if (!this.currentClaudeSessionId && (!this.sessionTabManager || this.sessionTabManager.tabs.size === 0)) { this.showOverlay('startPrompt'); } // Show close session button if we have a selected working directory if (this.selectedWorkingDir) { // Close session buttons removed with header } resolve(); }; this.socket.onmessage = (event) => { this.handleMessage(JSON.parse(event.data)); }; this.socket.onclose = (event) => { this.updateStatus('Disconnected'); // Reconnect button removed with header if (!event.wasClean && this.reconnectAttempts < this.maxReconnectAttempts) { setTimeout(() => this.reconnect(), this.reconnectDelay * Math.pow(2, this.reconnectAttempts)); this.reconnectAttempts++; } else { this.showError('Connection lost. Please check your network and try again.'); } }; this.socket.onerror = (error) => { console.error('WebSocket error:', error); this.showError('Failed to connect to the server'); reject(error); }; } catch (error) { console.error('Failed to create WebSocket:', error); this.showError('Failed to create connection'); reject(error); } }); } disconnect() { if (this.socket) { this.socket.close(); this.socket = null; } } reconnect() { this.disconnect(); setTimeout(() => { this.connect().catch(err => console.error('Reconnection failed:', err)); }, 1000); // Reconnect button removed with header } send(data) { if (this.socket && this.socket.readyState === WebSocket.OPEN) { this.socket.send(JSON.stringify(data)); } } handleMessage(message) { switch (message.type) { case 'connected': this.connectionId = message.connectionId; break; case 'session_created': this.currentClaudeSessionId = message.sessionId; this.currentClaudeSessionName = message.sessionName; this.updateWorkingDir(message.workingDir); this.updateSessionButton(message.sessionName); this.loadSessions(); // Add tab for the new session if using tab manager if (this.sessionTabManager) { this.sessionTabManager.addTab(message.sessionId, message.sessionName, 'idle', message.workingDir); this.sessionTabManager.switchToTab(message.sessionId); } this.showOverlay('startPrompt'); break; case 'session_joined': console.log('[session_joined] Message received, active:', message.active, 'tabs:', this.sessionTabManager?.tabs.size); this.currentClaudeSessionId = message.sessionId; this.currentClaudeSessionName = message.sessionName; this.updateWorkingDir(message.workingDir); this.updateSessionButton(message.sessionName); // Update tab status if (this.sessionTabManager) { this.sessionTabManager.updateTabStatus(message.sessionId, message.active ? 'active' : 'idle'); } // Notify split container of session change if (this.splitContainer) { this.splitContainer.onTabSwitch(message.sessionId); } // Resolve pending join promise if it exists if (this.pendingJoinResolve && this.pendingJoinSessionId === message.sessionId) { this.pendingJoinResolve(); this.pendingJoinResolve = null; this.pendingJoinSessionId = null; } // Replay output buffer if available if (message.outputBuffer && message.outputBuffer.length > 0) { this.terminal.clear(); message.outputBuffer.forEach(data => { // Filter out focus tracking sequences (^[[I and ^[[O) const filteredData = data.replace(/\x1b\[\[?[IO]/g, ''); this.terminal.write(filteredData); }); } // Show appropriate UI based on session state console.log('[session_joined] Checking if should show overlay. Active:', message.active); if (message.active) { console.log('[session_joined] Session is active, hiding overlay'); this.hideOverlay(); // Don't auto-focus to avoid focus tracking sequences // User can click to focus when ready } else { // Session exists but Claude is not running // Check if this is a brand new session (empty output buffer indicates new) const isNewSession = !message.outputBuffer || message.outputBuffer.length === 0; if (isNewSession) { console.log('[session_joined] New session detected, showing start prompt'); this.showOverlay('startPrompt'); } else { console.log('[session_joined] Existing session with stopped Claude, showing restart prompt'); // For existing sessions where Claude has stopped, show start prompt // This allows the user to restart Claude in the same session this.terminal.writeln(`\r\n\x1b[33m${this.getAlias('claude')} has stopped in this session. Click "Start ${this.getAlias('claude')}" to restart.\x1b[0m`); this.showOverlay('startPrompt'); } } break; case 'session_left': this.currentClaudeSessionId = null; this.currentClaudeSessionName = null; this.updateSessionButton('Sessions'); this.terminal.clear(); // Update tab status if (this.sessionTabManager && message.sessionId) { this.sessionTabManager.updateTabStatus(message.sessionId, 'disconnected'); } // Only show start prompt if we don't have any tabs // When switching tabs, we leave one and join another, so don't show prompt if (!this.sessionTabManager || this.sessionTabManager.tabs.size === 0) { this.showOverlay('startPrompt'); } break; case 'claude_started': this.hideOverlay(); // Don't auto-focus to avoid focus tracking sequences // User can click to focus when ready this.loadSessions(); // Refresh session list // Request usage stats to start tracking session usage this.requestUsageStats(); // Update tab status to active if (this.sessionTabManager && this.currentClaudeSessionId) { this.sessionTabManager.updateTabStatus(this.currentClaudeSessionId, 'active'); } break; case 'codex_started': this.hideOverlay(); this.loadSessions(); this.requestUsageStats(); if (this.sessionTabManager && this.currentClaudeSessionId) { this.sessionTabManager.updateTabStatus(this.currentClaudeSessionId, 'active'); } break; case 'agent_started': this.hideOverlay(); this.loadSessions(); this.requestUsageStats(); if (this.sessionTabManager && this.currentClaudeSessionId) { this.sessionTabManager.updateTabStatus(this.currentClaudeSessionId, 'active'); } break; case 'claude_stopped': this.terminal.writeln(`\r\n\x1b[33m${this.getAlias('claude')} stopped\x1b[0m`); // Show start prompt to allow restarting Claude in this session this.showOverlay('startPrompt'); this.loadSessions(); // Refresh session list break; case 'codex_stopped': this.terminal.writeln(`\r\n\x1b[33mCodex Code stopped\x1b[0m`); this.showOverlay('startPrompt'); this.loadSessions(); break; case 'agent_stopped': this.terminal.writeln(`\r\n\x1b[33m${this.getAlias('agent')} stopped\x1b[0m`); this.showOverlay('startPrompt'); this.loadSessions(); break; case 'output': // Filter out focus tracking sequences (^[[I and ^[[O) const filteredData = message.data.replace(/\x1b\[\[?[IO]/g, ''); this.terminal.write(filteredData); // Update session activity indicator with output data if (this.sessionTabManager && this.currentClaudeSessionId) { this.sessionTabManager.markSessionActivity(this.currentClaudeSessionId, true, message.data); } // Pass output to plan detector if (this.planDetector) { this.planDetector.processOutput(message.data); } break; case 'exit': this.terminal.writeln(`\r\n\x1b[33m${this.getAlias('claude')} exited with code ${message.code}\x1b[0m`); // Mark session as error if non-zero exit code if (this.sessionTabManager && this.currentClaudeSessionId && message.code !== 0) { this.sessionTabManager.markSessionError(this.currentClaudeSessionId, true); } this.showOverlay('startPrompt'); this.loadSessions(); // Refresh session list break; case 'error': this.showError(message.message); // Mark session as having an error if (this.sessionTabManager && this.currentClaudeSessionId) { this.sessionTabManager.markSessionError(this.currentClaudeSessionId, true); } break; case 'info': // Info message - show the start prompt if Claude is not running if (message.message.includes('not running')) { this.showOverlay('startPrompt'); } break; case 'session_deleted': this.showError(message.message); this.currentClaudeSessionId = null; this.currentClaudeSessionName = null; this.updateSessionButton('Sessions'); if (this.sessionTabManager && message.sessionId) { this.sessionTabManager.closeSession(message.sessionId, { skipServerRequest: true }); } this.loadSessions(); break; case 'pong': break; case 'usage_update': this.updateUsageDisplay( message.sessionStats, message.dailyStats, message.sessionTimer, message.analytics, message.burnRate, message.plan, message.limits ); break; default: console.log('Unknown message type:', message.type); } } startClaudeSession(options = {}) { // If no session, create one first if (!this.currentClaudeSessionId) { const sessionName = `Session ${new Date().toLocaleString()}`; this.send({ type: 'create_session', name: sessionName, workingDir: this.selectedWorkingDir }); // Wait for session creation, then start Claude setTimeout(() => { this.send({ type: 'start_claude', options }); }, 500); } else { this.send({ type: 'start_claude', options }); } this.showOverlay('loadingSpinner'); const loadingText = options.dangerouslySkipPermissions ? `Starting ${this.getAlias('claude')} (skipping permissions)...` : `Starting ${this.getAlias('claude')}...`; document.getElementById('loadingSpinner').querySelector('p').textContent = loadingText; } startCodexSession(options = {}) { // If no session, create one first if (!this.currentClaudeSessionId) { const sessionName = `Session ${new Date().toLocaleString()}`; this.send({ type: 'create_session', name: sessionName, workingDir: this.selectedWorkingDir }); // Wait for session creation, then start Codex setTimeout(() => { this.send({ type: 'start_codex', options }); }, 500); } else { this.send({ type: 'start_codex', options }); } this.showOverlay('loadingSpinner'); const loadingText = options.dangerouslySkipPermissions ? `Starting ${this.getAlias('codex')} (bypassing approvals and sandbox)...` : `Starting ${this.getAlias('codex')}...`; document.getElementById('loadingSpinner').querySelector('p').textContent = loadingText; } startAgentSession(options = {}) { // If no session, create one first if (!this.currentClaudeSessionId) { const sessionName = `Session ${new Date().toLocaleString()}`; this.send({ type: 'create_session', name: sessionName, workingDir: this.selectedWorkingDir }); // Wait for session creation, then start Agent setTimeout(() => { this.send({ type: 'start_agent', options }); }, 500); } else { this.send({ type: 'start_agent', options }); } this.showOverlay('loadingSpinner'); const loadingText = `Starting ${this.getAlias('agent')}...`; document.getElementById('loadingSpinner').querySelector('p').textContent = loadingText; } clearTerminal() { this.terminal.clear(); } toggleMobileMenu() { const mobileMenu = document.getElementById('mobileMenu'); const hamburgerBtn = document.getElementById('hamburgerBtn'); mobileMenu.classList.toggle('active'); hamburgerBtn.classList.toggle('active'); } closeMobileMenu() { const mobileMenu = document.getElementById('mobileMenu'); const hamburgerBtn = document.getElementById('hamburgerBtn'); mobileMenu.classList.remove('active'); hamburgerBtn.classList.remove('active'); } fitTerminal() { if (this.fitAddon) { try { this.fitAddon.fit(); // On mobile, ensure terminal doesn't exceed viewport width if (this.isMobile) { const terminalElement = document.querySelector('.xterm'); if (terminalElement) { const viewportWidth = window.innerWidth; const currentWidth = terminalElement.offsetWidth; if (currentWidth > viewportWidth) { // Reduce columns to fit viewport const charWidth = currentWidth / this.terminal.cols; const maxCols = Math.floor((viewportWidth - 20) / charWidth); this.terminal.resize(maxCols, this.terminal.rows); } } } } catch (error) { console.error('Error fitting terminal:', error); } } } updateStatus(status) { // Status display removed with header - status now shown in tabs console.log('Status:', status); } updateWorkingDir(dir) { // Working dir display removed with header - shown in tab titles console.log('Working directory:', dir); } showOverlay(contentId) { const overlay = document.getElementById('overlay'); const contents = ['loadingSpinner', 'startPrompt', 'errorMessage']; contents.forEach(id => { document.getElementById(id).style.display = id === contentId ? 'block' : 'none'; }); overlay.style.display = 'flex'; } hideOverlay() { const overlay = document.getElementById('overlay'); if (overlay) { console.log('[hideOverlay] Hiding overlay, current display:', overlay.style.display); overlay.style.display = 'none'; console.log('[hideOverlay] Overlay hidden, new display:', overlay.style.display); } else { console.error('[hideOverlay] Overlay element not found!'); } } showError(message) { document.getElementById('errorText').textContent = message; this.showOverlay('errorMessage'); } showSettings() { const modal = document.getElementById('settingsModal'); modal.classList.add('active'); // Prevent body scroll on mobile when modal is open if (this.isMobile) { document.body.style.overflow = 'hidden'; } const settings = this.loadSettings(); document.getElementById('fontSize').value = settings.fontSize; document.getElementById('fontSizeValue').textContent = settings.fontSize + 'px'; const themeSelect = document.getElementById('themeSelect'); if (themeSelect) themeSelect.value = settings.theme === 'light' ? 'light' : 'dark'; document.getElementById('showTokenStats').checked = settings.showTokenStats; } hideSettings() { document.getElementById('settingsModal').classList.remove('active'); // Restore body scroll if (this.isMobile) { document.body.style.overflow = ''; } } loadSettings() { const defaults = { fontSize: 14, showTokenStats: true, theme: 'dark' }; try { const saved = localStorage.getItem('cc-web-settings'); return saved ? { ...defaults, ...JSON.parse(saved) } : defaults; } catch (error) { console.error('Failed to load settings:', error); return defaults; } } saveSettings() { const settings = { fontSize: parseInt(document.getElementById('fontSize').value), showTokenStats: document.getElementById('showTokenStats').checked, theme: (document.getElementById('themeSelect')?.value) || 'dark' }; try { localStorage.setItem('cc-web-settings', JSON.stringify(settings)); this.applySettings(settings); this.hideSettings(); } catch (error) { console.error('Failed to save settings:', error); } } applySettings(settings) { // Token stats bar removed - no longer needed // Apply theme (dark is default; light sets attribute) if (settings.theme === 'light') { document.documentElement.setAttribute('data-theme', 'light'); } else { document.documentElement.removeAttribute('data-theme'); } this.terminal.options.fontSize = settings.fontSize; this.fitTerminal(); } startHeartbeat() { setInterval(() => { if (this.socket && this.socket.readyState === WebSocket.OPEN) { this.send({ type: 'ping' }); } }, 30000); } // Folder Browser Methods setupFolderBrowser() { const modal = document.getElementById('folderBrowserModal'); const upBtn = document.getElementById('folderUpBtn'); const homeBtn = document.getElementById('folderHomeBtn'); const selectBtn = document.getElementById('selectFolderBtn'); const cancelBtn = document.getElementById('cancelFolderBtn'); const showHiddenCheckbox = document.getElementById('showHiddenFolders'); const createFolderBtn = document.getElementById('createFolderBtn'); const confirmCreateBtn = document.getElementById('confirmCreateFolderBtn'); const cancelCreateBtn = document.getElementById('cancelCreateFolderBtn'); const newFolderInput = document.getElementById('newFolderNameInput'); upBtn.addEventListener('click', () => this.navigateToParent()); homeBtn.addEventListener('click', () => this.navigateToHome()); selectBtn.addEventListener('click', () => this.selectCurrentFolder()); cancelBtn.addEventListener('click', () => this.closeFolderBrowser()); showHiddenCheckbox.addEventListener('change', () => this.loadFolders(this.currentFolderPath)); createFolderBtn.addEventListener('click', () => this.showCreateFolderInput()); confirmCreateBtn.addEventListener('click', () => this.createFolder()); cancelCreateBtn.addEventListener('click', () => this.hideCreateFolderInput()); // Allow Enter key to create folder newFolderInput.addEventListener('keypress', (e) => { if (e.key === 'Enter') { this.createFolder(); } else if (e.key === 'Escape') { this.hideCreateFolderInput(); } }); // Close modal when clicking outside modal.addEventListener('click', (e) => { if (e.target === modal) { this.closeFolderBrowser(); } }); } async showFolderBrowser() { const modal = document.getElementById('folderBrowserModal'); modal.classList.add('active'); // Prevent body scroll on mobile when modal is open if (this.isMobile) { document.body.style.overflow = 'hidden'; } // Load home directory by default await this.loadFolders(); } closeFolderBrowser() { const modal = document.getElementById('folderBrowserModal'); modal.classList.remove('active'); // Restore body scroll if (this.isMobile) { document.body.style.overflow = ''; } // Reset the creating new session flag if canceling this.isCreatingNewSession = false; // If no folder selected, show error if (!this.currentFolderPath) { this.showError('You must select a folder to continue'); } } async loadFolders(path = null) { const showHidden = document.getElementById('showHiddenFolders').checked; const params = new URLSearchParams(); if (path) params.append('path', path); if (showHidden) params.append('showHidden', 'true'); try { const response = await this.authFetch(`/api/folders?${params}`); if (!response.ok) { // Handle 401 specifically - show auth prompt if (response.status === 401) { console.log('Authentication required - showing login prompt'); window.authManager.showLoginPrompt(); return; } const error = await response.json(); throw new Error(error.message || 'Failed to load folders'); } const data = await response.json(); this.currentFolderPath = data.currentPath; this.renderFolders(data); } catch (error) { console.error('Failed to load folders:', error); this.showError(`Failed to load folders: ${error.message}`); } } renderFolders(data) { const pathInput = document.getElementById('currentPathInput'); const folderList = document.getElementById('folderList'); const upBtn = document.getElementById('folderUpBtn'); // Update path display pathInput.value = data.currentPath; // Enable/disable up button upBtn.disabled = !data.parentPath; // Clear and populate folder list folderList.innerHTML = ''; if (data.folders.length === 0) { folderList.innerHTML = '<div class="empty-folder-message">No folders found</div>'; return; } data.folders.forEach(folder => { const folderItem = document.createElement('div'); folderItem.className = 'folder-item'; folderItem.innerHTML = ` <svg class="folder-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/> </svg> <span class="folder-name">${folder.name}</span> `; folderItem.addEventListener('click', () => this.loadFolders(folder.path)); folderList.appendChild(folderItem); }); } async navigateToParent() { if (this.currentFolderPath) { const parentPath = this.currentFolderPath.split('/').slice(0, -1).join('/') || '/'; await this.loadFolders(parentPath); } } async navigateToHome() { await this.loadFolders(); } showCreateFolderInput() { const createBar = document.getElementById('folderCreateBar'); const input = document.getElementById('newFolderNameInput'); createBar.style.display = 'flex'; input.value = ''; input.focus(); } hideCreateFolderInput() { const createBar = document.getElementById('folderCreateBar'); const input = document.getElementById('newFolderNameInput'); createBar.style.display = 'none'; input.value = ''; } async createFolder() { const input = document.getElementById('newFolderNameInput'); const