// Claudeman App - Tab-based Terminal UI class ClaudemanApp { constructor() { this.sessions = new Map(); this.cases = []; this.currentRun = null; this.totalTokens = 0; this.eventSource = null; this.terminal = null; this.fitAddon = null; this.activeSessionId = null; this.respawnStatus = {}; this.respawnTimers = {}; // Track timed respawn timers this.terminalBuffers = new Map(); // Store terminal content per session this.editingSessionId = null; // Session being edited in options modal this.pendingCloseSessionId = null; // Session pending close confirmation this.screenSessions = []; // Screen sessions for process monitor // Terminal write batching this.pendingWrites = ''; this.writeFrameScheduled = false; this.init(); } init() { this.initTerminal(); this.loadFontSize(); this.connectSSE(); this.loadState(); this.loadQuickStartCases(); this.setupEventListeners(); } initTerminal() { this.terminal = new Terminal({ theme: { background: '#0d0d0d', foreground: '#e0e0e0', cursor: '#e0e0e0', cursorAccent: '#0d0d0d', selection: 'rgba(255, 255, 255, 0.3)', black: '#0d0d0d', red: '#ff6b6b', green: '#51cf66', yellow: '#ffd43b', blue: '#339af0', magenta: '#cc5de8', cyan: '#22b8cf', white: '#e0e0e0', brightBlack: '#495057', brightRed: '#ff8787', brightGreen: '#69db7c', brightYellow: '#ffe066', brightBlue: '#5c7cfa', brightMagenta: '#da77f2', brightCyan: '#66d9e8', brightWhite: '#ffffff', }, fontFamily: '"Fira Code", "Cascadia Code", "JetBrains Mono", "SF Mono", Monaco, monospace', fontSize: 14, lineHeight: 1.2, cursorBlink: true, cursorStyle: 'block', scrollback: 10000, allowTransparency: true, }); this.fitAddon = new FitAddon.FitAddon(); this.terminal.loadAddon(this.fitAddon); const container = document.getElementById('terminalContainer'); this.terminal.open(container); this.fitAddon.fit(); // Welcome message this.showWelcome(); // Handle resize window.addEventListener('resize', () => this.fitAddon && this.fitAddon.fit()); const resizeObserver = new ResizeObserver(() => { if (this.fitAddon) { this.fitAddon.fit(); if (this.activeSessionId) { const dims = this.fitAddon.proposeDimensions(); if (dims) { fetch(`/api/sessions/${this.activeSessionId}/resize`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cols: dims.cols, rows: dims.rows }) }); } } } }); resizeObserver.observe(container); // Handle keyboard input this.terminal.onData((data) => { if (this.activeSessionId) { fetch(`/api/sessions/${this.activeSessionId}/input`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ input: data }) }); } }); } showWelcome() { this.terminal.writeln('\x1b[1;36m Claudeman Terminal\x1b[0m'); this.terminal.writeln('\x1b[90m Click "Run Claude" or press Ctrl+Enter to begin\x1b[0m'); this.terminal.writeln(''); } batchTerminalWrite(data) { this.pendingWrites += data; if (!this.writeFrameScheduled) { this.writeFrameScheduled = true; requestAnimationFrame(() => { if (this.pendingWrites && this.terminal) { this.terminal.write(this.pendingWrites); this.pendingWrites = ''; } this.writeFrameScheduled = false; }); } } setupEventListeners() { // Use capture to handle before terminal document.addEventListener('keydown', (e) => { // Escape - close panels and modals if (e.key === 'Escape') { this.closeAllPanels(); this.closeHelp(); } // Ctrl/Cmd + ? - help if ((e.ctrlKey || e.metaKey) && (e.key === '?' || e.key === '/')) { e.preventDefault(); this.showHelp(); } // Ctrl/Cmd + Enter - quick start if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); this.quickStart(); } // Ctrl/Cmd + W - close active session if ((e.ctrlKey || e.metaKey) && e.key === 'w') { e.preventDefault(); this.killActiveSession(); } // Ctrl/Cmd + Tab - next session if ((e.ctrlKey || e.metaKey) && e.key === 'Tab') { e.preventDefault(); this.nextSession(); } // Ctrl/Cmd + K - kill all if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); this.killAllSessions(); } // Ctrl/Cmd + L - clear terminal if ((e.ctrlKey || e.metaKey) && e.key === 'l') { e.preventDefault(); this.clearTerminal(); } // Ctrl/Cmd + +/- - font size if ((e.ctrlKey || e.metaKey) && (e.key === '=' || e.key === '+')) { e.preventDefault(); this.increaseFontSize(); } if ((e.ctrlKey || e.metaKey) && e.key === '-') { e.preventDefault(); this.decreaseFontSize(); } }, true); // Use capture phase to handle before terminal } // ========== SSE Connection ========== connectSSE() { this.eventSource = new EventSource('/api/events'); this.eventSource.onopen = () => this.setConnectionStatus('connected'); this.eventSource.onerror = () => { this.setConnectionStatus('disconnected'); setTimeout(() => this.connectSSE(), 3000); }; this.eventSource.addEventListener('init', (e) => { this.handleInit(JSON.parse(e.data)); }); this.eventSource.addEventListener('session:created', (e) => { const data = JSON.parse(e.data); this.sessions.set(data.id, data); this.renderSessionTabs(); this.updateCost(); }); this.eventSource.addEventListener('session:updated', (e) => { const data = JSON.parse(e.data); this.sessions.set(data.id, data); this.renderSessionTabs(); this.updateCost(); // Update tokens display if this is the active session if (data.id === this.activeSessionId && data.tokens) { this.updateRespawnTokens(data.tokens.total); } }); this.eventSource.addEventListener('session:deleted', (e) => { const data = JSON.parse(e.data); this.sessions.delete(data.id); this.terminalBuffers.delete(data.id); if (this.activeSessionId === data.id) { this.activeSessionId = null; this.terminal.clear(); this.showWelcome(); } this.renderSessionTabs(); }); this.eventSource.addEventListener('session:terminal', (e) => { const data = JSON.parse(e.data); if (data.id === this.activeSessionId) { this.batchTerminalWrite(data.data); } }); this.eventSource.addEventListener('session:completion', (e) => { const data = JSON.parse(e.data); this.totalCost += data.cost || 0; this.updateCost(); if (data.id === this.activeSessionId) { this.terminal.writeln(''); this.terminal.writeln(`\x1b[1;32m Done (Cost: $${(data.cost || 0).toFixed(4)})\x1b[0m`); } }); this.eventSource.addEventListener('session:error', (e) => { const data = JSON.parse(e.data); if (data.id === this.activeSessionId) { this.terminal.writeln(`\x1b[1;31m Error: ${data.error}\x1b[0m`); } }); this.eventSource.addEventListener('session:exit', (e) => { const data = JSON.parse(e.data); const session = this.sessions.get(data.id); if (session) { session.status = 'stopped'; this.renderSessionTabs(); } }); this.eventSource.addEventListener('session:idle', (e) => { const data = JSON.parse(e.data); const session = this.sessions.get(data.id); if (session) { session.status = 'idle'; this.renderSessionTabs(); } }); this.eventSource.addEventListener('session:working', (e) => { const data = JSON.parse(e.data); const session = this.sessions.get(data.id); if (session) { session.status = 'busy'; this.renderSessionTabs(); } }); // Scheduled run events this.eventSource.addEventListener('scheduled:created', (e) => { this.currentRun = JSON.parse(e.data); this.showTimer(); }); this.eventSource.addEventListener('scheduled:updated', (e) => { this.currentRun = JSON.parse(e.data); this.updateTimer(); }); this.eventSource.addEventListener('scheduled:completed', (e) => { this.currentRun = JSON.parse(e.data); this.hideTimer(); this.showToast('Scheduled run completed!', 'success'); }); this.eventSource.addEventListener('scheduled:stopped', (e) => { this.currentRun = null; this.hideTimer(); }); // Respawn events this.eventSource.addEventListener('respawn:started', (e) => { const data = JSON.parse(e.data); this.respawnStatus[data.sessionId] = data.status; if (data.sessionId === this.activeSessionId) { this.showRespawnBanner(); } }); this.eventSource.addEventListener('respawn:stopped', (e) => { const data = JSON.parse(e.data); delete this.respawnStatus[data.sessionId]; if (data.sessionId === this.activeSessionId) { this.hideRespawnBanner(); } }); this.eventSource.addEventListener('respawn:stateChanged', (e) => { const data = JSON.parse(e.data); if (this.respawnStatus[data.sessionId]) { this.respawnStatus[data.sessionId].state = data.state; } if (data.sessionId === this.activeSessionId) { this.updateRespawnBanner(data.state); } }); this.eventSource.addEventListener('respawn:cycleStarted', (e) => { const data = JSON.parse(e.data); if (this.respawnStatus[data.sessionId]) { this.respawnStatus[data.sessionId].cycleCount = data.cycleNumber; } if (data.sessionId === this.activeSessionId) { document.getElementById('respawnCycleCount').textContent = data.cycleNumber; } }); this.eventSource.addEventListener('respawn:stepSent', (e) => { const data = JSON.parse(e.data); if (data.sessionId === this.activeSessionId) { document.getElementById('respawnStep').textContent = data.input; } }); // Respawn timer events this.eventSource.addEventListener('respawn:timerStarted', (e) => { const data = JSON.parse(e.data); this.respawnTimers[data.sessionId] = { endAt: data.endAt, startedAt: data.startedAt, durationMinutes: data.durationMinutes }; if (data.sessionId === this.activeSessionId) { this.showRespawnTimer(); } }); // Auto-clear event this.eventSource.addEventListener('session:autoClear', (e) => { const data = JSON.parse(e.data); if (data.sessionId === this.activeSessionId) { this.showToast(`Auto-cleared at ${data.tokens.toLocaleString()} tokens`, 'info'); this.updateRespawnTokens(0); } }); // Background task events this.eventSource.addEventListener('task:created', (e) => { const data = JSON.parse(e.data); this.renderSessionTabs(); if (data.sessionId === this.activeSessionId) { this.renderTaskPanel(); } }); this.eventSource.addEventListener('task:completed', (e) => { const data = JSON.parse(e.data); this.renderSessionTabs(); if (data.sessionId === this.activeSessionId) { this.renderTaskPanel(); } }); this.eventSource.addEventListener('task:failed', (e) => { const data = JSON.parse(e.data); this.renderSessionTabs(); if (data.sessionId === this.activeSessionId) { this.renderTaskPanel(); } }); this.eventSource.addEventListener('task:updated', (e) => { const data = JSON.parse(e.data); if (data.sessionId === this.activeSessionId) { this.renderTaskPanel(); } }); // Screen events this.eventSource.addEventListener('screen:created', (e) => { const screen = JSON.parse(e.data); this.screenSessions.push(screen); this.renderScreenSessions(); }); this.eventSource.addEventListener('screen:killed', (e) => { const data = JSON.parse(e.data); this.screenSessions = this.screenSessions.filter(s => s.sessionId !== data.sessionId); this.renderScreenSessions(); }); this.eventSource.addEventListener('screen:died', (e) => { const data = JSON.parse(e.data); this.screenSessions = this.screenSessions.filter(s => s.sessionId !== data.sessionId); this.renderScreenSessions(); this.showToast('Screen session died: ' + data.sessionId.slice(0, 8), 'warning'); }); this.eventSource.addEventListener('screen:statsUpdated', (e) => { this.screenSessions = JSON.parse(e.data); if (document.getElementById('processPanel').classList.contains('open')) { this.renderScreenSessions(); } }); } setConnectionStatus(status) { const dot = document.querySelector('.status-dot'); const text = document.querySelector('.status-text'); dot.className = 'status-dot ' + status; text.textContent = status === 'connected' ? 'Connected' : 'Disconnected'; } handleInit(data) { this.sessions.clear(); data.sessions.forEach(s => this.sessions.set(s.id, s)); if (data.respawnStatus) { this.respawnStatus = data.respawnStatus; } this.totalCost = data.sessions.reduce((sum, s) => sum + (s.totalCost || 0), 0); this.totalCost += data.scheduledRuns.reduce((sum, r) => sum + (r.totalCost || 0), 0); const activeRun = data.scheduledRuns.find(r => r.status === 'running'); if (activeRun) { this.currentRun = activeRun; this.showTimer(); } this.updateCost(); this.renderSessionTabs(); // Auto-select first session if any if (this.sessions.size > 0 && !this.activeSessionId) { const firstSession = this.sessions.values().next().value; this.selectSession(firstSession.id); } } async loadState() { try { const res = await fetch('/api/status'); const data = await res.json(); this.handleInit(data); } catch (err) { console.error('Failed to load state:', err); } } // ========== Session Tabs ========== renderSessionTabs() { const container = document.getElementById('sessionTabs'); // Build tabs HTML let html = ''; for (const [id, session] of this.sessions) { const isActive = id === this.activeSessionId; const status = session.status || 'idle'; const name = this.getSessionName(session); const mode = session.mode || 'claude'; const taskStats = session.taskStats || { running: 0, total: 0 }; const hasRunningTasks = taskStats.running > 0; html += `
${mode === 'shell' ? 'sh' : ''} ${this.escapeHtml(name)} ${hasRunningTasks ? `${taskStats.running}` : ''} ⚙ ×
`; } container.innerHTML = html; } getSessionName(session) { // Use custom name if set if (session.name) { return session.name; } // Fall back to directory name if (session.workingDir) { return session.workingDir.split('/').pop() || session.workingDir; } return session.id.slice(0, 8); } async selectSession(sessionId) { if (this.activeSessionId === sessionId) return; this.activeSessionId = sessionId; this.renderSessionTabs(); // Load terminal buffer for this session try { const res = await fetch(`/api/sessions/${sessionId}/terminal`); const data = await res.json(); this.terminal.clear(); if (data.terminalBuffer) { this.terminal.write(data.terminalBuffer); } // Send resize const dims = this.fitAddon.proposeDimensions(); if (dims) { fetch(`/api/sessions/${sessionId}/resize`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cols: dims.cols, rows: dims.rows }) }); } // Update respawn banner if (this.respawnStatus[sessionId]) { this.showRespawnBanner(); this.updateRespawnBanner(this.respawnStatus[sessionId].state); document.getElementById('respawnCycleCount').textContent = this.respawnStatus[sessionId].cycleCount || 0; } else { this.hideRespawnBanner(); } // Update task panel if open if (document.getElementById('taskPanel').classList.contains('open')) { this.renderTaskPanel(); } this.terminal.focus(); } catch (err) { console.error('Failed to load session terminal:', err); } } async closeSession(sessionId) { try { await fetch(`/api/sessions/${sessionId}`, { method: 'DELETE' }); this.sessions.delete(sessionId); this.terminalBuffers.delete(sessionId); if (this.activeSessionId === sessionId) { this.activeSessionId = null; // Select another session or show welcome if (this.sessions.size > 0) { const nextSession = this.sessions.values().next().value; this.selectSession(nextSession.id); } else { this.terminal.clear(); this.showWelcome(); } } this.renderSessionTabs(); } catch (err) { this.showToast('Failed to close session', 'error'); } } // Request confirmation before closing a session requestCloseSession(sessionId) { const session = this.sessions.get(sessionId); if (!session) return; this.pendingCloseSessionId = sessionId; // Show session name in confirmation dialog const name = this.getSessionName(session); const sessionNameEl = document.getElementById('closeConfirmSessionName'); sessionNameEl.textContent = name; document.getElementById('closeConfirmModal').classList.add('active'); } cancelCloseSession() { this.pendingCloseSessionId = null; document.getElementById('closeConfirmModal').classList.remove('active'); } async confirmCloseSession() { const sessionId = this.pendingCloseSessionId; this.cancelCloseSession(); if (sessionId) { await this.closeSession(sessionId); } } nextSession() { const ids = Array.from(this.sessions.keys()); if (ids.length <= 1) return; const currentIndex = ids.indexOf(this.activeSessionId); const nextIndex = (currentIndex + 1) % ids.length; this.selectSession(ids[nextIndex]); } // ========== Quick Start ========== async loadQuickStartCases() { try { const res = await fetch('/api/cases'); const cases = await res.json(); this.cases = cases; const select = document.getElementById('quickStartCase'); // Build options let options = ''; cases.forEach(c => { if (c.name !== 'testcase') { options += ``; } }); select.innerHTML = options; // Auto-select first case and update directory display if (cases.length > 0) { const firstCase = cases.find(c => c.name === 'testcase') || cases[0]; select.value = firstCase.name; this.updateDirDisplayForCase(firstCase.name); } // Update directory when case selection changes select.addEventListener('change', () => { this.updateDirDisplayForCase(select.value); }); } catch (err) { console.error('Failed to load cases:', err); } } async updateDirDisplayForCase(caseName) { try { const res = await fetch(`/api/cases/${caseName}`); const data = await res.json(); if (data.path) { document.getElementById('dirDisplay').textContent = data.path; document.getElementById('dirInput').value = data.path; } } catch (err) { document.getElementById('dirDisplay').textContent = caseName; } } async quickStart() { // Alias for backward compatibility return this.runClaude(); } async runClaude() { const caseName = document.getElementById('quickStartCase').value || 'testcase'; this.terminal.clear(); this.terminal.writeln(`\x1b[1;32m Starting Claude in ${caseName}...\x1b[0m`); this.terminal.writeln(''); try { const res = await fetch('/api/quick-start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ caseName }) }); const data = await res.json(); if (!data.success) throw new Error(data.error); this.activeSessionId = data.sessionId; this.loadQuickStartCases(); // Send resize const dims = this.fitAddon.proposeDimensions(); if (dims) { await fetch(`/api/sessions/${data.sessionId}/resize`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cols: dims.cols, rows: dims.rows }) }); } this.terminal.focus(); } catch (err) { this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`); } } async runShell() { const caseName = document.getElementById('quickStartCase').value || 'testcase'; this.terminal.clear(); this.terminal.writeln(`\x1b[1;33m Starting Shell in ${caseName}...\x1b[0m`); this.terminal.writeln(''); try { // Get the case path const caseRes = await fetch(`/api/cases/${caseName}`); const caseData = await caseRes.json(); const workingDir = caseData.path || process.cwd(); // Create session with shell mode const createRes = await fetch('/api/sessions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ workingDir, mode: 'shell' }) }); const createData = await createRes.json(); if (!createData.success) throw new Error(createData.error); const sessionId = createData.session.id; // Start shell await fetch(`/api/sessions/${sessionId}/shell`, { method: 'POST' }); this.activeSessionId = sessionId; // Send resize const dims = this.fitAddon.proposeDimensions(); if (dims) { await fetch(`/api/sessions/${sessionId}/resize`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cols: dims.cols, rows: dims.rows }) }); } this.terminal.focus(); } catch (err) { this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`); } } // ========== Directory Input ========== toggleDirInput() { const btn = document.querySelector('#dirDisplay').parentElement; const input = document.getElementById('dirInput'); if (input.classList.contains('hidden')) { input.classList.remove('hidden'); btn.style.display = 'none'; input.focus(); } } hideDirInput() { const btn = document.querySelector('#dirDisplay').parentElement; const input = document.getElementById('dirInput'); setTimeout(() => { input.classList.add('hidden'); btn.style.display = ''; const value = input.value.trim(); document.getElementById('dirDisplay').textContent = value || 'No directory'; }, 100); } // ========== Respawn Panel ========== toggleRespawnPanel() { const panel = document.getElementById('respawnPanel'); panel.classList.toggle('open'); // Show "Enable on Current" button if there's an active session const enableOnCurrentBtn = document.getElementById('enableOnCurrentBtn'); if (this.activeSessionId && this.sessions.has(this.activeSessionId)) { const session = this.sessions.get(this.activeSessionId); // Only show if session is running (has a PID) if (session.pid) { enableOnCurrentBtn.style.display = ''; } else { enableOnCurrentBtn.style.display = 'none'; } } else { enableOnCurrentBtn.style.display = 'none'; } } getRespawnConfig() { const updatePrompt = document.getElementById('respawnPrompt').value; const idleTimeout = parseInt(document.getElementById('respawnIdleTimeout').value) || 5; const stepDelay = parseInt(document.getElementById('respawnStepDelay').value) || 1; const sendClear = document.getElementById('respawnSendClear').checked; const sendInit = document.getElementById('respawnSendInit').checked; const durationStr = document.getElementById('respawnDuration').value; const durationMinutes = durationStr ? parseInt(durationStr) : null; const autoClearEnabled = document.getElementById('autoClearEnabled').checked; const autoClearThreshold = parseInt(document.getElementById('autoClearThreshold').value) || 100000; return { respawnConfig: { updatePrompt, idleTimeoutMs: idleTimeout * 1000, interStepDelayMs: stepDelay * 1000, sendClear, sendInit, }, durationMinutes, autoClearEnabled, autoClearThreshold }; } async startInteractiveWithRespawn() { this.toggleRespawnPanel(); const dir = document.getElementById('dirInput').value.trim(); const { respawnConfig, durationMinutes, autoClearEnabled, autoClearThreshold } = this.getRespawnConfig(); this.terminal.clear(); this.terminal.writeln('\x1b[1;32m Starting session with respawn...\x1b[0m'); if (durationMinutes) { this.terminal.writeln(`\x1b[90m Duration: ${durationMinutes} minutes\x1b[0m`); } this.terminal.writeln(''); try { // Create session const createRes = await fetch('/api/sessions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ workingDir: dir || undefined }) }); const createData = await createRes.json(); if (!createData.success) throw new Error(createData.error); const sessionId = createData.session.id; this.activeSessionId = sessionId; // Start interactive with respawn await fetch(`/api/sessions/${sessionId}/interactive-respawn`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ respawnConfig, durationMinutes }) }); // Set auto-clear if enabled if (autoClearEnabled) { await fetch(`/api/sessions/${sessionId}/auto-clear`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: true, threshold: autoClearThreshold }) }); } // Send resize const dims = this.fitAddon.proposeDimensions(); if (dims) { await fetch(`/api/sessions/${sessionId}/resize`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cols: dims.cols, rows: dims.rows }) }); } this.terminal.focus(); } catch (err) { this.terminal.writeln(`\x1b[1;31m Error: ${err.message}\x1b[0m`); } } async enableRespawnOnCurrent() { if (!this.activeSessionId) { this.showToast('No active session', 'warning'); return; } this.toggleRespawnPanel(); const { respawnConfig, durationMinutes, autoClearEnabled, autoClearThreshold } = this.getRespawnConfig(); try { // Enable respawn on existing session const res = await fetch(`/api/sessions/${this.activeSessionId}/respawn/enable`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ config: respawnConfig, durationMinutes }) }); const data = await res.json(); if (data.error) throw new Error(data.error); // Set auto-clear if enabled if (autoClearEnabled) { await fetch(`/api/sessions/${this.activeSessionId}/auto-clear`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled: true, threshold: autoClearThreshold }) }); } this.showToast('Respawn enabled on current session', 'success'); this.terminal.focus(); } catch (err) { this.showToast('Failed to enable respawn: ' + err.message, 'error'); } } showRespawnBanner() { document.getElementById('respawnBanner').style.display = 'flex'; // Also show timer if there's a timed respawn if (this.activeSessionId && this.respawnTimers[this.activeSessionId]) { this.showRespawnTimer(); } // Show tokens if session has token data const session = this.sessions.get(this.activeSessionId); if (session && session.tokens) { this.updateRespawnTokens(session.tokens.total); } } hideRespawnBanner() { document.getElementById('respawnBanner').style.display = 'none'; this.hideRespawnTimer(); } updateRespawnBanner(state) { document.getElementById('respawnState').textContent = state.replace(/_/g, ' '); } showRespawnTimer() { const timerEl = document.getElementById('respawnTimer'); timerEl.style.display = ''; this.updateRespawnTimer(); // Update every second if (this.respawnTimerInterval) clearInterval(this.respawnTimerInterval); this.respawnTimerInterval = setInterval(() => this.updateRespawnTimer(), 1000); } hideRespawnTimer() { document.getElementById('respawnTimer').style.display = 'none'; if (this.respawnTimerInterval) { clearInterval(this.respawnTimerInterval); this.respawnTimerInterval = null; } } updateRespawnTimer() { if (!this.activeSessionId || !this.respawnTimers[this.activeSessionId]) { this.hideRespawnTimer(); return; } const timer = this.respawnTimers[this.activeSessionId]; const now = Date.now(); const remaining = Math.max(0, timer.endAt - now); if (remaining <= 0) { document.getElementById('respawnTimer').textContent = 'Time up'; delete this.respawnTimers[this.activeSessionId]; this.hideRespawnTimer(); return; } document.getElementById('respawnTimer').textContent = this.formatTime(remaining); } updateRespawnTokens(totalTokens) { const tokensEl = document.getElementById('respawnTokens'); if (totalTokens > 0) { tokensEl.style.display = ''; tokensEl.textContent = `${(totalTokens / 1000).toFixed(1)}k tokens`; } else { tokensEl.style.display = 'none'; } } async stopRespawn() { if (!this.activeSessionId) return; try { await fetch(`/api/sessions/${this.activeSessionId}/respawn/stop`, { method: 'POST' }); delete this.respawnTimers[this.activeSessionId]; } catch (err) { this.showToast('Failed to stop respawn', 'error'); } } // ========== Kill Sessions ========== async killActiveSession() { if (!this.activeSessionId) { this.showToast('No active session', 'warning'); return; } await this.closeSession(this.activeSessionId); } async killAllSessions() { if (this.sessions.size === 0) return; if (!confirm(`Kill all ${this.sessions.size} session(s)?`)) return; try { await fetch('/api/sessions', { method: 'DELETE' }); this.sessions.clear(); this.terminalBuffers.clear(); this.activeSessionId = null; this.respawnStatus = {}; this.hideRespawnBanner(); this.renderSessionTabs(); this.terminal.clear(); this.showWelcome(); this.showToast('All sessions killed', 'success'); } catch (err) { this.showToast('Failed to kill sessions', 'error'); } } // ========== Terminal Controls ========== clearTerminal() { this.terminal.clear(); } async copyTerminal() { try { const buffer = this.terminal.buffer.active; let text = ''; for (let i = 0; i < buffer.length; i++) { const line = buffer.getLine(i); if (line) text += line.translateToString(true) + '\n'; } await navigator.clipboard.writeText(text.replace(/\n+$/, '\n')); this.showToast('Copied to clipboard', 'success'); } catch (err) { this.showToast('Failed to copy', 'error'); } } increaseFontSize() { const current = this.terminal.options.fontSize || 14; this.setFontSize(Math.min(current + 2, 24)); } decreaseFontSize() { const current = this.terminal.options.fontSize || 14; this.setFontSize(Math.max(current - 2, 10)); } setFontSize(size) { this.terminal.options.fontSize = size; document.getElementById('fontSizeDisplay').textContent = size; this.fitAddon.fit(); localStorage.setItem('claudeman-font-size', size); } loadFontSize() { const saved = localStorage.getItem('claudeman-font-size'); if (saved) { const size = parseInt(saved, 10); if (size >= 10 && size <= 24) { this.terminal.options.fontSize = size; document.getElementById('fontSizeDisplay').textContent = size; } } } // ========== Timer ========== showTimer() { document.getElementById('timerBanner').style.display = 'flex'; this.updateTimer(); this.timerInterval = setInterval(() => this.updateTimer(), 1000); } hideTimer() { document.getElementById('timerBanner').style.display = 'none'; if (this.timerInterval) { clearInterval(this.timerInterval); this.timerInterval = null; } } updateTimer() { if (!this.currentRun || this.currentRun.status !== 'running') return; const now = Date.now(); const remaining = Math.max(0, this.currentRun.endAt - now); const total = this.currentRun.endAt - this.currentRun.startedAt; const elapsed = now - this.currentRun.startedAt; const percent = Math.min(100, (elapsed / total) * 100); document.getElementById('timerValue').textContent = this.formatTime(remaining); document.getElementById('timerProgress').style.width = `${percent}%`; document.getElementById('timerMeta').textContent = `${this.currentRun.completedTasks} tasks | $${this.currentRun.totalCost.toFixed(2)}`; } async stopCurrentRun() { if (!this.currentRun) return; try { await fetch(`/api/scheduled/${this.currentRun.id}`, { method: 'DELETE' }); } catch (err) { this.showToast('Failed to stop run', 'error'); } } formatTime(ms) { const s = Math.floor(ms / 1000); const h = Math.floor(s / 3600); const m = Math.floor((s % 3600) / 60); const sec = s % 60; return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`; } // ========== Tokens ========== updateCost() { // Now updates tokens instead of cost this.updateTokens(); } updateTokens() { let total = 0; this.sessions.forEach(s => { if (s.tokens) { total += s.tokens.total || 0; } }); this.totalTokens = total; const display = total >= 1000 ? `${(total / 1000).toFixed(1)}k` : total; document.getElementById('headerTokens').textContent = `${display} tokens`; } // ========== Session Options Modal ========== openSessionOptions(sessionId) { const session = this.sessions.get(sessionId); if (!session) return; this.editingSessionId = sessionId; document.getElementById('sessionNameInput').value = session.name || ''; document.getElementById('sessionDirDisplay').textContent = session.workingDir || 'Unknown'; // Update respawn status display const respawnStatus = document.getElementById('sessionRespawnStatus'); const stopBtn = document.getElementById('sessionStopRespawnBtn'); const configBtn = document.getElementById('sessionConfigRespawnBtn'); if (this.respawnStatus[sessionId]) { respawnStatus.classList.add('active'); respawnStatus.querySelector('.respawn-status-text').textContent = `Active (${this.respawnStatus[sessionId].state || 'running'})`; stopBtn.style.display = ''; configBtn.textContent = 'Reconfigure'; } else { respawnStatus.classList.remove('active'); respawnStatus.querySelector('.respawn-status-text').textContent = 'Not active'; stopBtn.style.display = 'none'; configBtn.textContent = 'Configure Respawn'; } // Only show respawn section for claude mode sessions with a running process const respawnSection = document.getElementById('sessionRespawnSection'); if (session.mode === 'claude' && session.pid) { respawnSection.style.display = ''; } else { respawnSection.style.display = 'none'; } document.getElementById('sessionOptionsModal').classList.add('active'); // Focus the name input setTimeout(() => document.getElementById('sessionNameInput').focus(), 100); } async stopRespawnFromOptions() { if (!this.editingSessionId) return; try { await fetch(`/api/sessions/${this.editingSessionId}/respawn/stop`, { method: 'POST' }); delete this.respawnTimers[this.editingSessionId]; // Update the modal display const respawnStatus = document.getElementById('sessionRespawnStatus'); respawnStatus.classList.remove('active'); respawnStatus.querySelector('.respawn-status-text').textContent = 'Not active'; document.getElementById('sessionStopRespawnBtn').style.display = 'none'; document.getElementById('sessionConfigRespawnBtn').textContent = 'Configure Respawn'; this.showToast('Respawn stopped', 'success'); } catch (err) { this.showToast('Failed to stop respawn', 'error'); } } closeSessionOptions() { this.editingSessionId = null; document.getElementById('sessionOptionsModal').classList.remove('active'); } async saveSessionOptions() { if (!this.editingSessionId) return; const name = document.getElementById('sessionNameInput').value.trim(); try { const res = await fetch(`/api/sessions/${this.editingSessionId}/name`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name }) }); const data = await res.json(); if (data.error) throw new Error(data.error); this.showToast('Session renamed', 'success'); this.closeSessionOptions(); } catch (err) { this.showToast('Failed to rename: ' + err.message, 'error'); } } // Inline rename on right-click startInlineRename(sessionId) { const session = this.sessions.get(sessionId); if (!session) return; const tabName = document.querySelector(`.tab-name[data-session-id="${sessionId}"]`); if (!tabName) return; const currentName = this.getSessionName(session); const input = document.createElement('input'); input.type = 'text'; input.value = session.name || ''; input.placeholder = currentName; input.className = 'tab-rename-input'; input.style.cssText = 'width: 80px; font-size: 0.75rem; padding: 2px 4px; background: var(--bg-input); border: 1px solid var(--accent); border-radius: 3px; color: var(--text); outline: none;'; const originalContent = tabName.textContent; tabName.textContent = ''; tabName.appendChild(input); input.focus(); input.select(); const finishRename = async () => { const newName = input.value.trim(); tabName.textContent = newName || originalContent; if (newName && newName !== session.name) { try { await fetch(`/api/sessions/${sessionId}/name`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: newName }) }); } catch (err) { tabName.textContent = originalContent; this.showToast('Failed to rename', 'error'); } } }; input.addEventListener('blur', finishRename); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); input.blur(); } else if (e.key === 'Escape') { input.value = ''; input.blur(); } }); } // ========== App Settings Modal ========== openAppSettings() { // Load current settings const settings = this.loadAppSettingsFromStorage(); document.getElementById('appSettingsClaudeMdPath').value = settings.defaultClaudeMdPath || ''; document.getElementById('appSettingsDefaultDir').value = settings.defaultWorkingDir || ''; document.getElementById('appSettingsModal').classList.add('active'); } closeAppSettings() { document.getElementById('appSettingsModal').classList.remove('active'); } async saveAppSettings() { const settings = { defaultClaudeMdPath: document.getElementById('appSettingsClaudeMdPath').value.trim(), defaultWorkingDir: document.getElementById('appSettingsDefaultDir').value.trim(), }; // Save to localStorage localStorage.setItem('claudeman-app-settings', JSON.stringify(settings)); // Also save to server try { await fetch('/api/settings', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(settings) }); this.showToast('Settings saved', 'success'); } catch (err) { // Server save failed but localStorage succeeded this.showToast('Settings saved locally', 'warning'); } this.closeAppSettings(); } loadAppSettingsFromStorage() { try { const saved = localStorage.getItem('claudeman-app-settings'); if (saved) { return JSON.parse(saved); } } catch (err) { console.error('Failed to load app settings:', err); } return {}; } async loadAppSettingsFromServer() { try { const res = await fetch('/api/settings'); if (res.ok) { const settings = await res.json(); // Merge with localStorage (server takes precedence) const localSettings = this.loadAppSettingsFromStorage(); const merged = { ...localSettings, ...settings }; localStorage.setItem('claudeman-app-settings', JSON.stringify(merged)); return merged; } } catch (err) { console.error('Failed to load settings from server:', err); } return this.loadAppSettingsFromStorage(); } // ========== Help Modal ========== showHelp() { document.getElementById('helpModal').classList.add('active'); } closeHelp() { document.getElementById('helpModal').classList.remove('active'); } closeAllPanels() { this.closeSessionOptions(); this.closeAppSettings(); this.cancelCloseSession(); document.getElementById('respawnPanel').classList.remove('open'); document.getElementById('monitorPanel').classList.remove('open'); } // ========== Monitor Panel (combined Screen Sessions + Background Tasks) ========== async toggleMonitorPanel() { const panel = document.getElementById('monitorPanel'); panel.classList.toggle('open'); if (panel.classList.contains('open')) { // Load screens and start stats collection await this.loadScreens(); await fetch('/api/screens/stats/start', { method: 'POST' }); this.renderTaskPanel(); } else { // Stop stats collection when panel is closed await fetch('/api/screens/stats/stop', { method: 'POST' }); } } // Legacy alias for task panel toggle (used by session tab badge) toggleTaskPanel() { this.toggleMonitorPanel(); } renderTaskPanel() { const session = this.sessions.get(this.activeSessionId); const body = document.getElementById('backgroundTasksBody'); const stats = document.getElementById('taskPanelStats'); if (!session || !session.taskTree || session.taskTree.length === 0) { body.innerHTML = '
No background tasks
'; stats.textContent = '0 tasks'; return; } const taskStats = session.taskStats || { running: 0, completed: 0, failed: 0, total: 0 }; stats.textContent = `${taskStats.running} running, ${taskStats.completed} done`; // Render task tree recursively const renderTask = (task, allTasks) => { const statusIcon = task.status === 'running' ? '' : task.status === 'completed' ? '✓' : '✗'; const duration = task.endTime ? `${((task.endTime - task.startTime) / 1000).toFixed(1)}s` : `${((Date.now() - task.startTime) / 1000).toFixed(0)}s...`; let childrenHtml = ''; if (task.children && task.children.length > 0) { childrenHtml = '
'; for (const childId of task.children) { // Find child task in allTasks map const childTask = allTasks.find(t => t.id === childId); if (childTask) { childrenHtml += `
${renderTask(childTask, allTasks)}
`; } } childrenHtml += '
'; } return `
${statusIcon}
${this.escapeHtml(task.description)}
${task.subagentType} ${duration}
${childrenHtml} `; }; // Flatten all tasks for lookup const allTasks = this.flattenTaskTree(session.taskTree); // Render only root tasks (those without parents or with null parentId) let html = '
'; for (const task of session.taskTree) { html += `
${renderTask(task, allTasks)}
`; } html += '
'; body.innerHTML = html; } flattenTaskTree(tasks, result = []) { for (const task of tasks) { result.push(task); // Children are stored as IDs, not nested objects in taskTree // The task tree from server already has the structure we need } return result; } // ========== Screen Sessions (in Monitor Panel) ========== async loadScreens() { try { const res = await fetch('/api/screens'); const data = await res.json(); this.screenSessions = data.screens || []; this.renderScreenSessions(); } catch (err) { console.error('Failed to load screens:', err); } } renderScreenSessions() { const body = document.getElementById('screenSessionsBody'); if (!this.screenSessions || this.screenSessions.length === 0) { body.innerHTML = '
No screen sessions
'; return; } let html = ''; for (const screen of this.screenSessions) { const stats = screen.stats || { memoryMB: 0, cpuPercent: 0, childCount: 0 }; const modeClass = screen.mode === 'shell' ? 'shell' : ''; html += `
${screen.mode}
${this.escapeHtml(screen.screenName)}
${stats.memoryMB}MB ${stats.cpuPercent}% ${stats.childCount} children PID: ${screen.pid}
`; } body.innerHTML = html; } async killScreen(sessionId) { if (!confirm('Kill this screen session?')) return; try { await fetch(`/api/screens/${sessionId}`, { method: 'DELETE' }); this.screenSessions = this.screenSessions.filter(s => s.sessionId !== sessionId); this.renderScreenSessions(); this.showToast('Screen killed', 'success'); } catch (err) { this.showToast('Failed to kill screen', 'error'); } } async reconcileScreens() { try { const res = await fetch('/api/screens/reconcile', { method: 'POST' }); const data = await res.json(); if (data.dead && data.dead.length > 0) { this.showToast(`Found ${data.dead.length} dead screen(s)`, 'warning'); await this.loadScreens(); } else { this.showToast('All screens are alive', 'success'); } } catch (err) { this.showToast('Failed to reconcile screens', 'error'); } } // ========== Toast ========== showToast(message, type = 'info') { const toast = document.createElement('div'); toast.className = `toast toast-${type}`; toast.textContent = message; let container = document.querySelector('.toast-container'); if (!container) { container = document.createElement('div'); container.className = 'toast-container'; document.body.appendChild(container); } container.appendChild(toast); requestAnimationFrame(() => toast.classList.add('show')); setTimeout(() => { toast.classList.remove('show'); setTimeout(() => toast.remove(), 200); }, 3000); } // ========== Utility ========== escapeHtml(text) { if (!text) return ''; const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } } // Initialize const app = new ClaudemanApp();