feat: UI improvements and E2E testing with agent-browser

- Move font controls (A+/A-) to left of connection status
- Redesign tab count with stepper buttons (−/number/+)
- Remove respawn panel, integrate settings into session options modal
- Fix Monitor panel SSE handler (wrong element ID)
- Add screen wrapping support to Session class
- Add E2E testing skill using agent-browser
- Document testing approach in README.md and CLAUDE.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-19 03:40:57 +01:00
co-authored by Claude Opus 4.5
parent 7d01d3f274
commit bf41dab827
10 changed files with 770 additions and 311 deletions
+101 -20
View File
@@ -1,8 +1,10 @@
import { EventEmitter } from 'node:events';
import { v4 as uuidv4 } from 'uuid';
import * as pty from 'node-pty';
import { SessionState, SessionStatus, SessionConfig } from './types.js';
import { execSync } from 'node:child_process';
import { SessionState, SessionStatus, SessionConfig, ScreenSession } from './types.js';
import { TaskTracker, type BackgroundTask } from './task-tracker.js';
import { ScreenManager } from './screen-manager.js';
export type { BackgroundTask } from './task-tracker.js';
@@ -89,7 +91,18 @@ export class Session extends EventEmitter {
private _autoClearEnabled: boolean = false;
private _isClearing: boolean = false; // Prevent recursive clearing
constructor(config: Partial<SessionConfig> & { workingDir: string; mode?: SessionMode; name?: string }) {
// Screen session support
private _screenManager: ScreenManager | null = null;
private _screenSession: ScreenSession | null = null;
private _useScreen: boolean = false;
constructor(config: Partial<SessionConfig> & {
workingDir: string;
mode?: SessionMode;
name?: string;
screenManager?: ScreenManager;
useScreen?: boolean;
}) {
super();
this.id = config.id || uuidv4();
this.workingDir = config.workingDir;
@@ -97,6 +110,8 @@ export class Session extends EventEmitter {
this.mode = config.mode || 'claude';
this._name = config.name || '';
this._lastActivityAt = this.createdAt;
this._screenManager = config.screenManager || null;
this._useScreen = config.useScreen ?? (this._screenManager !== null && ScreenManager.isScreenAvailable());
// Initialize task tracker and forward events
this._taskTracker = new TaskTracker();
@@ -286,17 +301,46 @@ export class Session extends EventEmitter {
this._lineBuffer = '';
this._lastActivityAt = Date.now();
console.log('[Session] Starting interactive Claude session');
console.log('[Session] Starting interactive Claude session' + (this._useScreen ? ' (with screen)' : ''));
this.ptyProcess = pty.spawn('claude', [
'--dangerously-skip-permissions'
], {
name: 'xterm-256color',
cols: 120,
rows: 40,
cwd: this.workingDir,
env: { ...process.env, TERM: 'xterm-256color' },
});
// If screen wrapping is enabled, create a screen session first
if (this._useScreen && this._screenManager) {
try {
this._screenSession = await this._screenManager.createScreen(this.id, this.workingDir, 'claude');
console.log('[Session] Created screen session:', this._screenSession.screenName);
// Wait a moment for screen to fully start
await new Promise(resolve => setTimeout(resolve, 300));
// Attach to the screen session via PTY
this.ptyProcess = pty.spawn('screen', [
'-x', this._screenSession.screenName
], {
name: 'xterm-256color',
cols: 120,
rows: 40,
cwd: this.workingDir,
env: { ...process.env, TERM: 'xterm-256color' },
});
} catch (err) {
console.error('[Session] Failed to create screen session, falling back to direct PTY:', err);
this._useScreen = false;
this._screenSession = null;
}
}
// Fallback to direct PTY if screen is not used
if (!this.ptyProcess) {
this.ptyProcess = pty.spawn('claude', [
'--dangerously-skip-permissions'
], {
name: 'xterm-256color',
cols: 120,
rows: 40,
cwd: this.workingDir,
env: { ...process.env, TERM: 'xterm-256color' },
});
}
this._pid = this.ptyProcess.pid;
console.log('[Session] Interactive PTY spawned with PID:', this._pid);
@@ -350,6 +394,10 @@ export class Session extends EventEmitter {
this.ptyProcess = null;
this._pid = null;
this._status = 'idle';
// If using screen, mark the screen as detached but don't kill it
if (this._screenSession && this._screenManager) {
this._screenManager.setAttached(this.id, false);
}
this.emit('exit', exitCode);
});
}
@@ -371,15 +419,44 @@ export class Session extends EventEmitter {
// Use user's default shell or bash
const shell = process.env.SHELL || '/bin/bash';
console.log('[Session] Starting shell session with:', shell);
console.log('[Session] Starting shell session with:', shell + (this._useScreen ? ' (with screen)' : ''));
this.ptyProcess = pty.spawn(shell, [], {
name: 'xterm-256color',
cols: 120,
rows: 40,
cwd: this.workingDir,
env: { ...process.env, TERM: 'xterm-256color' },
});
// If screen wrapping is enabled, create a screen session first
if (this._useScreen && this._screenManager) {
try {
this._screenSession = await this._screenManager.createScreen(this.id, this.workingDir, 'shell');
console.log('[Session] Created screen session:', this._screenSession.screenName);
// Wait a moment for screen to fully start
await new Promise(resolve => setTimeout(resolve, 300));
// Attach to the screen session via PTY
this.ptyProcess = pty.spawn('screen', [
'-x', this._screenSession.screenName
], {
name: 'xterm-256color',
cols: 120,
rows: 40,
cwd: this.workingDir,
env: { ...process.env, TERM: 'xterm-256color' },
});
} catch (err) {
console.error('[Session] Failed to create screen session, falling back to direct PTY:', err);
this._useScreen = false;
this._screenSession = null;
}
}
// Fallback to direct PTY if screen is not used
if (!this.ptyProcess) {
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);
@@ -406,6 +483,10 @@ export class Session extends EventEmitter {
this.ptyProcess = null;
this._pid = null;
this._status = 'idle';
// If using screen, mark the screen as detached but don't kill it
if (this._screenSession && this._screenManager) {
this._screenManager.setAttached(this.id, false);
}
this.emit('exit', exitCode);
});
+94 -153
View File
@@ -209,12 +209,13 @@ class ClaudemanApp {
this.eventSource.addEventListener('session:updated', (e) => {
const data = JSON.parse(e.data);
this.sessions.set(data.id, data);
const session = data.session || data;
this.sessions.set(session.id, session);
this.renderSessionTabs();
this.updateCost();
// Update tokens display if this is the active session
if (data.id === this.activeSessionId && data.tokens) {
this.updateRespawnTokens(data.tokens.total);
if (session.id === this.activeSessionId && session.tokens) {
this.updateRespawnTokens(session.tokens.total);
}
});
@@ -423,7 +424,7 @@ class ClaudemanApp {
this.eventSource.addEventListener('screen:statsUpdated', (e) => {
this.screenSessions = JSON.parse(e.data);
if (document.getElementById('processPanel').classList.contains('open')) {
if (document.getElementById('monitorPanel').classList.contains('open')) {
this.renderScreenSessions();
}
});
@@ -676,6 +677,19 @@ class ClaudemanApp {
return this.runClaude();
}
// Tab count stepper functions
incrementTabCount() {
const input = document.getElementById('tabCount');
const current = parseInt(input.value) || 1;
input.value = Math.min(10, current + 1);
}
decrementTabCount() {
const input = document.getElementById('tabCount');
const current = parseInt(input.value) || 1;
input.value = Math.max(1, current - 1);
}
async runClaude() {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
const tabCount = Math.min(10, Math.max(1, parseInt(document.getElementById('tabCount').value) || 1));
@@ -824,145 +838,7 @@ class ClaudemanApp {
}, 100);
}
// ========== Respawn Panel ==========
toggleRespawnPanel() {
const panel = document.getElementById('respawnPanel');
panel.classList.toggle('open');
// Show "Enable on Current" button if there's an active session
const enableOnCurrentBtn = document.getElementById('enableOnCurrentBtn');
if (this.activeSessionId && this.sessions.has(this.activeSessionId)) {
const session = this.sessions.get(this.activeSessionId);
// Only show if session is running (has a PID)
if (session.pid) {
enableOnCurrentBtn.style.display = '';
} else {
enableOnCurrentBtn.style.display = 'none';
}
} else {
enableOnCurrentBtn.style.display = 'none';
}
}
getRespawnConfig() {
const updatePrompt = document.getElementById('respawnPrompt').value;
const idleTimeout = parseInt(document.getElementById('respawnIdleTimeout').value) || 5;
const stepDelay = parseInt(document.getElementById('respawnStepDelay').value) || 1;
const sendClear = document.getElementById('respawnSendClear').checked;
const sendInit = document.getElementById('respawnSendInit').checked;
const durationStr = document.getElementById('respawnDuration').value;
const durationMinutes = durationStr ? parseInt(durationStr) : null;
const autoClearEnabled = document.getElementById('autoClearEnabled').checked;
const autoClearThreshold = parseInt(document.getElementById('autoClearThreshold').value) || 100000;
return {
respawnConfig: {
updatePrompt,
idleTimeoutMs: idleTimeout * 1000,
interStepDelayMs: stepDelay * 1000,
sendClear,
sendInit,
},
durationMinutes,
autoClearEnabled,
autoClearThreshold
};
}
async startInteractiveWithRespawn() {
this.toggleRespawnPanel();
const dir = document.getElementById('dirInput').value.trim();
const { respawnConfig, durationMinutes, autoClearEnabled, autoClearThreshold } = this.getRespawnConfig();
this.terminal.clear();
this.terminal.writeln('\x1b[1;32m Starting session with respawn...\x1b[0m');
if (durationMinutes) {
this.terminal.writeln(`\x1b[90m Duration: ${durationMinutes} minutes\x1b[0m`);
}
this.terminal.writeln('');
try {
// Create session
const createRes = await fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: dir || undefined })
});
const createData = await createRes.json();
if (!createData.success) throw new Error(createData.error);
const sessionId = createData.session.id;
this.activeSessionId = sessionId;
// Start interactive with respawn
await fetch(`/api/sessions/${sessionId}/interactive-respawn`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ respawnConfig, durationMinutes })
});
// Set auto-clear if enabled
if (autoClearEnabled) {
await fetch(`/api/sessions/${sessionId}/auto-clear`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: true, threshold: autoClearThreshold })
});
}
// 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`);
}
}
async enableRespawnOnCurrent() {
if (!this.activeSessionId) {
this.showToast('No active session', 'warning');
return;
}
this.toggleRespawnPanel();
const { respawnConfig, durationMinutes, autoClearEnabled, autoClearThreshold } = this.getRespawnConfig();
try {
// Enable respawn on existing session
const res = await fetch(`/api/sessions/${this.activeSessionId}/respawn/enable`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config: respawnConfig, durationMinutes })
});
const data = await res.json();
if (data.error) throw new Error(data.error);
// Set auto-clear if enabled
if (autoClearEnabled) {
await fetch(`/api/sessions/${this.activeSessionId}/auto-clear`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: true, threshold: autoClearThreshold })
});
}
this.showToast('Respawn enabled on current session', 'success');
this.terminal.focus();
} catch (err) {
this.showToast('Failed to enable respawn: ' + err.message, 'error');
}
}
// ========== Respawn Banner ==========
showRespawnBanner() {
document.getElementById('respawnBanner').style.display = 'flex';
@@ -1201,22 +1077,22 @@ class ClaudemanApp {
document.getElementById('sessionNameInput').value = session.name || '';
document.getElementById('sessionDirDisplay').textContent = session.workingDir || 'Unknown';
// Update respawn status display
// Update respawn status display and buttons
const respawnStatus = document.getElementById('sessionRespawnStatus');
const stopBtn = document.getElementById('sessionStopRespawnBtn');
const configBtn = document.getElementById('sessionConfigRespawnBtn');
const enableBtn = document.getElementById('modalEnableRespawnBtn');
const stopBtn = document.getElementById('modalStopRespawnBtn');
if (this.respawnStatus[sessionId]) {
respawnStatus.classList.add('active');
respawnStatus.querySelector('.respawn-status-text').textContent =
`Active (${this.respawnStatus[sessionId].state || 'running'})`;
this.respawnStatus[sessionId].state || 'Active';
enableBtn.style.display = 'none';
stopBtn.style.display = '';
configBtn.textContent = 'Reconfigure';
} else {
respawnStatus.classList.remove('active');
respawnStatus.querySelector('.respawn-status-text').textContent = 'Not active';
enableBtn.style.display = '';
stopBtn.style.display = 'none';
configBtn.textContent = 'Configure Respawn';
}
// Only show respawn section for claude mode sessions with a running process
@@ -1233,7 +1109,73 @@ class ClaudemanApp {
setTimeout(() => document.getElementById('sessionNameInput').focus(), 100);
}
async stopRespawnFromOptions() {
// Get respawn config from modal inputs
getModalRespawnConfig() {
const updatePrompt = document.getElementById('modalRespawnPrompt').value;
const idleTimeout = parseInt(document.getElementById('modalRespawnIdleTimeout').value) || 5;
const stepDelay = parseInt(document.getElementById('modalRespawnStepDelay').value) || 1;
const sendClear = document.getElementById('modalRespawnSendClear').checked;
const sendInit = document.getElementById('modalRespawnSendInit').checked;
const durationStr = document.getElementById('modalRespawnDuration').value;
const durationMinutes = durationStr ? parseInt(durationStr) : null;
const autoClearEnabled = document.getElementById('modalAutoClearEnabled').checked;
const autoClearThreshold = parseInt(document.getElementById('modalAutoClearThreshold').value) || 100000;
return {
respawnConfig: {
updatePrompt,
idleTimeoutMs: idleTimeout * 1000,
interStepDelayMs: stepDelay * 1000,
sendClear,
sendInit,
},
durationMinutes,
autoClearEnabled,
autoClearThreshold
};
}
async enableRespawnFromModal() {
if (!this.editingSessionId) {
this.showToast('No session selected', 'warning');
return;
}
const { respawnConfig, durationMinutes, autoClearEnabled, autoClearThreshold } = this.getModalRespawnConfig();
try {
// Enable respawn on the session
const res = await fetch(`/api/sessions/${this.editingSessionId}/respawn/enable`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ config: respawnConfig, durationMinutes })
});
const data = await res.json();
if (data.error) throw new Error(data.error);
// Set auto-clear if enabled
if (autoClearEnabled) {
await fetch(`/api/sessions/${this.editingSessionId}/auto-clear`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: true, threshold: autoClearThreshold })
});
}
// Update UI
const respawnStatus = document.getElementById('sessionRespawnStatus');
respawnStatus.classList.add('active');
respawnStatus.querySelector('.respawn-status-text').textContent = 'WATCHING';
document.getElementById('modalEnableRespawnBtn').style.display = 'none';
document.getElementById('modalStopRespawnBtn').style.display = '';
this.showToast('Respawn enabled', 'success');
} catch (err) {
this.showToast('Failed to enable respawn: ' + err.message, 'error');
}
}
async stopRespawnFromModal() {
if (!this.editingSessionId) return;
try {
await fetch(`/api/sessions/${this.editingSessionId}/respawn/stop`, { method: 'POST' });
@@ -1243,8 +1185,8 @@ class ClaudemanApp {
const respawnStatus = document.getElementById('sessionRespawnStatus');
respawnStatus.classList.remove('active');
respawnStatus.querySelector('.respawn-status-text').textContent = 'Not active';
document.getElementById('sessionStopRespawnBtn').style.display = 'none';
document.getElementById('sessionConfigRespawnBtn').textContent = 'Configure Respawn';
document.getElementById('modalEnableRespawnBtn').style.display = '';
document.getElementById('modalStopRespawnBtn').style.display = 'none';
this.showToast('Respawn stopped', 'success');
} catch (err) {
@@ -1412,7 +1354,6 @@ class ClaudemanApp {
this.closeSessionOptions();
this.closeAppSettings();
this.cancelCloseSession();
document.getElementById('respawnPanel').classList.remove('open');
document.getElementById('monitorPanel').classList.remove('open');
}
+85 -88
View File
@@ -25,16 +25,16 @@
</div>
<div class="header-right">
<div class="connection-status" id="connectionStatus">
<span class="status-dot"></span>
<span class="status-text">Connecting</span>
</div>
<div class="header-tokens" id="headerTokens" title="Total tokens across all sessions">0 tokens</div>
<div class="header-font-controls">
<button class="btn-icon-header btn-sm" onclick="app.decreaseFontSize()" title="Decrease font (Ctrl+-)">A-</button>
<span class="font-size-display" id="fontSizeDisplay">14</span>
<button class="btn-icon-header btn-sm" onclick="app.increaseFontSize()" title="Increase font (Ctrl++)">A+</button>
</div>
<div class="connection-status" id="connectionStatus">
<span class="status-dot"></span>
<span class="status-text">Connecting</span>
</div>
<div class="header-tokens" id="headerTokens" title="Total tokens across all sessions">0 tokens</div>
<button class="btn-icon-header" onclick="app.openAppSettings()" title="App Settings">&#x2699;</button>
</div>
</header>
@@ -73,7 +73,11 @@
<button class="btn-toolbar btn-primary" onclick="app.runClaude()" title="Run Claude (Ctrl+Enter)">
Run Claude
</button>
<input type="number" id="tabCount" class="toolbar-input-sm" value="1" min="1" max="10" title="Number of tabs to open (1-10)">
<div class="tab-count-group" title="Number of tabs to open (1-10)">
<button class="tab-count-btn" onclick="app.decrementTabCount()">−</button>
<input type="number" id="tabCount" class="tab-count-input" value="1" min="1" max="10" readonly>
<button class="tab-count-btn" onclick="app.incrementTabCount()">+</button>
</div>
<button class="btn-toolbar" onclick="app.runShell()" title="Run Shell">
Shell
</button>
@@ -102,78 +106,6 @@
</div>
</footer>
<!-- Respawn Settings Panel (slide-up) -->
<div class="respawn-panel" id="respawnPanel">
<div class="respawn-panel-header">
<span>Respawn Settings</span>
<button class="btn-close" onclick="app.toggleRespawnPanel()">&times;</button>
</div>
<div class="respawn-panel-body">
<div class="form-row">
<label>Update Prompt</label>
<input type="text" id="respawnPrompt" value="update all the docs and CLAUDE.md" placeholder="Prompt to send when idle">
</div>
<div class="form-row-inline">
<div class="form-col">
<label>Idle Timeout</label>
<div class="input-suffix">
<input type="number" id="respawnIdleTimeout" value="5" min="1" max="60">
<span>sec</span>
</div>
</div>
<div class="form-col">
<label>Step Delay</label>
<div class="input-suffix">
<input type="number" id="respawnStepDelay" value="1" min="0" max="10">
<span>sec</span>
</div>
</div>
</div>
<div class="form-row">
<label>Run Duration (optional)</label>
<div class="input-suffix">
<input type="number" id="respawnDuration" value="" min="1" placeholder="No limit">
<span>min</span>
</div>
<span class="form-hint">Leave empty for unlimited. Respawn stops after duration.</span>
</div>
<div class="form-row">
<label>After Update Prompt:</label>
<div class="checkbox-group">
<label class="checkbox-label">
<input type="checkbox" id="respawnSendClear" checked>
<span>Send /clear</span>
<span class="checkbox-hint">Clear conversation context</span>
</label>
<label class="checkbox-label">
<input type="checkbox" id="respawnSendInit" checked>
<span>Send /init</span>
<span class="checkbox-hint">Reinitialize session</span>
</label>
</div>
</div>
<div class="form-row">
<label>Auto-Clear Context</label>
<div class="checkbox-group">
<label class="checkbox-label">
<input type="checkbox" id="autoClearEnabled">
<span>Enable Auto-Clear</span>
<span class="checkbox-hint">Clear at token threshold</span>
</label>
</div>
<div class="input-suffix" style="margin-top: 0.5rem;">
<input type="number" id="autoClearThreshold" value="100000" min="10000" max="500000" step="10000">
<span>tokens</span>
</div>
</div>
<div class="form-actions">
<button class="btn-toolbar" onclick="app.toggleRespawnPanel()">Cancel</button>
<button class="btn-toolbar btn-success" onclick="app.enableRespawnOnCurrent()" id="enableOnCurrentBtn" style="display: none;">Enable on Current</button>
<button class="btn-toolbar btn-primary" onclick="app.startInteractiveWithRespawn()">Start New + Respawn</button>
</div>
</div>
</div>
<!-- Help Modal -->
<div class="modal" id="helpModal">
<div class="modal-backdrop" onclick="app.closeHelp()"></div>
@@ -228,10 +160,10 @@
</div>
</div>
<!-- Session Options Modal -->
<!-- Session Options Modal (includes Respawn Settings) -->
<div class="modal" id="sessionOptionsModal">
<div class="modal-backdrop" onclick="app.closeSessionOptions()"></div>
<div class="modal-content modal-sm">
<div class="modal-content">
<div class="modal-header">
<h3>Session Options</h3>
<button class="modal-close" onclick="app.closeSessionOptions()">&times;</button>
@@ -245,20 +177,85 @@
<label>Directory</label>
<div class="session-dir-display" id="sessionDirDisplay">/path/to/dir</div>
</div>
<div class="form-row session-respawn-section" id="sessionRespawnSection">
<label>Respawn Controller</label>
<div class="session-respawn-status" id="sessionRespawnStatus">
<span class="respawn-status-text">Not active</span>
<!-- Respawn Settings Section -->
<div class="session-respawn-section" id="sessionRespawnSection">
<div class="section-divider">
<span>Respawn Controller</span>
<div class="session-respawn-status" id="sessionRespawnStatus">
<span class="respawn-status-indicator"></span>
<span class="respawn-status-text">Not active</span>
</div>
</div>
<div class="session-respawn-actions">
<button class="btn-toolbar btn-sm" onclick="app.toggleRespawnPanel(); app.closeSessionOptions();" id="sessionConfigRespawnBtn">Configure Respawn</button>
<button class="btn-toolbar btn-sm btn-danger" onclick="app.stopRespawnFromOptions()" id="sessionStopRespawnBtn" style="display: none;">Stop Respawn</button>
<div class="form-row">
<label>Update Prompt</label>
<input type="text" id="modalRespawnPrompt" value="update all the docs and CLAUDE.md" placeholder="Prompt to send when idle">
</div>
<div class="form-row-inline">
<div class="form-col">
<label>Idle Timeout</label>
<div class="input-suffix">
<input type="number" id="modalRespawnIdleTimeout" value="5" min="1" max="60">
<span>sec</span>
</div>
</div>
<div class="form-col">
<label>Step Delay</label>
<div class="input-suffix">
<input type="number" id="modalRespawnStepDelay" value="1" min="0" max="10">
<span>sec</span>
</div>
</div>
</div>
<div class="form-row">
<label>Run Duration</label>
<div class="input-suffix">
<input type="number" id="modalRespawnDuration" value="" min="1" placeholder="No limit">
<span>min</span>
</div>
<span class="form-hint">Leave empty for unlimited. Respawn stops after duration.</span>
</div>
<div class="form-row">
<label>After Update Prompt</label>
<div class="checkbox-group-inline">
<label class="checkbox-inline">
<input type="checkbox" id="modalRespawnSendClear" checked>
<span>Send /clear</span>
</label>
<label class="checkbox-inline">
<input type="checkbox" id="modalRespawnSendInit" checked>
<span>Send /init</span>
</label>
</div>
</div>
<div class="form-row">
<label>Auto-Clear Context</label>
<div class="auto-clear-row">
<label class="checkbox-inline">
<input type="checkbox" id="modalAutoClearEnabled">
<span>Enable at</span>
</label>
<div class="input-suffix input-suffix-sm">
<input type="number" id="modalAutoClearThreshold" value="100000" min="10000" max="500000" step="10000">
<span>tokens</span>
</div>
</div>
</div>
<div class="respawn-actions">
<button class="btn-toolbar btn-success" onclick="app.enableRespawnFromModal()" id="modalEnableRespawnBtn">Enable Respawn</button>
<button class="btn-toolbar btn-danger" onclick="app.stopRespawnFromModal()" id="modalStopRespawnBtn" style="display: none;">Stop Respawn</button>
</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>
<button class="btn-toolbar btn-primary" onclick="app.saveSessionOptions()">Save Name</button>
</div>
</div>
</div>
+141 -47
View File
@@ -539,6 +539,66 @@ body {
max-width: 50vw;
}
/* Tab Count Input Group - nicer stepper design */
.tab-count-group {
display: flex;
align-items: center;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 4px;
overflow: hidden;
}
.tab-count-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 28px;
background: transparent;
border: none;
color: var(--text-dim);
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
}
.tab-count-btn:hover {
background: var(--bg-hover);
color: var(--accent-hover);
}
.tab-count-btn:active {
background: var(--accent);
color: white;
}
.tab-count-input {
width: 28px;
height: 28px;
background: transparent;
border: none;
border-left: 1px solid var(--border);
border-right: 1px solid var(--border);
color: var(--text);
font-size: 0.8rem;
font-weight: 600;
text-align: center;
font-family: 'SF Mono', Monaco, monospace;
-moz-appearance: textfield;
}
.tab-count-input::-webkit-inner-spin-button,
.tab-count-input::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.tab-count-input:focus {
outline: none;
}
.toolbar-input-sm {
padding: 0.3rem 0.4rem;
background: var(--bg-input);
@@ -586,39 +646,7 @@ body {
font-family: 'SF Mono', Monaco, monospace;
}
/* Slide-up Panels */
.respawn-panel,
.new-session-panel {
position: fixed;
bottom: var(--toolbar-height);
left: 50%;
transform: translateX(-50%) translateY(100%);
width: 400px;
max-width: 90vw;
background: var(--bg-card);
border: 1px solid var(--border);
border-bottom: none;
border-radius: 8px 8px 0 0;
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.4);
z-index: 100;
transition: transform 0.2s ease;
}
.respawn-panel.open,
.new-session-panel.open {
transform: translateX(-50%) translateY(0);
}
.respawn-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
font-size: 0.85rem;
font-weight: 500;
}
/* Close button for modals */
.btn-close {
background: transparent;
border: none;
@@ -633,10 +661,6 @@ body {
color: var(--text);
}
.respawn-panel-body {
padding: 1rem;
}
.form-row {
margin-bottom: 0.75rem;
}
@@ -828,33 +852,104 @@ body {
.session-respawn-section {
border-top: 1px solid var(--border);
padding-top: 0.75rem;
margin-top: 0.5rem;
margin-top: 0.75rem;
}
.section-divider {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
font-size: 0.8rem;
font-weight: 500;
color: var(--text);
}
.session-respawn-status {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
gap: 0.4rem;
font-size: 0.75rem;
padding: 0.25rem 0.5rem;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 4px;
margin-bottom: 0.5rem;
}
.session-respawn-status .respawn-status-indicator {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-muted);
}
.session-respawn-status .respawn-status-text {
font-size: 0.8rem;
color: var(--text-dim);
}
.session-respawn-status.active {
border-color: rgba(34, 197, 94, 0.4);
.session-respawn-status.active .respawn-status-indicator {
background: var(--green);
animation: pulse 1.5s infinite;
}
.session-respawn-status.active .respawn-status-text {
color: var(--green);
}
/* Inline checkbox group */
.checkbox-group-inline {
display: flex;
gap: 1rem;
}
.checkbox-inline {
display: flex;
align-items: center;
gap: 0.35rem;
cursor: pointer;
font-size: 0.8rem;
color: var(--text-dim);
}
.checkbox-inline input[type="checkbox"] {
accent-color: var(--accent);
}
.checkbox-inline:hover {
color: var(--text);
}
/* Auto-clear row */
.auto-clear-row {
display: flex;
align-items: center;
gap: 0.75rem;
}
.input-suffix-sm {
display: flex;
align-items: center;
}
.input-suffix-sm input {
width: 80px;
padding: 0.35rem 0.5rem;
font-size: 0.75rem;
}
.input-suffix-sm span {
padding: 0.35rem 0.4rem;
font-size: 0.7rem;
}
/* Respawn actions in modal */
.respawn-actions {
display: flex;
gap: 0.5rem;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border);
}
.session-respawn-actions {
display: flex;
gap: 0.5rem;
@@ -1385,8 +1480,7 @@ kbd {
display: none;
}
.respawn-panel,
.new-session-panel {
.monitor-panel {
width: 100%;
max-width: 100%;
border-radius: 8px 8px 0 0;
+15 -3
View File
@@ -118,7 +118,9 @@ export class WebServer extends EventEmitter {
const session = new Session({
workingDir,
mode: body.mode || 'claude',
name: body.name || ''
name: body.name || '',
screenManager: this.screenManager,
useScreen: true
});
this.sessions.set(session.id, session);
@@ -256,6 +258,7 @@ export class WebServer extends EventEmitter {
try {
await session.startInteractive();
this.broadcast('session:interactive', { id });
this.broadcast('session:updated', { session: session.toDetailedState() });
return { success: true, message: 'Interactive session started' };
} catch (err) {
return { error: (err as Error).message };
@@ -278,6 +281,7 @@ export class WebServer extends EventEmitter {
try {
await session.startShell();
this.broadcast('session:interactive', { id, mode: 'shell' });
this.broadcast('session:updated', { session: session.toDetailedState() });
return { success: true, message: 'Shell session started' };
} catch (err) {
return { error: (err as Error).message };
@@ -419,6 +423,7 @@ export class WebServer extends EventEmitter {
// Start interactive session
await session.startInteractive();
this.broadcast('session:interactive', { id });
this.broadcast('session:updated', { session: session.toDetailedState() });
// Create and start respawn controller
const controller = new RespawnController(session, body?.respawnConfig);
@@ -651,7 +656,11 @@ export class WebServer extends EventEmitter {
}
// Create a new session with the case as working directory
const session = new Session({ workingDir: casePath });
const session = new Session({
workingDir: casePath,
screenManager: this.screenManager,
useScreen: true
});
this.sessions.set(session.id, session);
this.setupSessionListeners(session);
this.broadcast('session:created', session.toDetailedState());
@@ -660,6 +669,7 @@ export class WebServer extends EventEmitter {
try {
await session.startInteractive();
this.broadcast('session:interactive', { id: session.id });
this.broadcast('session:updated', { session: session.toDetailedState() });
return {
success: true,
@@ -1053,7 +1063,9 @@ export class WebServer extends EventEmitter {
id: screen.sessionId, // Preserve the original session ID
workingDir: screen.workingDir,
mode: screen.mode,
name: `Restored: ${screen.screenName}`
name: `Restored: ${screen.screenName}`,
screenManager: this.screenManager,
useScreen: true
});
this.sessions.set(session.id, session);