claude-code-web
Version:
Web-based interface for Claude Code CLI accessible via browser
1,231 lines (1,061 loc) • 93.2 kB
JavaScript
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;
// 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.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;
}
this.setupTerminal();
this.setupUI();
this.setupPlanDetector();
this.loadSettings();
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();
// 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 - show folder picker to create first session
this.showFolderBrowser();
}
window.addEventListener('resize', () => {
this.fitTerminal();
});
window.addEventListener('beforeunload', () => {
this.disconnect();
});
}
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">×</button>
</div>
<div class="modal-body">
<div class="session-list">
${this.claudeSessions.map(session => {
const statusIcon = session.active ? '🟢' : '⚪';
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}">📁 ${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.showFolderBrowser();
});
document.getElementById('selectSessionNewFolder').addEventListener('click', () => {
modal.remove();
this.showFolderBrowser();
});
// Close on background click
modal.addEventListener('click', (e) => {
if (e.target === modal) {
modal.remove();
this.showFolderBrowser();
}
});
}
setupUI() {
const startBtn = document.getElementById('startBtn');
const dangerousSkipBtn = document.getElementById('dangerousSkipBtn');
const startCodexBtn = document.getElementById('startCodexBtn');
const dangerousCodexBtn = document.getElementById('dangerousCodexBtn');
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 (settingsBtn) settingsBtn.addEventListener('click', () => this.showSettings());
if (retryBtn) retryBtn.addEventListener('click', () => this.reconnect());
// 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();
// Commands ("/") menu anchored to terminal
this.setupCommandsMenu();
this.setupCustomCommandModal();
}
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() {
if (document.getElementById('commandsMenu')) return;
const menu = document.createElement('div');
menu.id = 'commandsMenu';
menu.className = 'commands-menu';
menu.innerHTML = `
<button id="commandsBtn" class="commands-button" title="Run command (/)">/</button>
<div id="commandsDropdown" class="commands-dropdown"></div>
`;
const container = document.getElementById('terminalContainer') || document.body;
container.appendChild(menu);
const btn = document.getElementById('commandsBtn');
const dropdown = document.getElementById('commandsDropdown');
const closeDropdown = () => dropdown.classList.remove('open');
const toggleDropdown = async () => {
if (dropdown.classList.contains('open')) {
closeDropdown();
} else {
await this.populateCommandsDropdown(dropdown);
dropdown.classList.add('open');
}
};
btn.addEventListener('click', (e) => {
e.stopPropagation();
toggleDropdown();
});
document.addEventListener('click', (e) => {
if (!menu.contains(e.target)) closeDropdown();
});
}
async populateCommandsDropdown(dropdown) {
dropdown.innerHTML = '<div class="commands-empty">Loading…</div>';
try {
const res = await this.authFetch('/api/commands/list');
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();
const items = Array.isArray(data.items) ? data.items : [];
if (items.length === 0) {
dropdown.innerHTML = '<div class="commands-empty">No commands found (~/.claude-code-web/commands)</div>';
// Still show Custom option even if no files
this.appendCustomCommandItem(dropdown);
return;
}
dropdown.innerHTML = '';
items
.sort((a, b) => a.label.localeCompare(b.label))
.forEach(item => {
const el = document.createElement('div');
el.className = 'commands-item';
el.textContent = item.label;
el.title = item.path;
el.addEventListener('click', async (e) => {
e.stopPropagation();
await this.runCommandFromPath(item.path);
dropdown.classList.remove('open');
});
dropdown.appendChild(el);
});
// Append Custom option at the bottom
this.appendCustomCommandItem(dropdown);
} catch (error) {
dropdown.innerHTML = `<div class="commands-error">Failed to load commands: ${error.message}</div>`;
// Still allow Custom input even on error
this.appendCustomCommandItem(dropdown);
}
}
appendCustomCommandItem(dropdown) {
const el = document.createElement('div');
el.className = 'commands-item';
el.textContent = 'Custom…';
el.title = 'Type or paste a custom multi-line message';
el.addEventListener('click', (e) => {
e.stopPropagation();
dropdown.classList.remove('open');
this.openCustomCommandModal();
});
dropdown.appendChild(el);
}
async runCommandFromPath(relPath) {
if (!this.currentClaudeSessionId) {
this.showError('Start Claude/Codex in a session first');
return;
}
try {
const url = `/api/commands/content?p=${encodeURIComponent(relPath)}`;
const res = await this.authFetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const content = await res.text();
if (!content) return;
// Send entire markdown content to active agent
this.send({ type: 'input', data: content });
} catch (error) {
this.showError(`Failed to run command: ${error.message}`);
}
}
setupCustomCommandModal() {
if (document.getElementById('customCommandModal')) return;
const modal = document.createElement('div');
modal.id = 'customCommandModal';
modal.className = 'commands-modal';
modal.innerHTML = `
<div class="modal-content">
<div class="modal-header">
<h2>Run Custom Message</h2>
<button class="close-btn" id="closeCustomCommandBtn">×</button>
</div>
<div class="modal-body">
<textarea id="customCommandInput" class="commands-textarea" placeholder="Type or paste your message... Tip: Press Ctrl/Cmd + Enter to run"></textarea>
</div>
<div class="modal-footer">
<button class="btn" id="cancelCustomCommandBtn">Cancel</button>
<button class="btn btn-primary" id="runCustomCommandBtn">Run</button>
</div>
</div>
`;
document.body.appendChild(modal);
const run = () => {
const textarea = document.getElementById('customCommandInput');
const content = (textarea?.value || '').trim();
if (!content) {
this.showError('Please enter a message to run');
return;
}
if (!this.currentClaudeSessionId) {
this.showError('Start Claude/Codex in a session first');
return;
}
// Send and close
this.send({ type: 'input', data: content + '\n' });
this.closeCustomCommandModal();
};
document.getElementById('runCustomCommandBtn').addEventListener('click', run);
document.getElementById('cancelCustomCommandBtn').addEventListener('click', () => this.closeCustomCommandModal());
document.getElementById('closeCustomCommandBtn').addEventListener('click', () => this.closeCustomCommandModal());
// Keyboard shortcut: Ctrl/Cmd + Enter runs
modal.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) {
e.preventDefault();
run();
}
if (e.key === 'Escape') {
this.closeCustomCommandModal();
}
});
}
openCustomCommandModal() {
const modal = document.getElementById('customCommandModal');
if (!modal) return;
modal.classList.add('active');
// Slight delay to ensure modal is visible before focusing
setTimeout(() => {
const textarea = document.getElementById('customCommandInput');
if (textarea) {
textarea.value = '';
textarea.focus();
}
}, 0);
}
closeCustomCommandModal() {
const modal = document.getElementById('customCommandModal');
if (modal) modal.classList.remove('active');
}
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');
}
// 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[33mClaude Code has stopped in this session. Click "Start Claude Code" 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 'claude_stopped':
this.terminal.writeln(`\r\n\x1b[33mClaude Code 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 '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[33mClaude Code 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');
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,
message.codexStats || null
);
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 Claude Code (⚠️ Skipping permissions)...' :
'Starting Claude Code...';
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 Codex (⚠️ Bypassing approvals and sandbox)...' :
'Starting Codex...';
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';
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
};
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
};
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) {
// Apply token stats visibility
const usageStatsContainer = document.getElementById('usageStatsContainer');
if (usageStatsContainer) {
usageStatsContainer.style.display = settings.showTokenStats ? 'flex' : 'none';
}
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.f