diff --git a/src/session.ts b/src/session.ts index 6cbc9358..a14ce761 100644 --- a/src/session.ts +++ b/src/session.ts @@ -26,6 +26,10 @@ const LINE_BUFFER_FLUSH_INTERVAL = 100; // ^[[I (focus in), ^[[O (focus out), and the enable/disable sequences const FOCUS_ESCAPE_FILTER = /\x1b\[\?1004[hl]|\x1b\[[IO]/g; +// Pre-compiled regex patterns for performance (avoid re-compilation on each call) +const ANSI_ESCAPE_PATTERN = /\x1b\[[0-9;]*m/g; +const TOKEN_PATTERN = /(\d+(?:\.\d+)?)\s*([kKmM])?\s*tokens/; + export interface ClaudeMessage { type: 'system' | 'assistant' | 'user' | 'result'; subtype?: string; @@ -658,8 +662,8 @@ export class Session extends EventEmitter { for (const line of lines) { const trimmed = line.trim(); - // Remove ANSI escape codes for JSON parsing - const cleanLine = trimmed.replace(/\x1b\[[0-9;]*m/g, ''); + // Remove ANSI escape codes for JSON parsing (use pre-compiled pattern) + const cleanLine = trimmed.replace(ANSI_ESCAPE_PATTERN, ''); if (cleanLine.startsWith('{') && cleanLine.endsWith('}')) { try { @@ -716,12 +720,12 @@ export class Session extends EventEmitter { // Parse token count from Claude's status line in interactive mode // Matches patterns like "123.4k tokens", "5234 tokens", "1.2M tokens" private parseTokensFromStatusLine(data: string): void { - // Remove ANSI escape codes for cleaner parsing - const cleanData = data.replace(/\x1b\[[0-9;]*m/g, ''); + // Remove ANSI escape codes for cleaner parsing (use pre-compiled pattern) + const cleanData = data.replace(ANSI_ESCAPE_PATTERN, ''); // Match patterns: "123.4k tokens", "5234 tokens", "1.2M tokens" // The status line typically shows total tokens like "1.2k tokens" near the prompt - const tokenMatch = cleanData.match(/(\d+(?:\.\d+)?)\s*([kKmM])?\s*tokens/); + const tokenMatch = cleanData.match(TOKEN_PATTERN); if (tokenMatch) { let tokenCount = parseFloat(tokenMatch[1]); @@ -863,10 +867,13 @@ export class Session extends EventEmitter { this._currentTaskId = null; // Kill the associated screen session if requested - if (this._screenSession && this._screenManager && killScreen) { + if (killScreen && this._screenManager) { + // Try to kill screen even if _screenSession is not set (e.g., restored sessions) try { - await this._screenManager.killScreen(this.id); - console.log('[Session] Killed screen session:', this._screenSession.screenName); + const killed = await this._screenManager.killScreen(this.id); + if (killed) { + console.log('[Session] Killed screen session for:', this.id); + } } catch (err) { console.error('[Session] Failed to kill screen session:', err); } diff --git a/src/web/public/app.js b/src/web/public/app.js index 507c7748..463ff864 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -29,9 +29,20 @@ class ClaudemanApp { // System stats polling this.systemStatsInterval = null; + // DOM element cache for performance (avoid repeated getElementById calls) + this._elemCache = {}; + this.init(); } + // Cached element getter - avoids repeated DOM queries + $(id) { + if (!this._elemCache[id]) { + this._elemCache[id] = document.getElementById(id); + } + return this._elemCache[id]; + } + init() { this.initTerminal(); this.loadFontSize(); @@ -92,24 +103,36 @@ class ClaudemanApp { // Welcome message this.showWelcome(); - // Handle resize - window.addEventListener('resize', () => this.fitAddon && this.fitAddon.fit()); + // Handle resize with throttling for performance + this._resizeTimeout = null; + this._lastResizeDims = null; - 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 }) - }); + const throttledResize = () => { + if (this._resizeTimeout) return; + this._resizeTimeout = setTimeout(() => { + this._resizeTimeout = null; + if (this.fitAddon) { + this.fitAddon.fit(); + if (this.activeSessionId) { + const dims = this.fitAddon.proposeDimensions(); + // Only send resize if dimensions actually changed + if (dims && (!this._lastResizeDims || + dims.cols !== this._lastResizeDims.cols || + dims.rows !== this._lastResizeDims.rows)) { + this._lastResizeDims = { cols: dims.cols, rows: dims.rows }; + fetch(`/api/sessions/${this.activeSessionId}/resize`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cols: dims.cols, rows: dims.rows }) + }); + } } } - } - }); + }, 100); // Throttle to 100ms + }; + + window.addEventListener('resize', throttledResize); + const resizeObserver = new ResizeObserver(throttledResize); resizeObserver.observe(container); // Handle keyboard input @@ -291,19 +314,23 @@ class ClaudemanApp { this.eventSource.addEventListener('session:idle', (e) => { const data = JSON.parse(e.data); + console.log('[DEBUG] session:idle event for:', data.id); const session = this.sessions.get(data.id); if (session) { session.status = 'idle'; this.renderSessionTabs(); + this.sendPendingCtrlL(data.id); } }); this.eventSource.addEventListener('session:working', (e) => { const data = JSON.parse(e.data); + console.log('[DEBUG] session:working event for:', data.id); const session = this.sessions.get(data.id); if (session) { session.status = 'busy'; this.renderSessionTabs(); + this.sendPendingCtrlL(data.id); } }); @@ -512,10 +539,10 @@ class ClaudemanApp { } _renderSessionTabsImmediate() { - const container = document.getElementById('sessionTabs'); + const container = this.$('sessionTabs'); - // Build tabs HTML - let html = ''; + // Build tabs HTML using array for better string concatenation performance + const parts = []; for (const [id, session] of this.sessions) { const isActive = id === this.activeSessionId; const status = session.status || 'idle'; @@ -524,19 +551,17 @@ class ClaudemanApp { const taskStats = session.taskStats || { running: 0, total: 0 }; const hasRunningTasks = taskStats.running > 0; - html += ` -
+ parts.push(`
${mode === 'shell' ? 'sh' : ''} ${this.escapeHtml(name)} ${hasRunningTasks ? `${taskStats.running}` : ''} ⚙ × -
- `; +
`); } - container.innerHTML = html; + container.innerHTML = parts.join(''); } getSessionName(session) { @@ -597,7 +622,8 @@ class ClaudemanApp { } // Update task panel if open - if (document.getElementById('taskPanel').classList.contains('open')) { + const taskPanel = document.getElementById('taskPanel'); + if (taskPanel && taskPanel.classList.contains('open')) { this.renderTaskPanel(); } @@ -824,12 +850,16 @@ class ClaudemanApp { // Send resize to the new session const dims = this.fitAddon.proposeDimensions(); if (dims) { - fetch(`/api/sessions/${firstSessionId}/resize`, { + await fetch(`/api/sessions/${firstSessionId}/resize`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cols: dims.cols, rows: dims.rows }) }); } + // Mark this session as needing Ctrl+L fix once Claude is running + this.pendingCtrlL = this.pendingCtrlL || new Set(); + this.pendingCtrlL.add(firstSessionId); + console.log('[DEBUG] Marked session for pending Ctrl+L:', firstSessionId); this.loadQuickStartCases(); } @@ -929,7 +959,7 @@ class ClaudemanApp { // ========== Respawn Banner ========== showRespawnBanner() { - document.getElementById('respawnBanner').style.display = 'flex'; + this.$('respawnBanner').style.display = 'flex'; // Also show timer if there's a timed respawn if (this.activeSessionId && this.respawnTimers[this.activeSessionId]) { this.showRespawnTimer(); @@ -942,16 +972,16 @@ class ClaudemanApp { } hideRespawnBanner() { - document.getElementById('respawnBanner').style.display = 'none'; + this.$('respawnBanner').style.display = 'none'; this.hideRespawnTimer(); } updateRespawnBanner(state) { - document.getElementById('respawnState').textContent = state.replace(/_/g, ' '); + this.$('respawnState').textContent = state.replace(/_/g, ' '); } showRespawnTimer() { - const timerEl = document.getElementById('respawnTimer'); + const timerEl = this.$('respawnTimer'); timerEl.style.display = ''; this.updateRespawnTimer(); // Update every second @@ -960,7 +990,7 @@ class ClaudemanApp { } hideRespawnTimer() { - document.getElementById('respawnTimer').style.display = 'none'; + this.$('respawnTimer').style.display = 'none'; if (this.respawnTimerInterval) { clearInterval(this.respawnTimerInterval); this.respawnTimerInterval = null; @@ -978,17 +1008,17 @@ class ClaudemanApp { const remaining = Math.max(0, timer.endAt - now); if (remaining <= 0) { - document.getElementById('respawnTimer').textContent = 'Time up'; + this.$('respawnTimer').textContent = 'Time up'; delete this.respawnTimers[this.activeSessionId]; this.hideRespawnTimer(); return; } - document.getElementById('respawnTimer').textContent = this.formatTime(remaining); + this.$('respawnTimer').textContent = this.formatTime(remaining); } updateRespawnTokens(totalTokens) { - const tokensEl = document.getElementById('respawnTokens'); + const tokensEl = this.$('respawnTokens'); if (totalTokens > 0) { tokensEl.style.display = ''; tokensEl.textContent = `${(totalTokens / 1000).toFixed(1)}k tokens`; @@ -1044,6 +1074,40 @@ class ClaudemanApp { this.terminal.clear(); } + // Send Ctrl+L to fix display for newly created sessions once Claude is running + sendPendingCtrlL(sessionId) { + console.log('[DEBUG] sendPendingCtrlL called for:', sessionId, 'pending:', this.pendingCtrlL ? [...this.pendingCtrlL] : 'none'); + if (!this.pendingCtrlL || !this.pendingCtrlL.has(sessionId)) { + console.log('[DEBUG] No pending Ctrl+L for this session'); + return; + } + this.pendingCtrlL.delete(sessionId); + + // Only send if this is the active session + if (sessionId !== this.activeSessionId) { + console.log('[DEBUG] Not active session, skipping Ctrl+L'); + return; + } + + console.log('[DEBUG] Sending resize + Ctrl+L for session:', sessionId); + // Send resize + Ctrl+L to fix the display + 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 }) + }).then(() => { + console.log('[DEBUG] Resize sent, now sending Ctrl+L'); + fetch(`/api/sessions/${sessionId}/input`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ input: '\x0c' }) + }); + }); + } + } + async copyTerminal() { try { const buffer = this.terminal.buffer.active; @@ -1151,7 +1215,7 @@ class ClaudemanApp { }); this.totalTokens = total; const display = total >= 1000 ? `${(total / 1000).toFixed(1)}k` : total; - document.getElementById('headerTokens').textContent = `${display} tokens`; + this.$('headerTokens').textContent = `${display} tokens`; } // ========== Session Options Modal ========== @@ -1813,18 +1877,24 @@ class ClaudemanApp { // ========== Toast ========== + // Cached toast container for performance + _toastContainer = null; + 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); + // Cache toast container reference + if (!this._toastContainer) { + this._toastContainer = document.querySelector('.toast-container'); + if (!this._toastContainer) { + this._toastContainer = document.createElement('div'); + this._toastContainer.className = 'toast-container'; + document.body.appendChild(this._toastContainer); + } } - container.appendChild(toast); + this._toastContainer.appendChild(toast); requestAnimationFrame(() => toast.classList.add('show')); @@ -1857,10 +1927,10 @@ class ClaudemanApp { } updateSystemStatsDisplay(stats) { - const cpuEl = document.getElementById('statCpu'); - const cpuBar = document.getElementById('statCpuBar'); - const memEl = document.getElementById('statMem'); - const memBar = document.getElementById('statMemBar'); + const cpuEl = this.$('statCpu'); + const cpuBar = this.$('statCpuBar'); + const memEl = this.$('statMem'); + const memBar = this.$('statMemBar'); if (cpuEl && cpuBar) { cpuEl.textContent = `${stats.cpu}%`; @@ -1896,11 +1966,19 @@ class ClaudemanApp { // ========== Utility ========== + // Pre-compiled HTML escape map for performance (avoids DOM element creation) + static _htmlEscapeMap = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + static _htmlEscapePattern = /[&<>"']/g; + escapeHtml(text) { if (!text) return ''; - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; + return text.replace(ClaudemanApp._htmlEscapePattern, char => ClaudemanApp._htmlEscapeMap[char]); } } diff --git a/src/web/server.ts b/src/web/server.ts index 4ffc4afe..9fea3d08 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -1160,9 +1160,20 @@ export class WebServer extends EventEmitter { } } + // Optimized: send pre-formatted SSE message to a client + private sendSSEPreformatted(reply: FastifyReply, message: string): void { + try { + reply.raw.write(message); + } catch { + this.sseClients.delete(reply); + } + } + private broadcast(event: string, data: unknown): void { + // Performance optimization: serialize JSON once for all clients + const message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; for (const client of this.sseClients) { - this.sendSSE(client, event, data); + this.sendSSEPreformatted(client, message); } }