claude-code-web
Version:
Web-based interface for Claude Code CLI accessible via browser
1,224 lines (1,049 loc) • 87.5 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;
// 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">×</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