feat: add session rename, shell mode, and UI improvements

- Add session name property with rename API endpoint
- Add shell session mode (plain bash/zsh without Claude)
- Add gear icon on session tabs for options (rename)
- Add mode indicator on tabs for shell sessions
- Rename "Quick Start" button to "Run Claude"
- Add "Shell" button to start plain shell sessions
- Replace cost counter with total token counter
- Add session options modal for renaming

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-19 01:26:38 +01:00
co-authored by Claude Opus 4.5
parent 4903c45ec9
commit dc5c874b11
5 changed files with 331 additions and 15 deletions
+76 -1
View File
@@ -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<SessionConfig> & { workingDir: string }) {
constructor(config: Partial<SessionConfig> & { 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<void> {
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) {
+122 -6
View File
@@ -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 += `
<div class="session-tab ${isActive ? 'active' : ''}" data-id="${id}" onclick="app.selectSession('${id}')">
<span class="tab-status ${status}"></span>
${mode === 'shell' ? '<span class="tab-mode shell">sh</span>' : ''}
<span class="tab-name">${this.escapeHtml(name)}</span>
${hasRunningTasks ? `<span class="tab-badge" onclick="event.stopPropagation(); app.toggleTaskPanel()">${taskStats.running}</span>` : ''}
<span class="tab-gear" onclick="event.stopPropagation(); app.openSessionOptions('${id}')" title="Session options">&#x2699;</span>
<span class="tab-close" onclick="event.stopPropagation(); app.closeSession('${id}')">&times;</span>
</div>
`;
@@ -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');
+36 -4
View File
@@ -32,7 +32,7 @@
<span class="status-dot"></span>
<span class="status-text">Connecting</span>
</div>
<div class="header-cost" id="headerCost">$0.00</div>
<div class="header-tokens" id="headerTokens" title="Total tokens across all sessions">0 tokens</div>
</div>
</header>
@@ -65,10 +65,13 @@
<!-- Bottom Toolbar -->
<footer class="toolbar">
<div class="toolbar-left">
<!-- Quick Start -->
<!-- Run Claude -->
<div class="toolbar-group">
<button class="btn-toolbar btn-primary" onclick="app.quickStart()" title="Quick Start (Ctrl+Enter)">
Quick Start
<button class="btn-toolbar btn-primary" onclick="app.runClaude()" title="Run Claude (Ctrl+Enter)">
Run Claude
</button>
<button class="btn-toolbar" onclick="app.runShell()" title="Run Shell">
Shell
</button>
<select id="quickStartCase" class="toolbar-select" title="Select case">
<option value="testcase">testcase</option>
@@ -241,6 +244,35 @@
</div>
</div>
<!-- Session Options Modal -->
<div class="modal" id="sessionOptionsModal">
<div class="modal-backdrop" onclick="app.closeSessionOptions()"></div>
<div class="modal-content modal-sm">
<div class="modal-header">
<h3>Session Options</h3>
<button class="modal-close" onclick="app.closeSessionOptions()">&times;</button>
</div>
<div class="modal-body">
<div class="form-row">
<label>Session Name</label>
<input type="text" id="sessionNameInput" placeholder="Enter session name...">
</div>
<div class="form-row">
<label>Mode</label>
<div class="session-mode-display" id="sessionModeDisplay">Claude</div>
</div>
<div class="form-row">
<label>Directory</label>
<div class="session-dir-display" id="sessionDirDisplay">/path/to/dir</div>
</div>
</div>
<div class="form-actions">
<button class="btn-toolbar" onclick="app.closeSessionOptions()">Cancel</button>
<button class="btn-toolbar btn-primary" onclick="app.saveSessionOptions()">Save</button>
</div>
</div>
</div>
<!-- Help Button -->
<button class="help-btn" onclick="app.showHelp()" title="Keyboard shortcuts (Ctrl+?)">?</button>
</div>
+54 -2
View File
@@ -180,13 +180,47 @@ body {
.status-dot.connected { background: var(--green); }
.status-dot.disconnected { background: var(--red); }
.header-cost {
.header-tokens {
font-size: 0.75rem;
font-weight: 500;
color: var(--green);
color: var(--accent-hover);
font-family: 'SF Mono', Monaco, monospace;
}
/* Session tab gear icon */
.session-tab .tab-gear {
opacity: 0;
padding: 0 0.2rem;
font-size: 0.75rem;
line-height: 1;
color: var(--text-muted);
cursor: pointer;
transition: opacity 0.15s;
}
.session-tab:hover .tab-gear {
opacity: 1;
}
.session-tab .tab-gear:hover {
color: var(--accent-hover);
}
/* Mode indicator on session tab */
.session-tab .tab-mode {
font-size: 0.6rem;
padding: 1px 4px;
border-radius: 3px;
background: var(--bg-hover);
color: var(--text-muted);
text-transform: uppercase;
}
.session-tab .tab-mode.shell {
background: rgba(234, 179, 8, 0.2);
color: var(--yellow);
}
/* Timer Banner - Compact */
.timer-banner {
display: flex;
@@ -675,6 +709,24 @@ body {
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
}
.modal-content.modal-sm {
max-width: 320px;
}
.session-mode-display,
.session-dir-display {
padding: 0.5rem 0.75rem;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text-dim);
font-size: 0.8rem;
font-family: 'SF Mono', Monaco, monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.modal-header {
display: flex;
justify-content: space-between;
+43 -2
View File
@@ -96,9 +96,13 @@ export class WebServer extends EventEmitter {
this.app.get('/api/sessions', async () => this.getSessionsState());
this.app.post('/api/sessions', async (req): Promise<SessionResponse> => {
const body = req.body as CreateSessionRequest;
const body = req.body as CreateSessionRequest & { mode?: 'claude' | 'shell'; name?: string };
const workingDir = body.workingDir || process.cwd();
const session = new Session({ workingDir });
const session = new Session({
workingDir,
mode: body.mode || 'claude',
name: body.name || ''
});
this.sessions.set(session.id, session);
this.setupSessionListeners(session);
@@ -107,6 +111,21 @@ export class WebServer extends EventEmitter {
return { success: true, session: session.toDetailedState() };
});
// Rename a session
this.app.put('/api/sessions/:id/name', async (req) => {
const { id } = req.params as { id: string };
const body = req.body as { name: string };
const session = this.sessions.get(id);
if (!session) {
return { error: 'Session not found' };
}
session.name = body.name || '';
this.broadcast('session:updated', session.toDetailedState());
return { success: true, name: session.name };
});
this.app.delete('/api/sessions/:id', async (req): Promise<ApiResponse> => {
const { id } = req.params as { id: string };
const session = this.sessions.get(id);
@@ -226,6 +245,28 @@ export class WebServer extends EventEmitter {
}
});
// Start a plain shell session (no Claude)
this.app.post('/api/sessions/:id/shell', async (req) => {
const { id } = req.params as { id: string };
const session = this.sessions.get(id);
if (!session) {
return { error: 'Session not found' };
}
if (session.isBusy()) {
return { error: 'Session is busy' };
}
try {
await session.startShell();
this.broadcast('session:interactive', { id, mode: 'shell' });
return { success: true, message: 'Shell session started' };
} catch (err) {
return { error: (err as Error).message };
}
});
// Send input to interactive session
this.app.post('/api/sessions/:id/input', async (req): Promise<ApiResponse> => {
const { id } = req.params as { id: string };