mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-07 07:59:42 +02:00
feat(tui): add web server auto-start, shell mode, and feature parity
TUI now checks if web server is running on startup and offers to start it in the background. Added new CLI options: --with-web (auto-start), --no-web (skip check), -p (port). TUI feature parity with web interface: - Shell mode: press 'h' in cases view to start bash instead of Claude - Multi-start: press 'm' to start 1-20 sessions at once - Respawn toggle: Ctrl+R to enable/disable respawn on Claude sessions - Session rename: API support via useSessionManager hook Security fixes from previous analysis: - Command injection prevention in screen-manager.ts - Path traversal protection in server.ts - Input validation for shell-interpolated values Also fixes memory leak in session.ts (timer tracking) and flaky test timeout in session-cleanup.test.ts. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -43,8 +43,11 @@ node dist/index.js web # After npm run build
|
|||||||
claudeman web # After npm link
|
claudeman web # After npm link
|
||||||
|
|
||||||
# Start TUI (terminal user interface):
|
# Start TUI (terminal user interface):
|
||||||
npx tsx src/index.ts tui # Dev mode - full-screen TUI
|
npx tsx src/index.ts tui # Dev mode - prompts to start web if not running
|
||||||
claudeman tui # After npm link
|
claudeman tui # After npm link
|
||||||
|
claudeman tui --with-web # Auto-start web server if not running (no prompt)
|
||||||
|
claudeman tui --no-web # Skip web server check entirely
|
||||||
|
claudeman tui -p 8080 # Specify web server port
|
||||||
|
|
||||||
# Testing (vitest)
|
# Testing (vitest)
|
||||||
# Note: globals: true configured - no imports needed for describe/it/expect
|
# Note: globals: true configured - no imports needed for describe/it/expect
|
||||||
@@ -414,12 +417,12 @@ npx agent-browser close
|
|||||||
| GET | `/api/sessions/:id/inner-state` | Get Ralph loop state + todos |
|
| GET | `/api/sessions/:id/inner-state` | Get Ralph loop state + todos |
|
||||||
| POST | `/api/sessions/:id/auto-compact` | Configure auto-compact threshold |
|
| POST | `/api/sessions/:id/auto-compact` | Configure auto-compact threshold |
|
||||||
| POST | `/api/sessions/:id/auto-clear` | Configure auto-clear threshold |
|
| POST | `/api/sessions/:id/auto-clear` | Configure auto-clear threshold |
|
||||||
| POST | `/api/quick-start` | Create case + start interactive session |
|
| POST | `/api/quick-start` | Create case + start session (claude or shell mode) |
|
||||||
| GET | `/api/cases` | List available cases |
|
| GET | `/api/cases` | List available cases |
|
||||||
| POST | `/api/cases` | Create new case |
|
| POST | `/api/cases` | Create new case |
|
||||||
| GET | `/api/screens` | List screen sessions with stats |
|
| GET | `/api/screens` | List screen sessions with stats |
|
||||||
|
|
||||||
## Keyboard Shortcuts
|
## Keyboard Shortcuts (Web UI)
|
||||||
|
|
||||||
| Shortcut | Action |
|
| Shortcut | Action |
|
||||||
|----------|--------|
|
|----------|--------|
|
||||||
@@ -432,6 +435,44 @@ npx agent-browser close
|
|||||||
| `Ctrl+?` | Show keyboard shortcuts help |
|
| `Ctrl+?` | Show keyboard shortcuts help |
|
||||||
| `Escape` | Close panels and modals |
|
| `Escape` | Close panels and modals |
|
||||||
|
|
||||||
|
## TUI Keyboard Shortcuts
|
||||||
|
|
||||||
|
**Start Screen - Sessions:**
|
||||||
|
| Key | Action |
|
||||||
|
|-----|--------|
|
||||||
|
| `↑`/`↓` | Navigate list |
|
||||||
|
| `Enter` | View session in TUI |
|
||||||
|
| `a` | Attach directly to screen (full terminal) |
|
||||||
|
| `d` | Delete/kill selected session |
|
||||||
|
| `c` | Switch to cases view |
|
||||||
|
| `n` | Quick-start new session |
|
||||||
|
| `r` | Refresh list |
|
||||||
|
| `q` | Quit TUI |
|
||||||
|
|
||||||
|
**Start Screen - Cases:**
|
||||||
|
| Key | Action |
|
||||||
|
|-----|--------|
|
||||||
|
| `↑`/`↓` | Navigate list |
|
||||||
|
| `Enter` | Start Claude session with selected case |
|
||||||
|
| `h` | Start Shell session with selected case |
|
||||||
|
| `m` | Multi-start (1-20 sessions at once) |
|
||||||
|
| `n` | Create new case |
|
||||||
|
| `s` | Switch to sessions view |
|
||||||
|
| `r` | Refresh list |
|
||||||
|
|
||||||
|
**Main View (Session Active):**
|
||||||
|
| Key | Action |
|
||||||
|
|-----|--------|
|
||||||
|
| `Tab`/`Shift+Tab` | Next/previous session tab |
|
||||||
|
| `Ctrl+1-9` | Go to session N |
|
||||||
|
| `[`/`]` | Previous/next session (vim-style) |
|
||||||
|
| `Ctrl+N` | New session |
|
||||||
|
| `Ctrl+W` | Close current session |
|
||||||
|
| `Ctrl+K` | Kill all sessions |
|
||||||
|
| `Ctrl+R` | Toggle respawn (Claude sessions only) |
|
||||||
|
| `Escape` | Back to start screen |
|
||||||
|
| `?` | Toggle help overlay |
|
||||||
|
|
||||||
## State Files
|
## State Files
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|
|||||||
+10
-2
@@ -483,10 +483,18 @@ program
|
|||||||
program
|
program
|
||||||
.command('tui')
|
.command('tui')
|
||||||
.description('Start the terminal user interface')
|
.description('Start the terminal user interface')
|
||||||
.action(async () => {
|
.option('--with-web', 'Automatically start the web server if not running (no prompt)')
|
||||||
|
.option('--no-web', 'Skip web server check entirely')
|
||||||
|
.option('-p, --port <port>', 'Web server port (default: 3000)', '3000')
|
||||||
|
.action(async (options: { withWeb?: boolean; web?: boolean; port?: string }) => {
|
||||||
try {
|
try {
|
||||||
const { startTUI } = await import('./tui/index.js');
|
const { startTUI } = await import('./tui/index.js');
|
||||||
await startTUI();
|
const port = parseInt(options.port || '3000', 10);
|
||||||
|
await startTUI({
|
||||||
|
autoStartWeb: options.withWeb || false,
|
||||||
|
skipWebCheck: options.web === false,
|
||||||
|
port,
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(chalk.red(`✗ Failed to start TUI: ${(err as Error).message}`));
|
console.error(chalk.red(`✗ Failed to start TUI: ${(err as Error).message}`));
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
|
|||||||
+75
-3
@@ -27,6 +27,56 @@ const SCREENS_FILE = join(homedir(), '.claudeman', 'screens.json');
|
|||||||
/** Pre-compiled regex for parsing `screen -ls` output */
|
/** Pre-compiled regex for parsing `screen -ls` output */
|
||||||
const SCREEN_PATTERN = /(\d+)\.(claudeman-([a-f0-9-]+))/g;
|
const SCREEN_PATTERN = /(\d+)\.(claudeman-([a-f0-9-]+))/g;
|
||||||
|
|
||||||
|
/** Regex to validate screen names (only allow safe characters) */
|
||||||
|
const SAFE_SCREEN_NAME_PATTERN = /^claudeman-[a-f0-9-]+$/;
|
||||||
|
|
||||||
|
/** Regex to validate working directory paths (no shell metacharacters) */
|
||||||
|
const SAFE_PATH_PATTERN = /^[a-zA-Z0-9_\/\-. ~]+$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that a screen name contains only safe characters.
|
||||||
|
* Prevents command injection via malformed session IDs.
|
||||||
|
*
|
||||||
|
* @param name - The screen name to validate
|
||||||
|
* @returns true if the name is safe for use in shell commands
|
||||||
|
*/
|
||||||
|
function isValidScreenName(name: string): boolean {
|
||||||
|
return SAFE_SCREEN_NAME_PATTERN.test(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that a path contains only safe characters.
|
||||||
|
* Prevents command injection via malformed paths.
|
||||||
|
*
|
||||||
|
* @param path - The path to validate
|
||||||
|
* @returns true if the path is safe for use in shell commands
|
||||||
|
*/
|
||||||
|
function isValidPath(path: string): boolean {
|
||||||
|
// Check for shell metacharacters that could lead to injection
|
||||||
|
if (path.includes(';') || path.includes('&') || path.includes('|') ||
|
||||||
|
path.includes('$') || path.includes('`') || path.includes('(') ||
|
||||||
|
path.includes(')') || path.includes('{') || path.includes('}') ||
|
||||||
|
path.includes('<') || path.includes('>') || path.includes("'") ||
|
||||||
|
path.includes('"') || path.includes('\n') || path.includes('\r')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return SAFE_PATH_PATTERN.test(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escapes a string for safe use in shell double quotes.
|
||||||
|
*
|
||||||
|
* @param str - The string to escape
|
||||||
|
* @returns The escaped string
|
||||||
|
*/
|
||||||
|
function shellEscape(str: string): string {
|
||||||
|
return str
|
||||||
|
.replace(/\\/g, '\\\\')
|
||||||
|
.replace(/"/g, '\\"')
|
||||||
|
.replace(/\$/g, '\\$')
|
||||||
|
.replace(/`/g, '\\`');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages GNU Screen sessions that wrap Claude CLI or shell processes.
|
* Manages GNU Screen sessions that wrap Claude CLI or shell processes.
|
||||||
*
|
*
|
||||||
@@ -107,6 +157,14 @@ export class ScreenManager extends EventEmitter {
|
|||||||
async createScreen(sessionId: string, workingDir: string, mode: 'claude' | 'shell', name?: string): Promise<ScreenSession> {
|
async createScreen(sessionId: string, workingDir: string, mode: 'claude' | 'shell', name?: string): Promise<ScreenSession> {
|
||||||
const screenName = `claudeman-${sessionId.slice(0, 8)}`;
|
const screenName = `claudeman-${sessionId.slice(0, 8)}`;
|
||||||
|
|
||||||
|
// Security: Validate screenName and workingDir to prevent command injection
|
||||||
|
if (!isValidScreenName(screenName)) {
|
||||||
|
throw new Error(`Invalid screen name: contains unsafe characters`);
|
||||||
|
}
|
||||||
|
if (!isValidPath(workingDir)) {
|
||||||
|
throw new Error(`Invalid working directory path: contains unsafe characters`);
|
||||||
|
}
|
||||||
|
|
||||||
// Create screen in detached mode with the appropriate command
|
// Create screen in detached mode with the appropriate command
|
||||||
// Set CLAUDEMAN_SCREEN=1 so Claude sessions know they're running in Claudeman
|
// Set CLAUDEMAN_SCREEN=1 so Claude sessions know they're running in Claudeman
|
||||||
// This helps prevent Claude from attempting to kill its own screen session
|
// This helps prevent Claude from attempting to kill its own screen session
|
||||||
@@ -161,8 +219,16 @@ export class ScreenManager extends EventEmitter {
|
|||||||
|
|
||||||
// Get screen session PID
|
// Get screen session PID
|
||||||
private getScreenPid(screenName: string): number | null {
|
private getScreenPid(screenName: string): number | null {
|
||||||
|
// Security: Validate screenName to prevent command injection
|
||||||
|
if (!isValidScreenName(screenName)) {
|
||||||
|
console.error('[ScreenManager] Invalid screen name in getScreenPid:', screenName);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const output = execSync(`screen -ls | grep "${screenName}"`, {
|
// Use shell-escaped screenName in grep
|
||||||
|
const escapedName = shellEscape(screenName);
|
||||||
|
const output = execSync(`screen -ls | grep "${escapedName}"`, {
|
||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
timeout: 5000
|
timeout: 5000
|
||||||
});
|
});
|
||||||
@@ -522,6 +588,12 @@ export class ScreenManager extends EventEmitter {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Security: Validate screenName to prevent command injection
|
||||||
|
if (!isValidScreenName(screen.screenName)) {
|
||||||
|
console.error('[ScreenManager] Invalid screen name in sendInput:', screen.screenName);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Split input into text and control characters
|
// Split input into text and control characters
|
||||||
// IMPORTANT: Must send text and carriage return as SEPARATE commands
|
// IMPORTANT: Must send text and carriage return as SEPARATE commands
|
||||||
@@ -529,8 +601,8 @@ export class ScreenManager extends EventEmitter {
|
|||||||
const hasCarriageReturn = input.includes('\r');
|
const hasCarriageReturn = input.includes('\r');
|
||||||
const textPart = input.replace(/\r/g, '').replace(/\n/g, '');
|
const textPart = input.replace(/\r/g, '').replace(/\n/g, '');
|
||||||
|
|
||||||
// Escape the text part for shell (double quotes)
|
// Escape the text part for shell using the helper function
|
||||||
const escapedText = textPart.replace(/"/g, '\\"').replace(/\$/g, '\\$').replace(/`/g, '\\`');
|
const escapedText = shellEscape(textPart);
|
||||||
|
|
||||||
// Send text first (if any)
|
// Send text first (if any)
|
||||||
if (escapedText) {
|
if (escapedText) {
|
||||||
|
|||||||
+90
-10
@@ -308,6 +308,13 @@ export class Session extends EventEmitter {
|
|||||||
private _autoCompactPrompt: string = ''; // Optional prompt for compact
|
private _autoCompactPrompt: string = ''; // Optional prompt for compact
|
||||||
private _isCompacting: boolean = false; // Prevent recursive compacting
|
private _isCompacting: boolean = false; // Prevent recursive compacting
|
||||||
|
|
||||||
|
// Timer tracking for cleanup (prevents memory leaks)
|
||||||
|
private _autoCompactTimer: NodeJS.Timeout | null = null;
|
||||||
|
private _autoClearTimer: NodeJS.Timeout | null = null;
|
||||||
|
private _promptCheckInterval: NodeJS.Timeout | null = null;
|
||||||
|
private _promptCheckTimeout: NodeJS.Timeout | null = null;
|
||||||
|
private _shellIdleTimer: NodeJS.Timeout | null = null;
|
||||||
|
|
||||||
// Screen session support
|
// Screen session support
|
||||||
private _screenManager: ScreenManager | null = null;
|
private _screenManager: ScreenManager | null = null;
|
||||||
private _screenSession: ScreenSession | null = null;
|
private _screenSession: ScreenSession | null = null;
|
||||||
@@ -645,11 +652,18 @@ export class Session extends EventEmitter {
|
|||||||
// For NEW screens: wait for prompt to appear then clean buffer
|
// For NEW screens: wait for prompt to appear then clean buffer
|
||||||
// For RESTORED screens: don't do anything - client will fetch buffer on tab switch
|
// For RESTORED screens: don't do anything - client will fetch buffer on tab switch
|
||||||
if (!isRestoredSession) {
|
if (!isRestoredSession) {
|
||||||
const checkForPrompt = setInterval(() => {
|
this._promptCheckInterval = setInterval(() => {
|
||||||
// Wait for the prompt character (❯) which means Claude is fully initialized
|
// Wait for the prompt character (❯) which means Claude is fully initialized
|
||||||
const bufferValue = this._terminalBuffer.value;
|
const bufferValue = this._terminalBuffer.value;
|
||||||
if (bufferValue.includes('❯') || bufferValue.includes('\u276f')) {
|
if (bufferValue.includes('❯') || bufferValue.includes('\u276f')) {
|
||||||
clearInterval(checkForPrompt);
|
if (this._promptCheckInterval) {
|
||||||
|
clearInterval(this._promptCheckInterval);
|
||||||
|
this._promptCheckInterval = null;
|
||||||
|
}
|
||||||
|
if (this._promptCheckTimeout) {
|
||||||
|
clearTimeout(this._promptCheckTimeout);
|
||||||
|
this._promptCheckTimeout = null;
|
||||||
|
}
|
||||||
// Clean the buffer - remove screen init junk before actual content
|
// Clean the buffer - remove screen init junk before actual content
|
||||||
// Strip: cursor movement (\x1b[nA/B/C/D), positioning (\x1b[n;nH),
|
// Strip: cursor movement (\x1b[nA/B/C/D), positioning (\x1b[n;nH),
|
||||||
// clear screen (\x1b[2J), scroll region (\x1b[n;nr), and whitespace
|
// clear screen (\x1b[2J), scroll region (\x1b[n;nr), and whitespace
|
||||||
@@ -661,7 +675,13 @@ export class Session extends EventEmitter {
|
|||||||
}
|
}
|
||||||
}, 50);
|
}, 50);
|
||||||
// Timeout after 5 seconds if prompt not found
|
// Timeout after 5 seconds if prompt not found
|
||||||
setTimeout(() => clearInterval(checkForPrompt), 5000);
|
this._promptCheckTimeout = setTimeout(() => {
|
||||||
|
if (this._promptCheckInterval) {
|
||||||
|
clearInterval(this._promptCheckInterval);
|
||||||
|
this._promptCheckInterval = null;
|
||||||
|
}
|
||||||
|
this._promptCheckTimeout = null;
|
||||||
|
}, 5000);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[Session] Failed to create screen session, falling back to direct PTY:', err);
|
console.error('[Session] Failed to create screen session, falling back to direct PTY:', err);
|
||||||
@@ -745,6 +765,19 @@ export class Session extends EventEmitter {
|
|||||||
this.ptyProcess = null;
|
this.ptyProcess = null;
|
||||||
this._pid = null;
|
this._pid = null;
|
||||||
this._status = 'idle';
|
this._status = 'idle';
|
||||||
|
// Clear all timers to prevent memory leaks
|
||||||
|
if (this.activityTimeout) {
|
||||||
|
clearTimeout(this.activityTimeout);
|
||||||
|
this.activityTimeout = null;
|
||||||
|
}
|
||||||
|
if (this._promptCheckInterval) {
|
||||||
|
clearInterval(this._promptCheckInterval);
|
||||||
|
this._promptCheckInterval = null;
|
||||||
|
}
|
||||||
|
if (this._promptCheckTimeout) {
|
||||||
|
clearTimeout(this._promptCheckTimeout);
|
||||||
|
this._promptCheckTimeout = null;
|
||||||
|
}
|
||||||
// If using screen, mark the screen as detached but don't kill it
|
// If using screen, mark the screen as detached but don't kill it
|
||||||
if (this._screenSession && this._screenManager) {
|
if (this._screenSession && this._screenManager) {
|
||||||
this._screenManager.setAttached(this.id, false);
|
this._screenManager.setAttached(this.id, false);
|
||||||
@@ -867,6 +900,15 @@ export class Session extends EventEmitter {
|
|||||||
this.ptyProcess = null;
|
this.ptyProcess = null;
|
||||||
this._pid = null;
|
this._pid = null;
|
||||||
this._status = 'idle';
|
this._status = 'idle';
|
||||||
|
// Clear timers to prevent memory leaks
|
||||||
|
if (this._shellIdleTimer) {
|
||||||
|
clearTimeout(this._shellIdleTimer);
|
||||||
|
this._shellIdleTimer = null;
|
||||||
|
}
|
||||||
|
if (this.activityTimeout) {
|
||||||
|
clearTimeout(this.activityTimeout);
|
||||||
|
this.activityTimeout = null;
|
||||||
|
}
|
||||||
// If using screen, mark the screen as detached but don't kill it
|
// If using screen, mark the screen as detached but don't kill it
|
||||||
if (this._screenSession && this._screenManager) {
|
if (this._screenSession && this._screenManager) {
|
||||||
this._screenManager.setAttached(this.id, false);
|
this._screenManager.setAttached(this.id, false);
|
||||||
@@ -875,7 +917,8 @@ export class Session extends EventEmitter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Mark as idle after a short delay (shell is ready)
|
// Mark as idle after a short delay (shell is ready)
|
||||||
setTimeout(() => {
|
this._shellIdleTimer = setTimeout(() => {
|
||||||
|
this._shellIdleTimer = null;
|
||||||
this._status = 'idle';
|
this._status = 'idle';
|
||||||
this._isWorking = false;
|
this._isWorking = false;
|
||||||
this.emit('idle');
|
this.emit('idle');
|
||||||
@@ -1151,6 +1194,9 @@ export class Session extends EventEmitter {
|
|||||||
|
|
||||||
// Wait for Claude to be idle before compacting
|
// Wait for Claude to be idle before compacting
|
||||||
const checkAndCompact = () => {
|
const checkAndCompact = () => {
|
||||||
|
// Check if session is still valid (not stopped)
|
||||||
|
if (!this._isCompacting) return;
|
||||||
|
|
||||||
if (!this._isWorking) {
|
if (!this._isWorking) {
|
||||||
// Send /compact command with optional prompt
|
// Send /compact command with optional prompt
|
||||||
const compactCmd = this._autoCompactPrompt
|
const compactCmd = this._autoCompactPrompt
|
||||||
@@ -1164,17 +1210,18 @@ export class Session extends EventEmitter {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Wait a moment then re-enable (longer than clear since compact takes time)
|
// Wait a moment then re-enable (longer than clear since compact takes time)
|
||||||
setTimeout(() => {
|
this._autoCompactTimer = setTimeout(() => {
|
||||||
|
this._autoCompactTimer = null;
|
||||||
this._isCompacting = false;
|
this._isCompacting = false;
|
||||||
}, 10000);
|
}, 10000);
|
||||||
} else {
|
} else {
|
||||||
// Check again in 2 seconds
|
// Check again in 2 seconds
|
||||||
setTimeout(checkAndCompact, 2000);
|
this._autoCompactTimer = setTimeout(checkAndCompact, 2000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Start checking after a short delay
|
// Start checking after a short delay
|
||||||
setTimeout(checkAndCompact, 1000);
|
this._autoCompactTimer = setTimeout(checkAndCompact, 1000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1189,6 +1236,9 @@ export class Session extends EventEmitter {
|
|||||||
|
|
||||||
// Wait for Claude to be idle before clearing
|
// Wait for Claude to be idle before clearing
|
||||||
const checkAndClear = () => {
|
const checkAndClear = () => {
|
||||||
|
// Check if session is still valid (not stopped)
|
||||||
|
if (!this._isClearing) return;
|
||||||
|
|
||||||
if (!this._isWorking) {
|
if (!this._isWorking) {
|
||||||
// Send /clear command
|
// Send /clear command
|
||||||
this.writeViaScreen('/clear\r');
|
this.writeViaScreen('/clear\r');
|
||||||
@@ -1198,17 +1248,18 @@ export class Session extends EventEmitter {
|
|||||||
this.emit('autoClear', { tokens: totalTokens, threshold: this._autoClearThreshold });
|
this.emit('autoClear', { tokens: totalTokens, threshold: this._autoClearThreshold });
|
||||||
|
|
||||||
// Wait a moment then re-enable
|
// Wait a moment then re-enable
|
||||||
setTimeout(() => {
|
this._autoClearTimer = setTimeout(() => {
|
||||||
|
this._autoClearTimer = null;
|
||||||
this._isClearing = false;
|
this._isClearing = false;
|
||||||
}, 5000);
|
}, 5000);
|
||||||
} else {
|
} else {
|
||||||
// Check again in 2 seconds
|
// Check again in 2 seconds
|
||||||
setTimeout(checkAndClear, 2000);
|
this._autoClearTimer = setTimeout(checkAndClear, 2000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Start checking after a short delay
|
// Start checking after a short delay
|
||||||
setTimeout(checkAndClear, 1000);
|
this._autoClearTimer = setTimeout(checkAndClear, 1000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1342,6 +1393,35 @@ export class Session extends EventEmitter {
|
|||||||
this._lineBufferFlushTimer = null;
|
this._lineBufferFlushTimer = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear auto-compact/auto-clear timers to prevent memory leaks
|
||||||
|
if (this._autoCompactTimer) {
|
||||||
|
clearTimeout(this._autoCompactTimer);
|
||||||
|
this._autoCompactTimer = null;
|
||||||
|
}
|
||||||
|
this._isCompacting = false;
|
||||||
|
|
||||||
|
if (this._autoClearTimer) {
|
||||||
|
clearTimeout(this._autoClearTimer);
|
||||||
|
this._autoClearTimer = null;
|
||||||
|
}
|
||||||
|
this._isClearing = false;
|
||||||
|
|
||||||
|
// Clear prompt check timers
|
||||||
|
if (this._promptCheckInterval) {
|
||||||
|
clearInterval(this._promptCheckInterval);
|
||||||
|
this._promptCheckInterval = null;
|
||||||
|
}
|
||||||
|
if (this._promptCheckTimeout) {
|
||||||
|
clearTimeout(this._promptCheckTimeout);
|
||||||
|
this._promptCheckTimeout = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear shell idle timer
|
||||||
|
if (this._shellIdleTimer) {
|
||||||
|
clearTimeout(this._shellIdleTimer);
|
||||||
|
this._shellIdleTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
// Immediately cleanup Promise callbacks to prevent orphaned references
|
// Immediately cleanup Promise callbacks to prevent orphaned references
|
||||||
// during the rest of stop() processing (e.g., if screen kill times out)
|
// during the rest of stop() processing (e.g., if screen kill times out)
|
||||||
if (this.rejectPromise) {
|
if (this.rejectPromise) {
|
||||||
|
|||||||
+111
-12
@@ -68,8 +68,10 @@ export function App(): React.ReactElement {
|
|||||||
activeSessionId,
|
activeSessionId,
|
||||||
activeSession,
|
activeSession,
|
||||||
refreshSessions,
|
refreshSessions,
|
||||||
|
refreshCases,
|
||||||
selectSession,
|
selectSession,
|
||||||
createSession,
|
createSession,
|
||||||
|
createCase,
|
||||||
killSession,
|
killSession,
|
||||||
killAllSessions,
|
killAllSessions,
|
||||||
nextSession,
|
nextSession,
|
||||||
@@ -79,6 +81,9 @@ export function App(): React.ReactElement {
|
|||||||
innerLoopState,
|
innerLoopState,
|
||||||
innerTodos,
|
innerTodos,
|
||||||
respawnStatus,
|
respawnStatus,
|
||||||
|
cases,
|
||||||
|
toggleRespawn,
|
||||||
|
renameSession,
|
||||||
} = useSessionManager();
|
} = useSessionManager();
|
||||||
|
|
||||||
// Calculate terminal height based on stdout dimensions
|
// Calculate terminal height based on stdout dimensions
|
||||||
@@ -112,7 +117,7 @@ export function App(): React.ReactElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Exit on Ctrl+C (with confirmation in main view)
|
// Exit on Ctrl+C
|
||||||
if (key.ctrl && input === 'c') {
|
if (key.ctrl && input === 'c') {
|
||||||
exit();
|
exit();
|
||||||
return;
|
return;
|
||||||
@@ -129,7 +134,19 @@ export function App(): React.ReactElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Main view shortcuts
|
// === SESSION SWITCHING SHORTCUTS ===
|
||||||
|
|
||||||
|
// Tab / Shift+Tab to switch sessions (most intuitive)
|
||||||
|
if (key.tab) {
|
||||||
|
if (key.shift) {
|
||||||
|
prevSession();
|
||||||
|
} else {
|
||||||
|
nextSession();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ctrl+Tab / Ctrl+Shift+Tab (if terminal supports it)
|
||||||
if (key.ctrl && key.tab) {
|
if (key.ctrl && key.tab) {
|
||||||
if (key.shift) {
|
if (key.shift) {
|
||||||
prevSession();
|
prevSession();
|
||||||
@@ -139,8 +156,28 @@ export function App(): React.ReactElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ctrl+1-9 for direct tab access
|
// Alt+Left/Right arrow keys for session switching
|
||||||
if (key.ctrl) {
|
if (key.meta && key.leftArrow) {
|
||||||
|
prevSession();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key.meta && key.rightArrow) {
|
||||||
|
nextSession();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// [ and ] for previous/next session (vim-like)
|
||||||
|
if (input === '[' && !key.ctrl && !key.meta) {
|
||||||
|
prevSession();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (input === ']' && !key.ctrl && !key.meta) {
|
||||||
|
nextSession();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alt+1-9 or Ctrl+1-9 for direct tab access
|
||||||
|
if (key.ctrl || key.meta) {
|
||||||
const num = parseInt(input, 10);
|
const num = parseInt(input, 10);
|
||||||
if (!isNaN(num) && num >= 1 && num <= 9 && num <= sessions.length) {
|
if (!isNaN(num) && num >= 1 && num <= 9 && num <= sessions.length) {
|
||||||
selectSession(sessions[num - 1].sessionId);
|
selectSession(sessions[num - 1].sessionId);
|
||||||
@@ -148,6 +185,8 @@ export function App(): React.ReactElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === SESSION MANAGEMENT SHORTCUTS ===
|
||||||
|
|
||||||
// Ctrl+W to close current session
|
// Ctrl+W to close current session
|
||||||
if (key.ctrl && input === 'w') {
|
if (key.ctrl && input === 'w') {
|
||||||
if (activeSessionId) {
|
if (activeSessionId) {
|
||||||
@@ -172,20 +211,62 @@ export function App(): React.ReactElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Escape to go back to start screen
|
// Ctrl+R to toggle respawn on active session
|
||||||
|
if (key.ctrl && input === 'r') {
|
||||||
|
if (activeSessionId && activeSession?.mode === 'claude') {
|
||||||
|
toggleRespawn();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape to go back to start screen (doesn't close session)
|
||||||
if (key.escape) {
|
if (key.escape) {
|
||||||
setViewMode('start');
|
setViewMode('start');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Forward other input to the active session
|
// === FORWARD INPUT TO SESSION ===
|
||||||
if (activeSessionId && !key.ctrl && !key.meta) {
|
// Forward all other input to the active screen session
|
||||||
|
if (activeSessionId) {
|
||||||
|
// Handle special keys
|
||||||
if (key.return) {
|
if (key.return) {
|
||||||
sendInput(activeSessionId, '\r');
|
sendInput(activeSessionId, '\r');
|
||||||
} else if (key.backspace || key.delete) {
|
return;
|
||||||
|
}
|
||||||
|
if (key.backspace || key.delete) {
|
||||||
sendInput(activeSessionId, '\x7f');
|
sendInput(activeSessionId, '\x7f');
|
||||||
} else if (input) {
|
return;
|
||||||
|
}
|
||||||
|
// Arrow keys - send ANSI escape sequences
|
||||||
|
if (key.upArrow) {
|
||||||
|
sendInput(activeSessionId, '\x1b[A');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key.downArrow) {
|
||||||
|
sendInput(activeSessionId, '\x1b[B');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key.rightArrow && !key.meta) {
|
||||||
|
sendInput(activeSessionId, '\x1b[C');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key.leftArrow && !key.meta) {
|
||||||
|
sendInput(activeSessionId, '\x1b[D');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Ctrl+key combinations (send as control characters)
|
||||||
|
if (key.ctrl && input) {
|
||||||
|
// Convert to control character (Ctrl+A = 0x01, Ctrl+B = 0x02, etc.)
|
||||||
|
const code = input.toLowerCase().charCodeAt(0) - 96;
|
||||||
|
if (code >= 1 && code <= 26) {
|
||||||
|
sendInput(activeSessionId, String.fromCharCode(code));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Regular character input
|
||||||
|
if (input && !key.ctrl && !key.meta) {
|
||||||
sendInput(activeSessionId, input);
|
sendInput(activeSessionId, input);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -195,9 +276,24 @@ export function App(): React.ReactElement {
|
|||||||
setViewMode('main');
|
setViewMode('main');
|
||||||
}, [selectSession]);
|
}, [selectSession]);
|
||||||
|
|
||||||
const handleCreateSession = useCallback(async () => {
|
const handleCreateSession = useCallback(async (caseName?: string, count?: number, mode: 'claude' | 'shell' = 'claude') => {
|
||||||
const sessionId = await createSession();
|
// Default to 'default' case if no case name provided (like web UI)
|
||||||
if (sessionId) {
|
const sessionsToCreate = Math.min(Math.max(count || 1, 1), 20);
|
||||||
|
let lastSessionId: string | null = null;
|
||||||
|
|
||||||
|
// Create sessions sequentially to avoid overwhelming the server
|
||||||
|
for (let i = 0; i < sessionsToCreate; i++) {
|
||||||
|
const sessionId = await createSession(caseName || 'default', mode);
|
||||||
|
if (sessionId) {
|
||||||
|
lastSessionId = sessionId;
|
||||||
|
}
|
||||||
|
// Small delay between session creations to allow server to process
|
||||||
|
if (i < sessionsToCreate - 1) {
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 300));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lastSessionId) {
|
||||||
setViewMode('main');
|
setViewMode('main');
|
||||||
}
|
}
|
||||||
}, [createSession]);
|
}, [createSession]);
|
||||||
@@ -239,11 +335,14 @@ export function App(): React.ReactElement {
|
|||||||
return (
|
return (
|
||||||
<StartScreen
|
<StartScreen
|
||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
|
cases={cases}
|
||||||
onSelectSession={handleSelectSession}
|
onSelectSession={handleSelectSession}
|
||||||
onAttachSession={handleAttachSession}
|
onAttachSession={handleAttachSession}
|
||||||
onDeleteSession={handleDeleteSession}
|
onDeleteSession={handleDeleteSession}
|
||||||
onCreateSession={handleCreateSession}
|
onCreateSession={handleCreateSession}
|
||||||
|
onCreateCase={createCase}
|
||||||
onRefresh={refreshSessions}
|
onRefresh={refreshSessions}
|
||||||
|
onRefreshCases={refreshCases}
|
||||||
onExit={exit}
|
onExit={exit}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -27,17 +27,30 @@ interface ShortcutGroup {
|
|||||||
|
|
||||||
const SHORTCUT_GROUPS: ShortcutGroup[] = [
|
const SHORTCUT_GROUPS: ShortcutGroup[] = [
|
||||||
{
|
{
|
||||||
title: 'Start Screen',
|
title: 'Start Screen - Sessions',
|
||||||
shortcuts: [
|
shortcuts: [
|
||||||
{ key: '\u2191/\u2193', description: 'Navigate session list' },
|
{ key: '\u2191/\u2193', description: 'Navigate list' },
|
||||||
{ key: 'Enter', description: 'View session in TUI' },
|
{ key: 'Enter', description: 'View session in TUI' },
|
||||||
{ key: 'a', description: 'Attach to screen (full terminal)' },
|
{ key: 'a', description: 'Attach to screen (full terminal)' },
|
||||||
{ key: 'd', description: 'Delete/kill selected session' },
|
{ key: 'd', description: 'Delete/kill selected session' },
|
||||||
{ key: 'n', description: 'Create new session' },
|
{ key: 'c', description: 'Switch to cases view' },
|
||||||
{ key: 'r', description: 'Refresh session list' },
|
{ key: 'n', description: 'Quick-start new session' },
|
||||||
|
{ key: 'r', description: 'Refresh list' },
|
||||||
{ key: 'q', description: 'Quit' },
|
{ key: 'q', description: 'Quit' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: 'Start Screen - Cases',
|
||||||
|
shortcuts: [
|
||||||
|
{ key: '\u2191/\u2193', description: 'Navigate list' },
|
||||||
|
{ key: 'Enter', description: 'Start Claude session with case' },
|
||||||
|
{ key: 'h', description: 'Start Shell session with case' },
|
||||||
|
{ key: 'm', description: 'Multi-start (1-20 sessions)' },
|
||||||
|
{ key: 'n', description: 'Create new case' },
|
||||||
|
{ key: 's', description: 'Switch to sessions view' },
|
||||||
|
{ key: 'r', description: 'Refresh list' },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: 'Main View - Navigation',
|
title: 'Main View - Navigation',
|
||||||
shortcuts: [
|
shortcuts: [
|
||||||
@@ -53,6 +66,7 @@ const SHORTCUT_GROUPS: ShortcutGroup[] = [
|
|||||||
{ key: 'Ctrl+N', description: 'New session' },
|
{ key: 'Ctrl+N', description: 'New session' },
|
||||||
{ key: 'Ctrl+W', description: 'Close current session' },
|
{ key: 'Ctrl+W', description: 'Close current session' },
|
||||||
{ key: 'Ctrl+K', description: 'Kill all sessions' },
|
{ key: 'Ctrl+K', description: 'Kill all sessions' },
|
||||||
|
{ key: 'Ctrl+R', description: 'Toggle respawn (Claude sessions only)' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
+477
-129
@@ -5,28 +5,40 @@
|
|||||||
*
|
*
|
||||||
* @description
|
* @description
|
||||||
* The initial screen displayed when launching `claudeman tui`:
|
* The initial screen displayed when launching `claudeman tui`:
|
||||||
* - Reads session list from ~/.claudeman/screens.json
|
* - Shows existing sessions and available cases
|
||||||
* - Shows session name, runtime, status (alive/dead), and mode
|
* - Supports two modes: session list and case selection
|
||||||
* - Arrow key navigation with visual selection highlight
|
* - Arrow key navigation with visual selection highlight
|
||||||
* - Actions: Enter (view), a (attach), d (delete), n (new), r (refresh), q (quit)
|
* - Actions: Enter (view/select), a (attach), d (delete), n (new), c (cases), r (refresh), q (quit)
|
||||||
*
|
*
|
||||||
* This is the "home screen" users return to with Escape from the main view.
|
* This is the "home screen" users return to with Escape from the main view.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Box, Text, useInput } from 'ink';
|
import { Box, Text, useInput } from 'ink';
|
||||||
|
import TextInput from 'ink-text-input';
|
||||||
import type { ScreenSession } from '../../types.js';
|
import type { ScreenSession } from '../../types.js';
|
||||||
|
|
||||||
|
interface CaseInfo {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
hasClaudeMd: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface StartScreenProps {
|
interface StartScreenProps {
|
||||||
sessions: ScreenSession[];
|
sessions: ScreenSession[];
|
||||||
|
cases: CaseInfo[];
|
||||||
onSelectSession: (session: ScreenSession) => void;
|
onSelectSession: (session: ScreenSession) => void;
|
||||||
onAttachSession: (session: ScreenSession) => void;
|
onAttachSession: (session: ScreenSession) => void;
|
||||||
onDeleteSession: (session: ScreenSession) => void;
|
onDeleteSession: (session: ScreenSession) => void;
|
||||||
onCreateSession: () => void;
|
onCreateSession: (caseName?: string, count?: number, mode?: 'claude' | 'shell') => void;
|
||||||
|
onCreateCase: (name: string) => Promise<boolean>;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
|
onRefreshCases: () => void;
|
||||||
onExit: () => void;
|
onExit: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ScreenMode = 'sessions' | 'cases' | 'new-case' | 'multi-start';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats a duration from milliseconds to a compact human-readable string.
|
* Formats a duration from milliseconds to a compact human-readable string.
|
||||||
*
|
*
|
||||||
@@ -55,86 +67,155 @@ function formatDuration(ms: number): string {
|
|||||||
* Start screen component for session discovery and selection.
|
* Start screen component for session discovery and selection.
|
||||||
*
|
*
|
||||||
* @description
|
* @description
|
||||||
* Renders a table of available sessions with:
|
* Renders two views:
|
||||||
* - Arrow key navigation (wraps at boundaries)
|
* - Sessions view: List of active sessions
|
||||||
* - Visual selection highlight (blue background)
|
* - Cases view: List of available cases to start a new session
|
||||||
* - Status indicators (green=alive, red=dead)
|
|
||||||
* - Runtime and mode information
|
|
||||||
*
|
|
||||||
* **Keyboard Shortcuts:**
|
|
||||||
* - `↑/↓`: Navigate selection
|
|
||||||
* - `Enter`: View session in TUI
|
|
||||||
* - `a`: Attach directly to screen (exits TUI)
|
|
||||||
* - `d/x`: Delete/kill selected session
|
|
||||||
*
|
*
|
||||||
* @param props - Component props
|
* @param props - Component props
|
||||||
* @param props.sessions - Array of sessions to display
|
|
||||||
* @param props.onSelectSession - Callback to view session in TUI
|
|
||||||
* @param props.onAttachSession - Callback to attach directly to screen
|
|
||||||
* @param props.onDeleteSession - Callback to delete/kill session
|
|
||||||
* @param props.onCreateSession - Callback to create new session
|
|
||||||
* @param props.onRefresh - Callback to refresh session list
|
|
||||||
* @param props.onExit - Callback to exit TUI
|
|
||||||
* @returns The start screen element
|
* @returns The start screen element
|
||||||
*/
|
*/
|
||||||
export function StartScreen({
|
export function StartScreen({
|
||||||
sessions,
|
sessions,
|
||||||
|
cases,
|
||||||
onSelectSession,
|
onSelectSession,
|
||||||
onAttachSession,
|
onAttachSession,
|
||||||
onDeleteSession,
|
onDeleteSession,
|
||||||
onCreateSession,
|
onCreateSession,
|
||||||
|
onCreateCase,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
|
onRefreshCases,
|
||||||
onExit,
|
onExit,
|
||||||
}: StartScreenProps): React.ReactElement {
|
}: StartScreenProps): React.ReactElement {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
|
const [mode, setMode] = useState<ScreenMode>('sessions');
|
||||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||||
|
const [newCaseName, setNewCaseName] = useState('');
|
||||||
|
const [multiStartCount, setMultiStartCount] = useState('1');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
// Ensure selectedIndex is valid when sessions change
|
// Reset selection when switching modes
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedIndex >= sessions.length && sessions.length > 0) {
|
setSelectedIndex(0);
|
||||||
setSelectedIndex(sessions.length - 1);
|
setError(null);
|
||||||
|
if (mode !== 'multi-start') {
|
||||||
|
setMultiStartCount('1');
|
||||||
}
|
}
|
||||||
}, [sessions.length, selectedIndex]);
|
}, [mode]);
|
||||||
|
|
||||||
|
// Ensure selectedIndex is valid when list changes
|
||||||
|
useEffect(() => {
|
||||||
|
const maxIndex = mode === 'sessions' ? sessions.length - 1 : cases.length - 1;
|
||||||
|
if (selectedIndex > maxIndex && maxIndex >= 0) {
|
||||||
|
setSelectedIndex(maxIndex);
|
||||||
|
}
|
||||||
|
}, [sessions.length, cases.length, selectedIndex, mode]);
|
||||||
|
|
||||||
// Handle keyboard input for navigation
|
// Handle keyboard input for navigation
|
||||||
useInput((input, key) => {
|
useInput((input, key) => {
|
||||||
|
// New case input mode - only handle escape and return
|
||||||
|
if (mode === 'new-case') {
|
||||||
|
if (key.escape) {
|
||||||
|
setMode('cases');
|
||||||
|
setNewCaseName('');
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multi-start input mode - only handle escape and return
|
||||||
|
if (mode === 'multi-start') {
|
||||||
|
if (key.escape) {
|
||||||
|
setMode('cases');
|
||||||
|
setMultiStartCount('1');
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Arrow key navigation
|
// Arrow key navigation
|
||||||
if (key.upArrow && sessions.length > 0) {
|
const listLength = mode === 'sessions' ? sessions.length : cases.length;
|
||||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : sessions.length - 1));
|
if (key.upArrow && listLength > 0) {
|
||||||
|
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : listLength - 1));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (key.downArrow && sessions.length > 0) {
|
if (key.downArrow && listLength > 0) {
|
||||||
setSelectedIndex((prev) => (prev < sessions.length - 1 ? prev + 1 : 0));
|
setSelectedIndex((prev) => (prev < listLength - 1 ? prev + 1 : 0));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enter to view session in TUI
|
// Enter to select
|
||||||
if (key.return && sessions.length > 0) {
|
if (key.return && listLength > 0) {
|
||||||
onSelectSession(sessions[selectedIndex]);
|
if (mode === 'sessions') {
|
||||||
|
onSelectSession(sessions[selectedIndex]);
|
||||||
|
} else if (mode === 'cases') {
|
||||||
|
// Start session with selected case
|
||||||
|
onCreateSession(cases[selectedIndex].name);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 'a' to attach directly to screen
|
// Mode-specific shortcuts
|
||||||
if (input === 'a' && sessions.length > 0 && sessions[selectedIndex].attached) {
|
if (mode === 'sessions') {
|
||||||
onAttachSession(sessions[selectedIndex]);
|
// 'a' to attach directly to screen
|
||||||
return;
|
if (input === 'a' && sessions.length > 0 && sessions[selectedIndex].attached) {
|
||||||
|
onAttachSession(sessions[selectedIndex]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 'd' or 'x' to delete/kill session
|
||||||
|
if ((input === 'd' || input === 'x') && sessions.length > 0) {
|
||||||
|
onDeleteSession(sessions[selectedIndex]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 'c' to switch to cases view
|
||||||
|
if (input === 'c') {
|
||||||
|
onRefreshCases();
|
||||||
|
setMode('cases');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 'n' to create new session (quick start with auto name)
|
||||||
|
if (input === 'n') {
|
||||||
|
onCreateSession();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else if (mode === 'cases') {
|
||||||
|
// 's' to switch back to sessions view
|
||||||
|
if (input === 's') {
|
||||||
|
setMode('sessions');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 'n' to create new case
|
||||||
|
if (input === 'n') {
|
||||||
|
setMode('new-case');
|
||||||
|
setNewCaseName('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 'm' to start multiple sessions with selected case
|
||||||
|
if (input === 'm' && cases.length > 0) {
|
||||||
|
setMode('multi-start');
|
||||||
|
setMultiStartCount('1');
|
||||||
|
setError(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 'h' to start a shell session with selected case
|
||||||
|
if (input === 'h' && cases.length > 0) {
|
||||||
|
onCreateSession(cases[selectedIndex].name, 1, 'shell');
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 'd' or 'x' to delete/kill session
|
// 'r' to refresh
|
||||||
if ((input === 'd' || input === 'x') && sessions.length > 0) {
|
|
||||||
onDeleteSession(sessions[selectedIndex]);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 'n' to create new session
|
|
||||||
if (input === 'n') {
|
|
||||||
onCreateSession();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 'r' to refresh session list
|
|
||||||
if (input === 'r') {
|
if (input === 'r') {
|
||||||
onRefresh();
|
if (mode === 'sessions') {
|
||||||
|
onRefresh();
|
||||||
|
} else {
|
||||||
|
onRefreshCases();
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -145,6 +226,168 @@ export function StartScreen({
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Handle new case name submission
|
||||||
|
const handleNewCaseSubmit = async (value: string) => {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) {
|
||||||
|
setError('Case name cannot be empty');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
|
||||||
|
setError('Use only letters, numbers, hyphens, underscores');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await onCreateCase(trimmed);
|
||||||
|
if (success) {
|
||||||
|
setMode('cases');
|
||||||
|
setNewCaseName('');
|
||||||
|
setError(null);
|
||||||
|
} else {
|
||||||
|
setError('Failed to create case (may already exist)');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Handle multi-start submission
|
||||||
|
const handleMultiStartSubmit = (value: string) => {
|
||||||
|
const count = parseInt(value.trim(), 10);
|
||||||
|
if (isNaN(count) || count < 1) {
|
||||||
|
setError('Enter a number from 1 to 20');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (count > 20) {
|
||||||
|
setError('Maximum 20 sessions at once');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the selected case name
|
||||||
|
const selectedCase = cases[selectedIndex];
|
||||||
|
if (!selectedCase) {
|
||||||
|
setError('No case selected');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the sessions
|
||||||
|
onCreateSession(selectedCase.name, count);
|
||||||
|
setMode('cases');
|
||||||
|
setMultiStartCount('1');
|
||||||
|
setError(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get currently selected item for display
|
||||||
|
const getSelectedItem = (): { type: 'session' | 'case'; name: string; path?: string } | null => {
|
||||||
|
if (mode === 'sessions' && sessions.length > 0 && selectedIndex < sessions.length) {
|
||||||
|
const session = sessions[selectedIndex];
|
||||||
|
return { type: 'session', name: session.name || 'unnamed', path: session.workingDir };
|
||||||
|
}
|
||||||
|
if ((mode === 'cases' || mode === 'multi-start') && cases.length > 0 && selectedIndex < cases.length) {
|
||||||
|
const caseInfo = cases[selectedIndex];
|
||||||
|
return { type: 'case', name: caseInfo.name, path: caseInfo.path };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedItem = getSelectedItem();
|
||||||
|
|
||||||
|
// Render new case input mode
|
||||||
|
if (mode === 'new-case') {
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" padding={1}>
|
||||||
|
<Box
|
||||||
|
borderStyle="double"
|
||||||
|
borderColor="cyan"
|
||||||
|
paddingX={2}
|
||||||
|
paddingY={1}
|
||||||
|
justifyContent="center"
|
||||||
|
>
|
||||||
|
<Text bold color="cyan">
|
||||||
|
Create New Case
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box marginY={1} flexDirection="column">
|
||||||
|
<Text>Enter case name (letters, numbers, hyphens, underscores):</Text>
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color="green">> </Text>
|
||||||
|
<TextInput
|
||||||
|
value={newCaseName}
|
||||||
|
onChange={setNewCaseName}
|
||||||
|
onSubmit={handleNewCaseSubmit}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{error && (
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color="red">{error}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box marginTop={2} borderStyle="single" borderColor="gray" paddingX={1}>
|
||||||
|
<Text>
|
||||||
|
<Text color="green">[Enter]</Text>
|
||||||
|
<Text> Create </Text>
|
||||||
|
<Text color="yellow">[Esc]</Text>
|
||||||
|
<Text> Cancel</Text>
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render multi-start input mode
|
||||||
|
if (mode === 'multi-start') {
|
||||||
|
const selectedCase = cases[selectedIndex];
|
||||||
|
return (
|
||||||
|
<Box flexDirection="column" padding={1}>
|
||||||
|
<Box
|
||||||
|
borderStyle="double"
|
||||||
|
borderColor="cyan"
|
||||||
|
paddingX={2}
|
||||||
|
paddingY={1}
|
||||||
|
justifyContent="center"
|
||||||
|
>
|
||||||
|
<Text bold color="cyan">
|
||||||
|
Start Multiple Sessions
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Show selected case */}
|
||||||
|
<Box marginY={1} borderStyle="single" borderColor="green" paddingX={2}>
|
||||||
|
<Text>
|
||||||
|
<Text color="green" bold>Selected Case: </Text>
|
||||||
|
<Text bold color="white">{selectedCase?.name || 'none'}</Text>
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box marginY={1} flexDirection="column">
|
||||||
|
<Text>How many sessions to start? (1-20):</Text>
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color="green">> </Text>
|
||||||
|
<TextInput
|
||||||
|
value={multiStartCount}
|
||||||
|
onChange={setMultiStartCount}
|
||||||
|
onSubmit={handleMultiStartSubmit}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
{error && (
|
||||||
|
<Box marginTop={1}>
|
||||||
|
<Text color="red">{error}</Text>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box marginTop={2} borderStyle="single" borderColor="gray" paddingX={1}>
|
||||||
|
<Text>
|
||||||
|
<Text color="green">[Enter]</Text>
|
||||||
|
<Text> Start Sessions </Text>
|
||||||
|
<Text color="yellow">[Esc]</Text>
|
||||||
|
<Text> Cancel</Text>
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box flexDirection="column" padding={1}>
|
<Box flexDirection="column" padding={1}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -160,88 +403,193 @@ export function StartScreen({
|
|||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Mode tabs */}
|
||||||
<Box marginY={1}>
|
<Box marginY={1}>
|
||||||
<Text dimColor>Session Manager - Press ? for help</Text>
|
<Text>
|
||||||
|
<Text
|
||||||
|
backgroundColor={mode === 'sessions' ? 'blue' : undefined}
|
||||||
|
color={mode === 'sessions' ? 'white' : 'gray'}
|
||||||
|
bold={mode === 'sessions'}
|
||||||
|
>
|
||||||
|
{' [s] Sessions '}
|
||||||
|
</Text>
|
||||||
|
<Text> </Text>
|
||||||
|
<Text
|
||||||
|
backgroundColor={mode === 'cases' ? 'blue' : undefined}
|
||||||
|
color={mode === 'cases' ? 'white' : 'gray'}
|
||||||
|
bold={mode === 'cases'}
|
||||||
|
>
|
||||||
|
{' [c] Cases '}
|
||||||
|
</Text>
|
||||||
|
<Text dimColor> | Press ? for help</Text>
|
||||||
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
{/* Session list */}
|
{/* Selected item display */}
|
||||||
{sessions.length === 0 ? (
|
{selectedItem && (
|
||||||
<Box flexDirection="column" marginY={1}>
|
<Box marginBottom={1} borderStyle="single" borderColor="green" paddingX={2}>
|
||||||
<Text color="yellow">No sessions found</Text>
|
<Text>
|
||||||
<Text dimColor>Press [n] to create a new session</Text>
|
<Text color="green" bold>Selected: </Text>
|
||||||
</Box>
|
<Text bold color="white">{selectedItem.name}</Text>
|
||||||
) : (
|
{selectedItem.path && (
|
||||||
<Box flexDirection="column">
|
<Text dimColor> ({selectedItem.path.replace(process.env.HOME || '', '~')})</Text>
|
||||||
{/* Table header */}
|
)}
|
||||||
<Box marginBottom={1}>
|
</Text>
|
||||||
<Text bold>
|
|
||||||
<Text color="gray">{' '}</Text>
|
|
||||||
<Text>{'NAME'.padEnd(22)}</Text>
|
|
||||||
<Text>{'RUNTIME'.padEnd(12)}</Text>
|
|
||||||
<Text>{'STATUS'.padEnd(10)}</Text>
|
|
||||||
<Text>{'MODE'.padEnd(10)}</Text>
|
|
||||||
</Text>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
{/* Session rows */}
|
|
||||||
{sessions.map((session, index) => {
|
|
||||||
const runtime = formatDuration(now - session.createdAt);
|
|
||||||
const statusColor = session.attached ? 'green' : 'red';
|
|
||||||
const statusIcon = session.attached ? '\u25CF' : '\u25CB';
|
|
||||||
const statusText = session.attached ? 'alive' : 'dead';
|
|
||||||
const name = (session.name || 'unnamed').slice(0, 20);
|
|
||||||
const isSelected = index === selectedIndex;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Box key={session.sessionId}>
|
|
||||||
{isSelected ? (
|
|
||||||
<Text backgroundColor="blue" color="white">
|
|
||||||
<Text color="cyan" bold>{' \u25B6 '}</Text>
|
|
||||||
<Text bold>{name.padEnd(22)}</Text>
|
|
||||||
<Text>{runtime.padEnd(12)}</Text>
|
|
||||||
<Text color={statusColor}>
|
|
||||||
{statusIcon} {statusText.padEnd(8)}
|
|
||||||
</Text>
|
|
||||||
<Text>{session.mode.padEnd(10)}</Text>
|
|
||||||
</Text>
|
|
||||||
) : (
|
|
||||||
<Text>
|
|
||||||
<Text color="gray">{' '}</Text>
|
|
||||||
<Text>{name.padEnd(22)}</Text>
|
|
||||||
<Text dimColor>{runtime.padEnd(12)}</Text>
|
|
||||||
<Text color={statusColor}>
|
|
||||||
{statusIcon} {statusText.padEnd(8)}
|
|
||||||
</Text>
|
|
||||||
<Text>{session.mode.padEnd(10)}</Text>
|
|
||||||
</Text>
|
|
||||||
)}
|
|
||||||
</Box>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Footer with controls */}
|
{/* Sessions view */}
|
||||||
<Box marginTop={2} flexDirection="column">
|
{mode === 'sessions' && (
|
||||||
<Box borderStyle="single" borderColor="gray" paddingX={1}>
|
<>
|
||||||
<Text>
|
{sessions.length === 0 ? (
|
||||||
<Text color="green">[n]</Text>
|
<Box flexDirection="column" marginY={1}>
|
||||||
<Text> New </Text>
|
<Text color="yellow">No sessions found</Text>
|
||||||
<Text color="green">[{'\u2191\u2193'}]</Text>
|
<Text dimColor>Press [n] to create a new session, or [c] to select a case</Text>
|
||||||
<Text> Navigate </Text>
|
</Box>
|
||||||
<Text color="green">[Enter]</Text>
|
) : (
|
||||||
<Text> View </Text>
|
<Box flexDirection="column">
|
||||||
<Text color="green">[a]</Text>
|
{/* Table header */}
|
||||||
<Text> Attach </Text>
|
<Box marginBottom={1}>
|
||||||
<Text color="red">[d]</Text>
|
<Text bold>
|
||||||
<Text> Delete </Text>
|
<Text color="gray">{' '}</Text>
|
||||||
<Text color="green">[r]</Text>
|
<Text>{'NAME'.padEnd(22)}</Text>
|
||||||
<Text> Refresh </Text>
|
<Text>{'RUNTIME'.padEnd(12)}</Text>
|
||||||
<Text color="yellow">[q]</Text>
|
<Text>{'STATUS'.padEnd(10)}</Text>
|
||||||
<Text> Quit</Text>
|
<Text>{'MODE'.padEnd(10)}</Text>
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
|
||||||
|
{/* Session rows */}
|
||||||
|
{sessions.map((session, index) => {
|
||||||
|
const runtime = formatDuration(now - session.createdAt);
|
||||||
|
const statusColor = session.attached ? 'green' : 'red';
|
||||||
|
const statusIcon = session.attached ? '\u25CF' : '\u25CB';
|
||||||
|
const statusText = session.attached ? 'alive' : 'dead';
|
||||||
|
const name = (session.name || 'unnamed').slice(0, 20);
|
||||||
|
const isSelected = index === selectedIndex;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box key={session.sessionId}>
|
||||||
|
{isSelected ? (
|
||||||
|
<Text backgroundColor="blue" color="white">
|
||||||
|
<Text color="cyan" bold>{' \u25B6 '}</Text>
|
||||||
|
<Text bold>{name.padEnd(22)}</Text>
|
||||||
|
<Text>{runtime.padEnd(12)}</Text>
|
||||||
|
<Text color={statusColor}>
|
||||||
|
{statusIcon} {statusText.padEnd(8)}
|
||||||
|
</Text>
|
||||||
|
<Text>{session.mode.padEnd(10)}</Text>
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text>
|
||||||
|
<Text color="gray">{' '}</Text>
|
||||||
|
<Text>{name.padEnd(22)}</Text>
|
||||||
|
<Text dimColor>{runtime.padEnd(12)}</Text>
|
||||||
|
<Text color={statusColor}>
|
||||||
|
{statusIcon} {statusText.padEnd(8)}
|
||||||
|
</Text>
|
||||||
|
<Text>{session.mode.padEnd(10)}</Text>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Sessions footer */}
|
||||||
|
<Box marginTop={2} flexDirection="column">
|
||||||
|
<Box borderStyle="single" borderColor="gray" paddingX={1}>
|
||||||
|
<Text>
|
||||||
|
<Text color="green">[n]</Text>
|
||||||
|
<Text> New </Text>
|
||||||
|
<Text color="green">[c]</Text>
|
||||||
|
<Text> Cases </Text>
|
||||||
|
<Text color="green">[{'\u2191\u2193'}]</Text>
|
||||||
|
<Text> Navigate </Text>
|
||||||
|
<Text color="green">[Enter]</Text>
|
||||||
|
<Text> View </Text>
|
||||||
|
<Text color="green">[a]</Text>
|
||||||
|
<Text> Attach </Text>
|
||||||
|
<Text color="red">[d]</Text>
|
||||||
|
<Text> Delete </Text>
|
||||||
|
<Text color="yellow">[q]</Text>
|
||||||
|
<Text> Quit</Text>
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cases view */}
|
||||||
|
{mode === 'cases' && (
|
||||||
|
<>
|
||||||
|
{cases.length === 0 ? (
|
||||||
|
<Box flexDirection="column" marginY={1}>
|
||||||
|
<Text color="yellow">No cases found</Text>
|
||||||
|
<Text dimColor>Press [n] to create a new case</Text>
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box flexDirection="column">
|
||||||
|
{/* Table header */}
|
||||||
|
<Box marginBottom={1}>
|
||||||
|
<Text bold>
|
||||||
|
<Text color="gray">{' '}</Text>
|
||||||
|
<Text>{'CASE NAME'.padEnd(30)}</Text>
|
||||||
|
<Text>{'PATH'.padEnd(40)}</Text>
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Case rows */}
|
||||||
|
{cases.map((caseInfo, index) => {
|
||||||
|
const isSelected = index === selectedIndex;
|
||||||
|
const name = caseInfo.name.slice(0, 28);
|
||||||
|
const path = caseInfo.path.replace(process.env.HOME || '', '~').slice(0, 38);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box key={caseInfo.name}>
|
||||||
|
{isSelected ? (
|
||||||
|
<Text backgroundColor="blue" color="white">
|
||||||
|
<Text color="cyan" bold>{' \u25B6 '}</Text>
|
||||||
|
<Text bold>{name.padEnd(30)}</Text>
|
||||||
|
<Text>{path.padEnd(40)}</Text>
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text>
|
||||||
|
<Text color="gray">{' '}</Text>
|
||||||
|
<Text>{name.padEnd(30)}</Text>
|
||||||
|
<Text dimColor>{path.padEnd(40)}</Text>
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Cases footer */}
|
||||||
|
<Box marginTop={2} flexDirection="column">
|
||||||
|
<Box borderStyle="single" borderColor="gray" paddingX={1}>
|
||||||
|
<Text>
|
||||||
|
<Text color="green">[Enter]</Text>
|
||||||
|
<Text> Claude </Text>
|
||||||
|
<Text color="yellow">[h]</Text>
|
||||||
|
<Text> Shell </Text>
|
||||||
|
<Text color="cyan">[m]</Text>
|
||||||
|
<Text> Multi (1-20) </Text>
|
||||||
|
<Text color="green">[n]</Text>
|
||||||
|
<Text> New Case </Text>
|
||||||
|
<Text color="green">[s]</Text>
|
||||||
|
<Text> Sessions </Text>
|
||||||
|
<Text color="green">[r]</Text>
|
||||||
|
<Text> Refresh </Text>
|
||||||
|
<Text color="yellow">[q]</Text>
|
||||||
|
<Text> Quit</Text>
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,12 +34,112 @@ const SCREENS_FILE = join(homedir(), '.claudeman', 'screens.json');
|
|||||||
const INNER_STATE_FILE = join(homedir(), '.claudeman', 'state-inner.json');
|
const INNER_STATE_FILE = join(homedir(), '.claudeman', 'state-inner.json');
|
||||||
const OUTPUT_POLL_INTERVAL = 500; // Poll terminal output every 500ms
|
const OUTPUT_POLL_INTERVAL = 500; // Poll terminal output every 500ms
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Emoji to ASCII replacement map for screen hardcopy output.
|
||||||
|
* GNU screen's hardcopy doesn't handle UTF-8 multi-byte characters well,
|
||||||
|
* so we replace common Claude Code emoji with ASCII equivalents.
|
||||||
|
*/
|
||||||
|
const EMOJI_REPLACEMENTS: [RegExp, string][] = [
|
||||||
|
// Claude Code logo/branding
|
||||||
|
[/\u{1F9E0}/gu, '*'], // 🧠 brain -> *
|
||||||
|
[/\u{2728}/gu, '*'], // ✨ sparkles -> *
|
||||||
|
[/\u{1F4AC}/gu, '>'], // 💬 speech bubble -> >
|
||||||
|
[/\u{1F916}/gu, '[bot]'], // 🤖 robot -> [bot]
|
||||||
|
|
||||||
|
// Status indicators
|
||||||
|
[/\u{2714}/gu, '[ok]'], // ✔ check mark -> [ok]
|
||||||
|
[/\u{2705}/gu, '[ok]'], // ✅ check mark button -> [ok]
|
||||||
|
[/\u{274C}/gu, '[x]'], // ❌ cross mark -> [x]
|
||||||
|
[/\u{26A0}/gu, '[!]'], // ⚠ warning -> [!]
|
||||||
|
[/\u{2139}/gu, '[i]'], // ℹ info -> [i]
|
||||||
|
[/\u{1F6A8}/gu, '[!]'], // 🚨 rotating light -> [!]
|
||||||
|
|
||||||
|
// Progress/activity
|
||||||
|
[/\u{23F3}/gu, '...'], // ⏳ hourglass -> ...
|
||||||
|
[/\u{231B}/gu, '...'], // ⌛ hourglass done -> ...
|
||||||
|
[/\u{1F504}/gu, '(...)'], // 🔄 refresh -> (...)
|
||||||
|
[/\u{25B6}/gu, '>'], // ▶ play -> >
|
||||||
|
[/\u{23F8}/gu, '||'], // ⏸ pause -> ||
|
||||||
|
[/\u{23F9}/gu, '[]'], // ⏹ stop -> []
|
||||||
|
|
||||||
|
// File/folder icons
|
||||||
|
[/\u{1F4C1}/gu, '[dir]'], // 📁 folder -> [dir]
|
||||||
|
[/\u{1F4C2}/gu, '[dir]'], // 📂 open folder -> [dir]
|
||||||
|
[/\u{1F4C4}/gu, '[file]'], // 📄 file -> [file]
|
||||||
|
[/\u{1F4DD}/gu, '[edit]'], // 📝 memo -> [edit]
|
||||||
|
|
||||||
|
// Arrows and navigation
|
||||||
|
[/\u{2190}/gu, '<-'], // ← left arrow
|
||||||
|
[/\u{2192}/gu, '->'], // → right arrow
|
||||||
|
[/\u{2191}/gu, '^'], // ↑ up arrow
|
||||||
|
[/\u{2193}/gu, 'v'], // ↓ down arrow
|
||||||
|
[/\u{21B5}/gu, '<CR>'], // ↵ return symbol -> <CR>
|
||||||
|
[/\u{23CE}/gu, '<CR>'], // ⏎ return symbol -> <CR>
|
||||||
|
|
||||||
|
// Special Unicode box-drawing and symbols that may corrupt
|
||||||
|
[/\u{25B8}/gu, '>'], // ▸ small right triangle
|
||||||
|
[/\u{25B9}/gu, '>'], // ▹ white small right triangle
|
||||||
|
[/\u{2022}/gu, '-'], // • bullet -> -
|
||||||
|
[/\u{25CF}/gu, 'o'], // ● black circle -> o
|
||||||
|
[/\u{25CB}/gu, 'o'], // ○ white circle -> o
|
||||||
|
[/\u{25A0}/gu, '#'], // ■ black square -> #
|
||||||
|
[/\u{25A1}/gu, '[]'], // □ white square -> []
|
||||||
|
[/\u{2261}/gu, '='], // ≡ hamburger menu -> =
|
||||||
|
|
||||||
|
// Misc
|
||||||
|
[/\u{1F512}/gu, '[lock]'], // 🔒 lock
|
||||||
|
[/\u{1F513}/gu, '[unlock]'], // 🔓 unlock
|
||||||
|
[/\u{1F527}/gu, '[tool]'], // 🔧 wrench
|
||||||
|
[/\u{2699}/gu, '[gear]'], // ⚙ gear
|
||||||
|
[/\u{1F50D}/gu, '[search]'], // 🔍 magnifying glass
|
||||||
|
[/\u{1F4E6}/gu, '[pkg]'], // 📦 package
|
||||||
|
[/\u{1F680}/gu, '[>]'], // 🚀 rocket
|
||||||
|
[/\u{1F3AF}/gu, '[*]'], // 🎯 target
|
||||||
|
|
||||||
|
// Catch-all for any remaining emoji in common ranges
|
||||||
|
// These will appear as replacement characters otherwise
|
||||||
|
[/[\u{1F300}-\u{1F9FF}]/gu, ''], // Misc symbols and pictographs
|
||||||
|
[/[\u{2600}-\u{26FF}]/gu, ''], // Misc symbols
|
||||||
|
[/[\u{FE00}-\u{FE0F}]/gu, ''], // Variation selectors
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitizes screen hardcopy output by replacing emoji with ASCII equivalents.
|
||||||
|
*
|
||||||
|
* @param content - Raw hardcopy output that may contain corrupted UTF-8
|
||||||
|
* @returns Sanitized string with emoji replaced by ASCII
|
||||||
|
*/
|
||||||
|
function sanitizeHardcopyOutput(content: string): string {
|
||||||
|
let result = content;
|
||||||
|
|
||||||
|
// Apply all emoji replacements
|
||||||
|
for (const [pattern, replacement] of EMOJI_REPLACEMENTS) {
|
||||||
|
result = result.replace(pattern, replacement);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove any remaining replacement characters (U+FFFD) that indicate
|
||||||
|
// encoding issues with multi-byte sequences
|
||||||
|
result = result.replace(/\uFFFD+/g, '');
|
||||||
|
|
||||||
|
// Remove any other non-printable characters except common whitespace
|
||||||
|
// This catches any remaining problematic bytes
|
||||||
|
result = result.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '');
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
interface RespawnStatus {
|
interface RespawnStatus {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
state: string;
|
state: string;
|
||||||
cycleCount: number;
|
cycleCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CaseInfo {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
hasClaudeMd: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface SessionManagerState {
|
interface SessionManagerState {
|
||||||
sessions: ScreenSession[];
|
sessions: ScreenSession[];
|
||||||
activeSessionId: string | null;
|
activeSessionId: string | null;
|
||||||
@@ -48,14 +148,19 @@ interface SessionManagerState {
|
|||||||
innerLoopState: InnerLoopState | null;
|
innerLoopState: InnerLoopState | null;
|
||||||
innerTodos: InnerTodoItem[];
|
innerTodos: InnerTodoItem[];
|
||||||
respawnStatus: RespawnStatus | null;
|
respawnStatus: RespawnStatus | null;
|
||||||
|
cases: CaseInfo[];
|
||||||
refreshSessions: () => void;
|
refreshSessions: () => void;
|
||||||
|
refreshCases: () => Promise<void>;
|
||||||
selectSession: (sessionId: string) => void;
|
selectSession: (sessionId: string) => void;
|
||||||
createSession: () => Promise<string | null>;
|
createSession: (caseName?: string, mode?: 'claude' | 'shell') => Promise<string | null>;
|
||||||
|
createCase: (name: string) => Promise<boolean>;
|
||||||
killSession: (sessionId: string) => void;
|
killSession: (sessionId: string) => void;
|
||||||
killAllSessions: () => void;
|
killAllSessions: () => void;
|
||||||
nextSession: () => void;
|
nextSession: () => void;
|
||||||
prevSession: () => void;
|
prevSession: () => void;
|
||||||
sendInput: (sessionId: string, input: string) => void;
|
sendInput: (sessionId: string, input: string) => void;
|
||||||
|
toggleRespawn: () => Promise<boolean>;
|
||||||
|
renameSession: (sessionId: string, name: string) => Promise<boolean>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -160,6 +265,7 @@ export function useSessionManager(): SessionManagerState {
|
|||||||
const [innerLoopState, setInnerLoopState] = useState<InnerLoopState | null>(null);
|
const [innerLoopState, setInnerLoopState] = useState<InnerLoopState | null>(null);
|
||||||
const [innerTodos, setInnerTodos] = useState<InnerTodoItem[]>([]);
|
const [innerTodos, setInnerTodos] = useState<InnerTodoItem[]>([]);
|
||||||
const [respawnStatus, setRespawnStatus] = useState<RespawnStatus | null>(null);
|
const [respawnStatus, setRespawnStatus] = useState<RespawnStatus | null>(null);
|
||||||
|
const [cases, setCases] = useState<CaseInfo[]>([]);
|
||||||
const outputBufferRef = useRef<string>('');
|
const outputBufferRef = useRef<string>('');
|
||||||
|
|
||||||
// Load sessions on mount and watch for changes
|
// Load sessions on mount and watch for changes
|
||||||
@@ -271,12 +377,20 @@ export function useSessionManager(): SessionManagerState {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const hardcopyFile = `/tmp/claudeman-${activeSessionId}-hardcopy`;
|
const hardcopyFile = `/tmp/claudeman-${activeSessionId}-hardcopy`;
|
||||||
execSync(`screen -S ${activeSession.screenName} -X hardcopy ${hardcopyFile}`, {
|
// Use screen with UTF-8 mode (-U) for proper character handling
|
||||||
|
execSync(`screen -U -S ${activeSession.screenName} -X hardcopy ${hardcopyFile}`, {
|
||||||
encoding: 'utf-8',
|
encoding: 'utf-8',
|
||||||
timeout: 1000,
|
timeout: 1000,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
LANG: process.env.LANG || 'en_US.UTF-8',
|
||||||
|
LC_ALL: process.env.LC_ALL || 'en_US.UTF-8',
|
||||||
|
}
|
||||||
});
|
});
|
||||||
if (existsSync(hardcopyFile)) {
|
if (existsSync(hardcopyFile)) {
|
||||||
const content = readFileSync(hardcopyFile, 'utf-8');
|
const rawContent = readFileSync(hardcopyFile, 'utf-8');
|
||||||
|
// Sanitize emoji/unicode that screen hardcopy corrupts
|
||||||
|
const content = sanitizeHardcopyOutput(rawContent);
|
||||||
// Only update if content changed
|
// Only update if content changed
|
||||||
if (content !== outputBufferRef.current) {
|
if (content !== outputBufferRef.current) {
|
||||||
outputBufferRef.current = content;
|
outputBufferRef.current = content;
|
||||||
@@ -310,6 +424,25 @@ export function useSessionManager(): SessionManagerState {
|
|||||||
setSessions(loadSessions());
|
setSessions(loadSessions());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Refresh cases from API
|
||||||
|
const refreshCases = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:3000/api/cases');
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json() as CaseInfo[];
|
||||||
|
setCases(data);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Server not running, clear cases
|
||||||
|
setCases([]);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load cases on mount
|
||||||
|
useEffect(() => {
|
||||||
|
refreshCases();
|
||||||
|
}, [refreshCases]);
|
||||||
|
|
||||||
// Select a session
|
// Select a session
|
||||||
const selectSession = useCallback((sessionId: string) => {
|
const selectSession = useCallback((sessionId: string) => {
|
||||||
setActiveSessionId(sessionId);
|
setActiveSessionId(sessionId);
|
||||||
@@ -318,23 +451,46 @@ export function useSessionManager(): SessionManagerState {
|
|||||||
// Polling effect will handle fetching output
|
// Polling effect will handle fetching output
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Create new session
|
// Create a new case
|
||||||
const createSession = useCallback(async (): Promise<string | null> => {
|
const createCase = useCallback(async (name: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:3000/api/cases', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json() as { success?: boolean };
|
||||||
|
if (data.success) {
|
||||||
|
await refreshCases();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [refreshCases]);
|
||||||
|
|
||||||
|
// Create new session (optionally with case name and mode)
|
||||||
|
const createSession = useCallback(async (caseName?: string, mode: 'claude' | 'shell' = 'claude'): Promise<string | null> => {
|
||||||
try {
|
try {
|
||||||
// Use the web API to create a session if server is running
|
// Use the web API to create a session if server is running
|
||||||
const response = await fetch('http://localhost:3000/api/quick-start', {
|
const response = await fetch('http://localhost:3000/api/quick-start', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ caseName: `tui-${Date.now()}` }),
|
body: JSON.stringify({ caseName: caseName || `case-${Date.now()}`, mode }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const data = await response.json() as { success?: boolean; sessionId?: string };
|
const data = await response.json() as { success?: boolean; sessionId?: string; caseName?: string };
|
||||||
if (data.success && data.sessionId) {
|
if (data.success && data.sessionId) {
|
||||||
// Refresh and select new session
|
// Refresh and select new session
|
||||||
const sessionId = data.sessionId;
|
const sessionId = data.sessionId;
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
refreshSessions();
|
refreshSessions();
|
||||||
|
refreshCases();
|
||||||
setActiveSessionId(sessionId);
|
setActiveSessionId(sessionId);
|
||||||
}, 500);
|
}, 500);
|
||||||
return sessionId;
|
return sessionId;
|
||||||
@@ -355,7 +511,7 @@ export function useSessionManager(): SessionManagerState {
|
|||||||
);
|
);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}, [refreshSessions]);
|
}, [refreshSessions, refreshCases]);
|
||||||
|
|
||||||
// Kill a session
|
// Kill a session
|
||||||
const killSession = useCallback((sessionId: string) => {
|
const killSession = useCallback((sessionId: string) => {
|
||||||
@@ -437,6 +593,55 @@ export function useSessionManager(): SessionManagerState {
|
|||||||
}
|
}
|
||||||
}, [sessions]);
|
}, [sessions]);
|
||||||
|
|
||||||
|
// Toggle respawn for active session
|
||||||
|
const toggleRespawn = useCallback(async (): Promise<boolean> => {
|
||||||
|
if (!activeSessionId) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const isEnabled = respawnStatus?.enabled || false;
|
||||||
|
const endpoint = isEnabled
|
||||||
|
? `http://localhost:3000/api/sessions/${activeSessionId}/respawn/stop`
|
||||||
|
: `http://localhost:3000/api/sessions/${activeSessionId}/respawn/enable`;
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(isEnabled ? {} : { config: {} }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json() as { success?: boolean };
|
||||||
|
return data.success || false;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [activeSessionId, respawnStatus]);
|
||||||
|
|
||||||
|
// Rename a session
|
||||||
|
const renameSession = useCallback(async (sessionId: string, name: string): Promise<boolean> => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://localhost:3000/api/sessions/${sessionId}/name`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json() as { success?: boolean };
|
||||||
|
if (data.success) {
|
||||||
|
// Refresh sessions to get updated name
|
||||||
|
setTimeout(refreshSessions, 100);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}, [refreshSessions]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sessions,
|
sessions,
|
||||||
activeSessionId,
|
activeSessionId,
|
||||||
@@ -445,13 +650,18 @@ export function useSessionManager(): SessionManagerState {
|
|||||||
innerLoopState,
|
innerLoopState,
|
||||||
innerTodos,
|
innerTodos,
|
||||||
respawnStatus,
|
respawnStatus,
|
||||||
|
cases,
|
||||||
refreshSessions,
|
refreshSessions,
|
||||||
|
refreshCases,
|
||||||
selectSession,
|
selectSession,
|
||||||
createSession,
|
createSession,
|
||||||
|
createCase,
|
||||||
killSession,
|
killSession,
|
||||||
killAllSessions,
|
killAllSessions,
|
||||||
nextSession,
|
nextSession,
|
||||||
prevSession,
|
prevSession,
|
||||||
sendInput,
|
sendInput,
|
||||||
|
toggleRespawn,
|
||||||
|
renameSession,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
+136
-1
@@ -11,6 +11,7 @@
|
|||||||
* - Real-time terminal output via screen hardcopy polling
|
* - Real-time terminal output via screen hardcopy polling
|
||||||
* - Ralph Wiggum loop tracking
|
* - Ralph Wiggum loop tracking
|
||||||
* - Respawn status monitoring
|
* - Respawn status monitoring
|
||||||
|
* - Auto-detection and startup of web server if not running
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```bash
|
* ```bash
|
||||||
@@ -25,6 +26,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { render } from 'ink';
|
import { render } from 'ink';
|
||||||
|
import { spawn } from 'child_process';
|
||||||
|
import { createInterface } from 'readline';
|
||||||
import { App } from './App.js';
|
import { App } from './App.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -43,6 +46,88 @@ function isRawModeSupported(): boolean {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the Claudeman web server is running.
|
||||||
|
*
|
||||||
|
* @param port - Port to check (default 3000)
|
||||||
|
* @returns Promise that resolves to true if server is running
|
||||||
|
*/
|
||||||
|
async function isWebServerRunning(port: number = 3000): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 2000);
|
||||||
|
|
||||||
|
const response = await fetch(`http://localhost:${port}/api/status`, {
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeout);
|
||||||
|
return response.ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompts the user with a yes/no question.
|
||||||
|
*
|
||||||
|
* @param question - The question to ask
|
||||||
|
* @returns Promise that resolves to true for yes, false for no
|
||||||
|
*/
|
||||||
|
async function promptYesNo(question: string): Promise<boolean> {
|
||||||
|
const rl = createInterface({
|
||||||
|
input: process.stdin,
|
||||||
|
output: process.stdout,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
rl.question(`${question} [Y/n] `, (answer) => {
|
||||||
|
rl.close();
|
||||||
|
const normalized = answer.trim().toLowerCase();
|
||||||
|
resolve(normalized === '' || normalized === 'y' || normalized === 'yes');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Starts the Claudeman web server in the background.
|
||||||
|
*
|
||||||
|
* @param port - Port to run on (default 3000)
|
||||||
|
* @returns true if server started successfully
|
||||||
|
*/
|
||||||
|
function startWebServerInBackground(port: number = 3000): boolean {
|
||||||
|
try {
|
||||||
|
// Find the current script's directory to locate the web server entry
|
||||||
|
const child = spawn('node', [
|
||||||
|
'--import', 'tsx/esm',
|
||||||
|
`${process.cwd()}/src/index.ts`,
|
||||||
|
'web',
|
||||||
|
'-p', String(port),
|
||||||
|
], {
|
||||||
|
detached: true,
|
||||||
|
stdio: 'ignore',
|
||||||
|
cwd: process.cwd(),
|
||||||
|
});
|
||||||
|
|
||||||
|
child.unref();
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TUI startup options.
|
||||||
|
*/
|
||||||
|
interface TUIOptions {
|
||||||
|
/** Auto-start web server without prompting if not running */
|
||||||
|
autoStartWeb?: boolean;
|
||||||
|
/** Skip web server check entirely */
|
||||||
|
skipWebCheck?: boolean;
|
||||||
|
/** Web server port */
|
||||||
|
port?: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts the TUI application in the current terminal.
|
* Starts the TUI application in the current terminal.
|
||||||
*
|
*
|
||||||
@@ -51,10 +136,16 @@ function isRawModeSupported(): boolean {
|
|||||||
* The terminal is cleared for a full-screen experience.
|
* The terminal is cleared for a full-screen experience.
|
||||||
* This function blocks until the user exits the TUI.
|
* This function blocks until the user exits the TUI.
|
||||||
*
|
*
|
||||||
|
* Before starting, checks if the web server is running and offers to start it
|
||||||
|
* in the background if not (unless skipWebCheck is true).
|
||||||
|
*
|
||||||
|
* @param options - TUI startup options
|
||||||
* @throws Exits with code 1 if TTY/raw mode is not supported
|
* @throws Exits with code 1 if TTY/raw mode is not supported
|
||||||
* @returns Promise that resolves when the TUI exits
|
* @returns Promise that resolves when the TUI exits
|
||||||
*/
|
*/
|
||||||
export async function startTUI(): Promise<void> {
|
export async function startTUI(options: TUIOptions = {}): Promise<void> {
|
||||||
|
const { autoStartWeb = false, skipWebCheck = false, port = 3000 } = options;
|
||||||
|
|
||||||
// Check if we're in an interactive terminal
|
// Check if we're in an interactive terminal
|
||||||
if (!isRawModeSupported()) {
|
if (!isRawModeSupported()) {
|
||||||
console.error('Error: TUI requires an interactive terminal with TTY support.');
|
console.error('Error: TUI requires an interactive terminal with TTY support.');
|
||||||
@@ -62,6 +153,50 @@ export async function startTUI(): Promise<void> {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if web server is running (unless skipped)
|
||||||
|
if (!skipWebCheck) {
|
||||||
|
const serverRunning = await isWebServerRunning(port);
|
||||||
|
|
||||||
|
if (!serverRunning) {
|
||||||
|
console.log('\x1b[33mClaudeman web server is not running.\x1b[0m');
|
||||||
|
console.log('The TUI requires the web server to create and manage sessions.\n');
|
||||||
|
|
||||||
|
let startServer = autoStartWeb;
|
||||||
|
|
||||||
|
if (!autoStartWeb) {
|
||||||
|
startServer = await promptYesNo('Would you like to start the web server in the background?');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (startServer) {
|
||||||
|
console.log(`\x1b[32mStarting web server on port ${port}...\x1b[0m`);
|
||||||
|
const success = startWebServerInBackground(port);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
// Wait a moment for the server to start
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||||
|
|
||||||
|
// Verify it started
|
||||||
|
const nowRunning = await isWebServerRunning(port);
|
||||||
|
if (nowRunning) {
|
||||||
|
console.log('\x1b[32mWeb server started successfully!\x1b[0m\n');
|
||||||
|
} else {
|
||||||
|
console.log('\x1b[33mServer may still be starting... continuing with TUI.\x1b[0m');
|
||||||
|
console.log('If you have issues, try running "claudeman web" in a separate terminal.\n');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('\x1b[31mFailed to start web server.\x1b[0m');
|
||||||
|
console.log('Please run "claudeman web" in a separate terminal first.\n');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('\nYou can start the web server anytime with: claudeman web');
|
||||||
|
console.log('Some TUI features may not work without the web server.\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Brief pause before clearing screen
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Clear the terminal for full-screen experience
|
// Clear the terminal for full-screen experience
|
||||||
process.stdout.write('\x1b[2J\x1b[H');
|
process.stdout.write('\x1b[2J\x1b[H');
|
||||||
|
|
||||||
|
|||||||
@@ -299,6 +299,8 @@ export interface CreateCaseRequest {
|
|||||||
export interface QuickStartRequest {
|
export interface QuickStartRequest {
|
||||||
/** Optional case name, defaults to 'testcase' */
|
/** Optional case name, defaults to 'testcase' */
|
||||||
caseName?: string;
|
caseName?: string;
|
||||||
|
/** Session mode: 'claude' for Claude CLI, 'shell' for bash shell */
|
||||||
|
mode?: 'claude' | 'shell';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+42
-19
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
import Fastify, { FastifyInstance, FastifyReply } from 'fastify';
|
import Fastify, { FastifyInstance, FastifyReply } from 'fastify';
|
||||||
import fastifyStatic from '@fastify/static';
|
import fastifyStatic from '@fastify/static';
|
||||||
import { join, dirname } from 'node:path';
|
import { join, dirname, resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync } from 'node:fs';
|
import { existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync } from 'node:fs';
|
||||||
import { homedir, totalmem, freemem, loadavg, cpus } from 'node:os';
|
import { homedir, totalmem, freemem, loadavg, cpus } from 'node:os';
|
||||||
@@ -749,6 +749,13 @@ export class WebServer extends EventEmitter {
|
|||||||
|
|
||||||
const casePath = join(casesDir, name);
|
const casePath = join(casesDir, name);
|
||||||
|
|
||||||
|
// Security: Path traversal protection - ensure resolved path is within casesDir
|
||||||
|
const resolvedPath = resolve(casePath);
|
||||||
|
const resolvedBase = resolve(casesDir);
|
||||||
|
if (!resolvedPath.startsWith(resolvedBase + '/') && resolvedPath !== resolvedBase) {
|
||||||
|
return { success: false, error: 'Invalid case path' };
|
||||||
|
}
|
||||||
|
|
||||||
if (existsSync(casePath)) {
|
if (existsSync(casePath)) {
|
||||||
return { success: false, error: 'Case already exists' };
|
return { success: false, error: 'Case already exists' };
|
||||||
}
|
}
|
||||||
@@ -792,7 +799,7 @@ export class WebServer extends EventEmitter {
|
|||||||
return { success: false, error: `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached.` };
|
return { success: false, error: `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached.` };
|
||||||
}
|
}
|
||||||
|
|
||||||
const { caseName = 'testcase' } = req.body as QuickStartRequest;
|
const { caseName = 'testcase', mode = 'claude' } = req.body as QuickStartRequest;
|
||||||
|
|
||||||
// Validate case name
|
// Validate case name
|
||||||
if (!/^[a-zA-Z0-9_-]+$/.test(caseName)) {
|
if (!/^[a-zA-Z0-9_-]+$/.test(caseName)) {
|
||||||
@@ -801,6 +808,13 @@ export class WebServer extends EventEmitter {
|
|||||||
|
|
||||||
const casePath = join(casesDir, caseName);
|
const casePath = join(casesDir, caseName);
|
||||||
|
|
||||||
|
// Security: Path traversal protection - ensure resolved path is within casesDir
|
||||||
|
const resolvedPath = resolve(casePath);
|
||||||
|
const resolvedBase = resolve(casesDir);
|
||||||
|
if (!resolvedPath.startsWith(resolvedBase + '/') && resolvedPath !== resolvedBase) {
|
||||||
|
return { success: false, error: 'Invalid case path' };
|
||||||
|
}
|
||||||
|
|
||||||
// Create case folder and CLAUDE.md if it doesn't exist
|
// Create case folder and CLAUDE.md if it doesn't exist
|
||||||
if (!existsSync(casePath)) {
|
if (!existsSync(casePath)) {
|
||||||
try {
|
try {
|
||||||
@@ -822,16 +836,22 @@ export class WebServer extends EventEmitter {
|
|||||||
const session = new Session({
|
const session = new Session({
|
||||||
workingDir: casePath,
|
workingDir: casePath,
|
||||||
screenManager: this.screenManager,
|
screenManager: this.screenManager,
|
||||||
useScreen: true
|
useScreen: true,
|
||||||
|
mode: mode,
|
||||||
});
|
});
|
||||||
this.sessions.set(session.id, session);
|
this.sessions.set(session.id, session);
|
||||||
this.setupSessionListeners(session);
|
this.setupSessionListeners(session);
|
||||||
this.broadcast('session:created', session.toDetailedState());
|
this.broadcast('session:created', session.toDetailedState());
|
||||||
|
|
||||||
// Start interactive mode
|
// Start in the appropriate mode
|
||||||
try {
|
try {
|
||||||
await session.startInteractive();
|
if (mode === 'shell') {
|
||||||
this.broadcast('session:interactive', { id: session.id });
|
await session.startShell();
|
||||||
|
this.broadcast('session:interactive', { id: session.id, mode: 'shell' });
|
||||||
|
} else {
|
||||||
|
await session.startInteractive();
|
||||||
|
this.broadcast('session:interactive', { id: session.id });
|
||||||
|
}
|
||||||
this.broadcast('session:updated', { session: session.toDetailedState() });
|
this.broadcast('session:updated', { session: session.toDetailedState() });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -1034,10 +1054,11 @@ export class WebServer extends EventEmitter {
|
|||||||
this.broadcast('session:exit', { id: session.id, code });
|
this.broadcast('session:exit', { id: session.id, code });
|
||||||
this.broadcast('session:updated', session.toDetailedState());
|
this.broadcast('session:updated', session.toDetailedState());
|
||||||
|
|
||||||
// Clean up respawn controller when session exits
|
// Clean up respawn controller when session exits (stop + remove listeners)
|
||||||
const controller = this.respawnControllers.get(session.id);
|
const controller = this.respawnControllers.get(session.id);
|
||||||
if (controller) {
|
if (controller) {
|
||||||
controller.stop();
|
controller.stop();
|
||||||
|
controller.removeAllListeners();
|
||||||
this.respawnControllers.delete(session.id);
|
this.respawnControllers.delete(session.id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1285,11 +1306,10 @@ export class WebServer extends EventEmitter {
|
|||||||
run.status = 'stopped';
|
run.status = 'stopped';
|
||||||
run.logs.push(`[${new Date().toISOString()}] Run stopped by user`);
|
run.logs.push(`[${new Date().toISOString()}] Run stopped by user`);
|
||||||
|
|
||||||
if (run.sessionId) {
|
// Use cleanupSession for proper resource cleanup (listeners, respawn, etc.)
|
||||||
const session = this.sessions.get(run.sessionId);
|
if (run.sessionId && this.sessions.has(run.sessionId)) {
|
||||||
if (session) {
|
await this.cleanupSession(run.sessionId);
|
||||||
await session.stop();
|
run.sessionId = null;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.broadcast('scheduled:stopped', run);
|
this.broadcast('scheduled:stopped', run);
|
||||||
@@ -1570,22 +1590,25 @@ export class WebServer extends EventEmitter {
|
|||||||
// Stop screen stats collection
|
// Stop screen stats collection
|
||||||
this.screenManager.stopStatsCollection();
|
this.screenManager.stopStatsCollection();
|
||||||
|
|
||||||
// Stop all respawn controllers
|
// Stop all respawn controllers and remove listeners
|
||||||
for (const controller of this.respawnControllers.values()) {
|
for (const controller of this.respawnControllers.values()) {
|
||||||
controller.stop();
|
controller.stop();
|
||||||
|
controller.removeAllListeners();
|
||||||
}
|
}
|
||||||
this.respawnControllers.clear();
|
this.respawnControllers.clear();
|
||||||
|
|
||||||
// Stop all sessions
|
// Stop all scheduled runs first (they have their own session cleanup)
|
||||||
for (const session of this.sessions.values()) {
|
|
||||||
await session.stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stop all scheduled runs
|
|
||||||
for (const [id] of this.scheduledRuns) {
|
for (const [id] of this.scheduledRuns) {
|
||||||
await this.stopScheduledRun(id);
|
await this.stopScheduledRun(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Properly clean up all remaining sessions (removes listeners, clears state, etc.)
|
||||||
|
// Don't kill screens on server stop - they can be reattached on restart
|
||||||
|
const sessionIds = Array.from(this.sessions.keys());
|
||||||
|
for (const sessionId of sessionIds) {
|
||||||
|
await this.cleanupSession(sessionId, false);
|
||||||
|
}
|
||||||
|
|
||||||
await this.app.close();
|
await this.app.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ describe('Session Cleanup', () => {
|
|||||||
for (const id of sessionIds) {
|
for (const id of sessionIds) {
|
||||||
expect(sessions.find((s: any) => s.id === id)).toBeUndefined();
|
expect(sessions.find((s: any) => s.id === id)).toBeUndefined();
|
||||||
}
|
}
|
||||||
});
|
}, 60000); // Extended timeout for multi-session test
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Respawn Controller Cleanup', () => {
|
describe('Respawn Controller Cleanup', () => {
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
# Claudeman TODO List
|
||||||
|
|
||||||
|
## Completed
|
||||||
|
|
||||||
|
- [x] Memory leaks intensive check - Fixed timer tracking in session.ts, proper cleanup in server.ts
|
||||||
|
- [x] Security hardening - Added input validation, command injection prevention, path traversal protection
|
||||||
|
- [x] Performance optimization - Pre-compiled regex patterns, event debouncing, buffer management
|
||||||
|
- [x] Reliability improvements - Proper cleanup on session exit, listener cleanup
|
||||||
|
- [x] TUI parity with web interface:
|
||||||
|
- [x] Shell mode support (h key in cases view)
|
||||||
|
- [x] Multi-start (m key, 1-20 sessions at once)
|
||||||
|
- [x] Respawn toggle (Ctrl+R)
|
||||||
|
- [x] Session rename API support
|
||||||
|
- [x] Selected item display in start screen
|
||||||
|
- [x] Help overlay with all shortcuts
|
||||||
|
- [x] Web server auto-detection and startup
|
||||||
|
- Prompts to start web server if not running
|
||||||
|
- `--with-web` flag for automatic startup (no prompt)
|
||||||
|
- `--no-web` flag to skip check entirely
|
||||||
|
- [x] Documentation updates - Updated CLAUDE.md with TUI shortcuts
|
||||||
|
|
||||||
|
## Ongoing (Long-term stability)
|
||||||
|
|
||||||
|
- [ ] Monitor for edge cases during extended runtime
|
||||||
|
- [ ] Consider implementing graceful degradation for API failures
|
||||||
|
- [ ] Add metrics/logging for long-running session health
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
- [ ] TUI: Add inline session rename UI (currently API only)
|
||||||
|
- [ ] TUI: Add task panel for background tasks
|
||||||
|
- [ ] TUI: Add scheduled runs display
|
||||||
|
- [ ] TUI: Add auto-compact/auto-clear configuration UI
|
||||||
|
- [ ] Consider WebSocket for TUI instead of polling (reduce latency)
|
||||||
Reference in New Issue
Block a user