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);
}
}