mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-02 13:39:41 +02:00
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:
+76
-1
@@ -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
@@ -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">⚙</span>
|
||||
<span class="tab-close" onclick="event.stopPropagation(); app.closeSession('${id}')">×</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');
|
||||
|
||||
@@ -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()">×</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>
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user