diff --git a/src/session.ts b/src/session.ts index 8958ff3a..c26a22cd 100644 --- a/src/session.ts +++ b/src/session.ts @@ -49,11 +49,15 @@ export interface SessionEvents { autoClear: (data: { tokens: number; threshold: number }) => void; } +export type SessionMode = 'claude' | 'shell'; + export class Session extends EventEmitter { readonly id: string; readonly workingDir: string; readonly createdAt: number; + readonly mode: SessionMode; + private _name: string; private ptyProcess: pty.IPty | null = null; private _pid: number | null = null; private _status: SessionStatus = 'idle'; @@ -81,11 +85,13 @@ export class Session extends EventEmitter { private _autoClearEnabled: boolean = false; private _isClearing: boolean = false; // Prevent recursive clearing - constructor(config: Partial & { workingDir: string }) { + constructor(config: Partial & { workingDir: string; mode?: SessionMode; name?: string }) { super(); this.id = config.id || uuidv4(); this.workingDir = config.workingDir; this.createdAt = config.createdAt || Date.now(); + this.mode = config.mode || 'claude'; + this._name = config.name || ''; this._lastActivityAt = this.createdAt; // Initialize task tracker and forward events @@ -185,6 +191,14 @@ export class Session extends EventEmitter { return this._autoClearEnabled; } + get name(): string { + return this._name; + } + + set name(value: string) { + this._name = value; + } + setAutoClear(enabled: boolean, threshold?: number): void { this._autoClearEnabled = enabled; if (threshold !== undefined) { @@ -219,6 +233,8 @@ export class Session extends EventEmitter { toDetailedState() { return { ...this.toState(), + name: this._name, + mode: this.mode, claudeSessionId: this._claudeSessionId, totalCost: this._totalCost, textOutput: this._textOutput, @@ -330,6 +346,65 @@ export class Session extends EventEmitter { }); } + // Start a plain shell session (bash/zsh without Claude) + async startShell(): Promise { + if (this.ptyProcess) { + throw new Error('Session already has a running process'); + } + + this._status = 'busy'; + this._terminalBuffer = ''; + this._outputBuffer = ''; + this._textOutput = ''; + this._errorBuffer = ''; + this._messages = []; + this._lineBuffer = ''; + this._lastActivityAt = Date.now(); + + // Use user's default shell or bash + const shell = process.env.SHELL || '/bin/bash'; + console.log('[Session] Starting shell session with:', shell); + + this.ptyProcess = pty.spawn(shell, [], { + name: 'xterm-256color', + cols: 120, + rows: 40, + cwd: this.workingDir, + env: { ...process.env, TERM: 'xterm-256color' }, + }); + + this._pid = this.ptyProcess.pid; + console.log('[Session] Shell PTY spawned with PID:', this._pid); + + this.ptyProcess.onData((data: string) => { + this._terminalBuffer += data; + this._lastActivityAt = Date.now(); + + // Trim buffer if it exceeds max size + if (this._terminalBuffer.length > MAX_TERMINAL_BUFFER_SIZE) { + this._terminalBuffer = this._terminalBuffer.slice(-TERMINAL_BUFFER_TRIM_SIZE); + } + + this.emit('terminal', data); + this.emit('output', data); + }); + + this.ptyProcess.onExit(({ exitCode }) => { + console.log('[Session] Shell PTY exited with code:', exitCode); + this.ptyProcess = null; + this._pid = null; + this._status = 'idle'; + this.emit('exit', exitCode); + }); + + // Mark as idle after a short delay (shell is ready) + setTimeout(() => { + this._status = 'idle'; + this._isWorking = false; + this.emit('idle'); + }, 500); + } + async runPrompt(prompt: string): Promise<{ result: string; cost: number }> { return new Promise((resolve, reject) => { if (this.ptyProcess) { diff --git a/src/web/public/app.js b/src/web/public/app.js index a36ffb5e..b4ff5f97 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -4,7 +4,7 @@ class ClaudemanApp { this.sessions = new Map(); this.cases = []; this.currentRun = null; - this.totalCost = 0; + this.totalTokens = 0; this.eventSource = null; this.terminal = null; this.fitAddon = null; @@ -12,6 +12,7 @@ class ClaudemanApp { 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 // Terminal write batching this.pendingWrites = ''; @@ -459,14 +460,17 @@ class ClaudemanApp { 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}` : ''} + ⚙ ×
`; @@ -483,6 +487,11 @@ class ClaudemanApp { } 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; } @@ -594,10 +603,15 @@ class ClaudemanApp { } 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 session in ${caseName}...\x1b[0m`); + this.terminal.writeln(`\x1b[1;32m Starting Claude in ${caseName}...\x1b[0m`); this.terminal.writeln(''); try { @@ -629,6 +643,53 @@ class ClaudemanApp { } } + 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`); + } + } + // ========== New Session ========== createNewSession() { @@ -1052,12 +1113,66 @@ class ClaudemanApp { return `${h.toString().padStart(2, '0')}:${m.toString().padStart(2, '0')}:${sec.toString().padStart(2, '0')}`; } - // ========== Cost ========== + // ========== Tokens ========== updateCost() { - let cost = this.totalCost; - this.sessions.forEach(s => cost = Math.max(cost, s.totalCost || 0)); - document.getElementById('headerCost').textContent = `$${cost.toFixed(2)}`; + // 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('sessionModeDisplay').textContent = session.mode === 'shell' ? 'Shell' : 'Claude'; + document.getElementById('sessionDirDisplay').textContent = session.workingDir || 'Unknown'; + document.getElementById('sessionOptionsModal').classList.add('active'); + + // Focus the name input + setTimeout(() => document.getElementById('sessionNameInput').focus(), 100); + } + + 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'); + } } // ========== Help Modal ========== @@ -1071,6 +1186,7 @@ class ClaudemanApp { } closeAllPanels() { + this.closeSessionOptions(); document.getElementById('respawnPanel').classList.remove('open'); document.getElementById('newSessionPanel').classList.remove('open'); document.getElementById('taskPanel').classList.remove('open'); diff --git a/src/web/public/index.html b/src/web/public/index.html index 9a28e147..3c7dd325 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -32,7 +32,7 @@ Connecting -
$0.00
+
0 tokens
@@ -65,10 +65,13 @@