mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39: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
|
||||
|
||||
# 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 --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)
|
||||
# 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 |
|
||||
| POST | `/api/sessions/:id/auto-compact` | Configure auto-compact 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 |
|
||||
| POST | `/api/cases` | Create new case |
|
||||
| GET | `/api/screens` | List screen sessions with stats |
|
||||
|
||||
## Keyboard Shortcuts
|
||||
## Keyboard Shortcuts (Web UI)
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
@@ -432,6 +435,44 @@ npx agent-browser close
|
||||
| `Ctrl+?` | Show keyboard shortcuts help |
|
||||
| `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
|
||||
|
||||
| File | Purpose |
|
||||
|
||||
+10
-2
@@ -483,10 +483,18 @@ program
|
||||
program
|
||||
.command('tui')
|
||||
.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 {
|
||||
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) {
|
||||
console.error(chalk.red(`✗ Failed to start TUI: ${(err as Error).message}`));
|
||||
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 */
|
||||
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.
|
||||
*
|
||||
@@ -107,6 +157,14 @@ export class ScreenManager extends EventEmitter {
|
||||
async createScreen(sessionId: string, workingDir: string, mode: 'claude' | 'shell', name?: string): Promise<ScreenSession> {
|
||||
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
|
||||
// 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
|
||||
@@ -161,8 +219,16 @@ export class ScreenManager extends EventEmitter {
|
||||
|
||||
// Get screen session PID
|
||||
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 {
|
||||
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',
|
||||
timeout: 5000
|
||||
});
|
||||
@@ -522,6 +588,12 @@ export class ScreenManager extends EventEmitter {
|
||||
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 {
|
||||
// Split input into text and control characters
|
||||
// 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 textPart = input.replace(/\r/g, '').replace(/\n/g, '');
|
||||
|
||||
// Escape the text part for shell (double quotes)
|
||||
const escapedText = textPart.replace(/"/g, '\\"').replace(/\$/g, '\\$').replace(/`/g, '\\`');
|
||||
// Escape the text part for shell using the helper function
|
||||
const escapedText = shellEscape(textPart);
|
||||
|
||||
// Send text first (if any)
|
||||
if (escapedText) {
|
||||
|
||||
+90
-10
@@ -308,6 +308,13 @@ export class Session extends EventEmitter {
|
||||
private _autoCompactPrompt: string = ''; // Optional prompt for compact
|
||||
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
|
||||
private _screenManager: ScreenManager | 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 RESTORED screens: don't do anything - client will fetch buffer on tab switch
|
||||
if (!isRestoredSession) {
|
||||
const checkForPrompt = setInterval(() => {
|
||||
this._promptCheckInterval = setInterval(() => {
|
||||
// Wait for the prompt character (❯) which means Claude is fully initialized
|
||||
const bufferValue = this._terminalBuffer.value;
|
||||
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
|
||||
// Strip: cursor movement (\x1b[nA/B/C/D), positioning (\x1b[n;nH),
|
||||
// clear screen (\x1b[2J), scroll region (\x1b[n;nr), and whitespace
|
||||
@@ -661,7 +675,13 @@ export class Session extends EventEmitter {
|
||||
}
|
||||
}, 50);
|
||||
// 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) {
|
||||
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._pid = null;
|
||||
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 (this._screenSession && this._screenManager) {
|
||||
this._screenManager.setAttached(this.id, false);
|
||||
@@ -867,6 +900,15 @@ export class Session extends EventEmitter {
|
||||
this.ptyProcess = null;
|
||||
this._pid = null;
|
||||
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 (this._screenSession && this._screenManager) {
|
||||
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)
|
||||
setTimeout(() => {
|
||||
this._shellIdleTimer = setTimeout(() => {
|
||||
this._shellIdleTimer = null;
|
||||
this._status = 'idle';
|
||||
this._isWorking = false;
|
||||
this.emit('idle');
|
||||
@@ -1151,6 +1194,9 @@ export class Session extends EventEmitter {
|
||||
|
||||
// Wait for Claude to be idle before compacting
|
||||
const checkAndCompact = () => {
|
||||
// Check if session is still valid (not stopped)
|
||||
if (!this._isCompacting) return;
|
||||
|
||||
if (!this._isWorking) {
|
||||
// Send /compact command with optional prompt
|
||||
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)
|
||||
setTimeout(() => {
|
||||
this._autoCompactTimer = setTimeout(() => {
|
||||
this._autoCompactTimer = null;
|
||||
this._isCompacting = false;
|
||||
}, 10000);
|
||||
} else {
|
||||
// Check again in 2 seconds
|
||||
setTimeout(checkAndCompact, 2000);
|
||||
this._autoCompactTimer = setTimeout(checkAndCompact, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
// 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
|
||||
const checkAndClear = () => {
|
||||
// Check if session is still valid (not stopped)
|
||||
if (!this._isClearing) return;
|
||||
|
||||
if (!this._isWorking) {
|
||||
// Send /clear command
|
||||
this.writeViaScreen('/clear\r');
|
||||
@@ -1198,17 +1248,18 @@ export class Session extends EventEmitter {
|
||||
this.emit('autoClear', { tokens: totalTokens, threshold: this._autoClearThreshold });
|
||||
|
||||
// Wait a moment then re-enable
|
||||
setTimeout(() => {
|
||||
this._autoClearTimer = setTimeout(() => {
|
||||
this._autoClearTimer = null;
|
||||
this._isClearing = false;
|
||||
}, 5000);
|
||||
} else {
|
||||
// Check again in 2 seconds
|
||||
setTimeout(checkAndClear, 2000);
|
||||
this._autoClearTimer = setTimeout(checkAndClear, 2000);
|
||||
}
|
||||
};
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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
|
||||
// during the rest of stop() processing (e.g., if screen kill times out)
|
||||
if (this.rejectPromise) {
|
||||
|
||||
+111
-12
@@ -68,8 +68,10 @@ export function App(): React.ReactElement {
|
||||
activeSessionId,
|
||||
activeSession,
|
||||
refreshSessions,
|
||||
refreshCases,
|
||||
selectSession,
|
||||
createSession,
|
||||
createCase,
|
||||
killSession,
|
||||
killAllSessions,
|
||||
nextSession,
|
||||
@@ -79,6 +81,9 @@ export function App(): React.ReactElement {
|
||||
innerLoopState,
|
||||
innerTodos,
|
||||
respawnStatus,
|
||||
cases,
|
||||
toggleRespawn,
|
||||
renameSession,
|
||||
} = useSessionManager();
|
||||
|
||||
// Calculate terminal height based on stdout dimensions
|
||||
@@ -112,7 +117,7 @@ export function App(): React.ReactElement {
|
||||
return;
|
||||
}
|
||||
|
||||
// Exit on Ctrl+C (with confirmation in main view)
|
||||
// Exit on Ctrl+C
|
||||
if (key.ctrl && input === 'c') {
|
||||
exit();
|
||||
return;
|
||||
@@ -129,7 +134,19 @@ export function App(): React.ReactElement {
|
||||
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.shift) {
|
||||
prevSession();
|
||||
@@ -139,8 +156,28 @@ export function App(): React.ReactElement {
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+1-9 for direct tab access
|
||||
if (key.ctrl) {
|
||||
// Alt+Left/Right arrow keys for session switching
|
||||
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);
|
||||
if (!isNaN(num) && num >= 1 && num <= 9 && num <= sessions.length) {
|
||||
selectSession(sessions[num - 1].sessionId);
|
||||
@@ -148,6 +185,8 @@ export function App(): React.ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
// === SESSION MANAGEMENT SHORTCUTS ===
|
||||
|
||||
// Ctrl+W to close current session
|
||||
if (key.ctrl && input === 'w') {
|
||||
if (activeSessionId) {
|
||||
@@ -172,20 +211,62 @@ export function App(): React.ReactElement {
|
||||
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) {
|
||||
setViewMode('start');
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward other input to the active session
|
||||
if (activeSessionId && !key.ctrl && !key.meta) {
|
||||
// === FORWARD INPUT TO SESSION ===
|
||||
// Forward all other input to the active screen session
|
||||
if (activeSessionId) {
|
||||
// Handle special keys
|
||||
if (key.return) {
|
||||
sendInput(activeSessionId, '\r');
|
||||
} else if (key.backspace || key.delete) {
|
||||
return;
|
||||
}
|
||||
if (key.backspace || key.delete) {
|
||||
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);
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -195,9 +276,24 @@ export function App(): React.ReactElement {
|
||||
setViewMode('main');
|
||||
}, [selectSession]);
|
||||
|
||||
const handleCreateSession = useCallback(async () => {
|
||||
const sessionId = await createSession();
|
||||
if (sessionId) {
|
||||
const handleCreateSession = useCallback(async (caseName?: string, count?: number, mode: 'claude' | 'shell' = 'claude') => {
|
||||
// Default to 'default' case if no case name provided (like web UI)
|
||||
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');
|
||||
}
|
||||
}, [createSession]);
|
||||
@@ -239,11 +335,14 @@ export function App(): React.ReactElement {
|
||||
return (
|
||||
<StartScreen
|
||||
sessions={sessions}
|
||||
cases={cases}
|
||||
onSelectSession={handleSelectSession}
|
||||
onAttachSession={handleAttachSession}
|
||||
onDeleteSession={handleDeleteSession}
|
||||
onCreateSession={handleCreateSession}
|
||||
onCreateCase={createCase}
|
||||
onRefresh={refreshSessions}
|
||||
onRefreshCases={refreshCases}
|
||||
onExit={exit}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -27,17 +27,30 @@ interface ShortcutGroup {
|
||||
|
||||
const SHORTCUT_GROUPS: ShortcutGroup[] = [
|
||||
{
|
||||
title: 'Start Screen',
|
||||
title: 'Start Screen - Sessions',
|
||||
shortcuts: [
|
||||
{ key: '\u2191/\u2193', description: 'Navigate session list' },
|
||||
{ key: '\u2191/\u2193', description: 'Navigate list' },
|
||||
{ key: 'Enter', description: 'View session in TUI' },
|
||||
{ key: 'a', description: 'Attach to screen (full terminal)' },
|
||||
{ key: 'd', description: 'Delete/kill selected session' },
|
||||
{ key: 'n', description: 'Create new session' },
|
||||
{ key: 'r', description: 'Refresh session list' },
|
||||
{ key: 'c', description: 'Switch to cases view' },
|
||||
{ key: 'n', description: 'Quick-start new session' },
|
||||
{ key: 'r', description: 'Refresh list' },
|
||||
{ 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',
|
||||
shortcuts: [
|
||||
@@ -53,6 +66,7 @@ const SHORTCUT_GROUPS: ShortcutGroup[] = [
|
||||
{ key: 'Ctrl+N', description: 'New session' },
|
||||
{ key: 'Ctrl+W', description: 'Close current session' },
|
||||
{ key: 'Ctrl+K', description: 'Kill all sessions' },
|
||||
{ key: 'Ctrl+R', description: 'Toggle respawn (Claude sessions only)' },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
+477
-129
@@ -5,28 +5,40 @@
|
||||
*
|
||||
* @description
|
||||
* The initial screen displayed when launching `claudeman tui`:
|
||||
* - Reads session list from ~/.claudeman/screens.json
|
||||
* - Shows session name, runtime, status (alive/dead), and mode
|
||||
* - Shows existing sessions and available cases
|
||||
* - Supports two modes: session list and case selection
|
||||
* - 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.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import TextInput from 'ink-text-input';
|
||||
import type { ScreenSession } from '../../types.js';
|
||||
|
||||
interface CaseInfo {
|
||||
name: string;
|
||||
path: string;
|
||||
hasClaudeMd: boolean;
|
||||
}
|
||||
|
||||
interface StartScreenProps {
|
||||
sessions: ScreenSession[];
|
||||
cases: CaseInfo[];
|
||||
onSelectSession: (session: ScreenSession) => void;
|
||||
onAttachSession: (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;
|
||||
onRefreshCases: () => void;
|
||||
onExit: () => void;
|
||||
}
|
||||
|
||||
type ScreenMode = 'sessions' | 'cases' | 'new-case' | 'multi-start';
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @description
|
||||
* Renders a table of available sessions with:
|
||||
* - Arrow key navigation (wraps at boundaries)
|
||||
* - Visual selection highlight (blue background)
|
||||
* - 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
|
||||
* Renders two views:
|
||||
* - Sessions view: List of active sessions
|
||||
* - Cases view: List of available cases to start a new session
|
||||
*
|
||||
* @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
|
||||
*/
|
||||
export function StartScreen({
|
||||
sessions,
|
||||
cases,
|
||||
onSelectSession,
|
||||
onAttachSession,
|
||||
onDeleteSession,
|
||||
onCreateSession,
|
||||
onCreateCase,
|
||||
onRefresh,
|
||||
onRefreshCases,
|
||||
onExit,
|
||||
}: StartScreenProps): React.ReactElement {
|
||||
const now = Date.now();
|
||||
const [mode, setMode] = useState<ScreenMode>('sessions');
|
||||
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
|
||||
React.useEffect(() => {
|
||||
if (selectedIndex >= sessions.length && sessions.length > 0) {
|
||||
setSelectedIndex(sessions.length - 1);
|
||||
// Reset selection when switching modes
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
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
|
||||
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
|
||||
if (key.upArrow && sessions.length > 0) {
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : sessions.length - 1));
|
||||
const listLength = mode === 'sessions' ? sessions.length : cases.length;
|
||||
if (key.upArrow && listLength > 0) {
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : listLength - 1));
|
||||
return;
|
||||
}
|
||||
if (key.downArrow && sessions.length > 0) {
|
||||
setSelectedIndex((prev) => (prev < sessions.length - 1 ? prev + 1 : 0));
|
||||
if (key.downArrow && listLength > 0) {
|
||||
setSelectedIndex((prev) => (prev < listLength - 1 ? prev + 1 : 0));
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter to view session in TUI
|
||||
if (key.return && sessions.length > 0) {
|
||||
onSelectSession(sessions[selectedIndex]);
|
||||
// Enter to select
|
||||
if (key.return && listLength > 0) {
|
||||
if (mode === 'sessions') {
|
||||
onSelectSession(sessions[selectedIndex]);
|
||||
} else if (mode === 'cases') {
|
||||
// Start session with selected case
|
||||
onCreateSession(cases[selectedIndex].name);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 'a' to attach directly to screen
|
||||
if (input === 'a' && sessions.length > 0 && sessions[selectedIndex].attached) {
|
||||
onAttachSession(sessions[selectedIndex]);
|
||||
return;
|
||||
// Mode-specific shortcuts
|
||||
if (mode === 'sessions') {
|
||||
// 'a' to attach directly to screen
|
||||
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
|
||||
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
|
||||
// 'r' to refresh
|
||||
if (input === 'r') {
|
||||
onRefresh();
|
||||
if (mode === 'sessions') {
|
||||
onRefresh();
|
||||
} else {
|
||||
onRefreshCases();
|
||||
}
|
||||
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 (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
{/* Header */}
|
||||
@@ -160,88 +403,193 @@ export function StartScreen({
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Mode tabs */}
|
||||
<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>
|
||||
|
||||
{/* Session list */}
|
||||
{sessions.length === 0 ? (
|
||||
<Box flexDirection="column" marginY={1}>
|
||||
<Text color="yellow">No sessions found</Text>
|
||||
<Text dimColor>Press [n] to create a new session</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Box flexDirection="column">
|
||||
{/* Table header */}
|
||||
<Box marginBottom={1}>
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
{/* Selected item display */}
|
||||
{selectedItem && (
|
||||
<Box marginBottom={1} borderStyle="single" borderColor="green" paddingX={2}>
|
||||
<Text>
|
||||
<Text color="green" bold>Selected: </Text>
|
||||
<Text bold color="white">{selectedItem.name}</Text>
|
||||
{selectedItem.path && (
|
||||
<Text dimColor> ({selectedItem.path.replace(process.env.HOME || '', '~')})</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Footer with controls */}
|
||||
<Box marginTop={2} flexDirection="column">
|
||||
<Box borderStyle="single" borderColor="gray" paddingX={1}>
|
||||
<Text>
|
||||
<Text color="green">[n]</Text>
|
||||
<Text> New </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="green">[r]</Text>
|
||||
<Text> Refresh </Text>
|
||||
<Text color="yellow">[q]</Text>
|
||||
<Text> Quit</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
{/* Sessions view */}
|
||||
{mode === 'sessions' && (
|
||||
<>
|
||||
{sessions.length === 0 ? (
|
||||
<Box flexDirection="column" marginY={1}>
|
||||
<Text color="yellow">No sessions found</Text>
|
||||
<Text dimColor>Press [n] to create a new session, or [c] to select a case</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Box flexDirection="column">
|
||||
{/* Table header */}
|
||||
<Box marginBottom={1}>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,12 +34,112 @@ const SCREENS_FILE = join(homedir(), '.claudeman', 'screens.json');
|
||||
const INNER_STATE_FILE = join(homedir(), '.claudeman', 'state-inner.json');
|
||||
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 {
|
||||
enabled: boolean;
|
||||
state: string;
|
||||
cycleCount: number;
|
||||
}
|
||||
|
||||
interface CaseInfo {
|
||||
name: string;
|
||||
path: string;
|
||||
hasClaudeMd: boolean;
|
||||
}
|
||||
|
||||
interface SessionManagerState {
|
||||
sessions: ScreenSession[];
|
||||
activeSessionId: string | null;
|
||||
@@ -48,14 +148,19 @@ interface SessionManagerState {
|
||||
innerLoopState: InnerLoopState | null;
|
||||
innerTodos: InnerTodoItem[];
|
||||
respawnStatus: RespawnStatus | null;
|
||||
cases: CaseInfo[];
|
||||
refreshSessions: () => void;
|
||||
refreshCases: () => Promise<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;
|
||||
killAllSessions: () => void;
|
||||
nextSession: () => void;
|
||||
prevSession: () => 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 [innerTodos, setInnerTodos] = useState<InnerTodoItem[]>([]);
|
||||
const [respawnStatus, setRespawnStatus] = useState<RespawnStatus | null>(null);
|
||||
const [cases, setCases] = useState<CaseInfo[]>([]);
|
||||
const outputBufferRef = useRef<string>('');
|
||||
|
||||
// Load sessions on mount and watch for changes
|
||||
@@ -271,12 +377,20 @@ export function useSessionManager(): SessionManagerState {
|
||||
|
||||
try {
|
||||
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',
|
||||
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)) {
|
||||
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
|
||||
if (content !== outputBufferRef.current) {
|
||||
outputBufferRef.current = content;
|
||||
@@ -310,6 +424,25 @@ export function useSessionManager(): SessionManagerState {
|
||||
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
|
||||
const selectSession = useCallback((sessionId: string) => {
|
||||
setActiveSessionId(sessionId);
|
||||
@@ -318,23 +451,46 @@ export function useSessionManager(): SessionManagerState {
|
||||
// Polling effect will handle fetching output
|
||||
}, []);
|
||||
|
||||
// Create new session
|
||||
const createSession = useCallback(async (): Promise<string | null> => {
|
||||
// Create a new case
|
||||
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 {
|
||||
// Use the web API to create a session if server is running
|
||||
const response = await fetch('http://localhost:3000/api/quick-start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ caseName: `tui-${Date.now()}` }),
|
||||
body: JSON.stringify({ caseName: caseName || `case-${Date.now()}`, mode }),
|
||||
});
|
||||
|
||||
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) {
|
||||
// Refresh and select new session
|
||||
const sessionId = data.sessionId;
|
||||
setTimeout(() => {
|
||||
refreshSessions();
|
||||
refreshCases();
|
||||
setActiveSessionId(sessionId);
|
||||
}, 500);
|
||||
return sessionId;
|
||||
@@ -355,7 +511,7 @@ export function useSessionManager(): SessionManagerState {
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}, [refreshSessions]);
|
||||
}, [refreshSessions, refreshCases]);
|
||||
|
||||
// Kill a session
|
||||
const killSession = useCallback((sessionId: string) => {
|
||||
@@ -437,6 +593,55 @@ export function useSessionManager(): SessionManagerState {
|
||||
}
|
||||
}, [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 {
|
||||
sessions,
|
||||
activeSessionId,
|
||||
@@ -445,13 +650,18 @@ export function useSessionManager(): SessionManagerState {
|
||||
innerLoopState,
|
||||
innerTodos,
|
||||
respawnStatus,
|
||||
cases,
|
||||
refreshSessions,
|
||||
refreshCases,
|
||||
selectSession,
|
||||
createSession,
|
||||
createCase,
|
||||
killSession,
|
||||
killAllSessions,
|
||||
nextSession,
|
||||
prevSession,
|
||||
sendInput,
|
||||
toggleRespawn,
|
||||
renameSession,
|
||||
};
|
||||
}
|
||||
|
||||
+136
-1
@@ -11,6 +11,7 @@
|
||||
* - Real-time terminal output via screen hardcopy polling
|
||||
* - Ralph Wiggum loop tracking
|
||||
* - Respawn status monitoring
|
||||
* - Auto-detection and startup of web server if not running
|
||||
*
|
||||
* @example
|
||||
* ```bash
|
||||
@@ -25,6 +26,8 @@
|
||||
*/
|
||||
|
||||
import { render } from 'ink';
|
||||
import { spawn } from 'child_process';
|
||||
import { createInterface } from 'readline';
|
||||
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.
|
||||
*
|
||||
@@ -51,10 +136,16 @@ function isRawModeSupported(): boolean {
|
||||
* The terminal is cleared for a full-screen experience.
|
||||
* 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
|
||||
* @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
|
||||
if (!isRawModeSupported()) {
|
||||
console.error('Error: TUI requires an interactive terminal with TTY support.');
|
||||
@@ -62,6 +153,50 @@ export async function startTUI(): Promise<void> {
|
||||
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
|
||||
process.stdout.write('\x1b[2J\x1b[H');
|
||||
|
||||
|
||||
@@ -299,6 +299,8 @@ export interface CreateCaseRequest {
|
||||
export interface QuickStartRequest {
|
||||
/** Optional case name, defaults to 'testcase' */
|
||||
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 fastifyStatic from '@fastify/static';
|
||||
import { join, dirname } from 'node:path';
|
||||
import { join, dirname, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync } from 'node:fs';
|
||||
import { homedir, totalmem, freemem, loadavg, cpus } from 'node:os';
|
||||
@@ -749,6 +749,13 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
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)) {
|
||||
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.` };
|
||||
}
|
||||
|
||||
const { caseName = 'testcase' } = req.body as QuickStartRequest;
|
||||
const { caseName = 'testcase', mode = 'claude' } = req.body as QuickStartRequest;
|
||||
|
||||
// Validate case name
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(caseName)) {
|
||||
@@ -801,6 +808,13 @@ export class WebServer extends EventEmitter {
|
||||
|
||||
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
|
||||
if (!existsSync(casePath)) {
|
||||
try {
|
||||
@@ -822,16 +836,22 @@ export class WebServer extends EventEmitter {
|
||||
const session = new Session({
|
||||
workingDir: casePath,
|
||||
screenManager: this.screenManager,
|
||||
useScreen: true
|
||||
useScreen: true,
|
||||
mode: mode,
|
||||
});
|
||||
this.sessions.set(session.id, session);
|
||||
this.setupSessionListeners(session);
|
||||
this.broadcast('session:created', session.toDetailedState());
|
||||
|
||||
// Start interactive mode
|
||||
// Start in the appropriate mode
|
||||
try {
|
||||
await session.startInteractive();
|
||||
this.broadcast('session:interactive', { id: session.id });
|
||||
if (mode === 'shell') {
|
||||
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() });
|
||||
|
||||
return {
|
||||
@@ -1034,10 +1054,11 @@ export class WebServer extends EventEmitter {
|
||||
this.broadcast('session:exit', { id: session.id, code });
|
||||
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);
|
||||
if (controller) {
|
||||
controller.stop();
|
||||
controller.removeAllListeners();
|
||||
this.respawnControllers.delete(session.id);
|
||||
}
|
||||
});
|
||||
@@ -1285,11 +1306,10 @@ export class WebServer extends EventEmitter {
|
||||
run.status = 'stopped';
|
||||
run.logs.push(`[${new Date().toISOString()}] Run stopped by user`);
|
||||
|
||||
if (run.sessionId) {
|
||||
const session = this.sessions.get(run.sessionId);
|
||||
if (session) {
|
||||
await session.stop();
|
||||
}
|
||||
// Use cleanupSession for proper resource cleanup (listeners, respawn, etc.)
|
||||
if (run.sessionId && this.sessions.has(run.sessionId)) {
|
||||
await this.cleanupSession(run.sessionId);
|
||||
run.sessionId = null;
|
||||
}
|
||||
|
||||
this.broadcast('scheduled:stopped', run);
|
||||
@@ -1570,22 +1590,25 @@ export class WebServer extends EventEmitter {
|
||||
// Stop screen stats collection
|
||||
this.screenManager.stopStatsCollection();
|
||||
|
||||
// Stop all respawn controllers
|
||||
// Stop all respawn controllers and remove listeners
|
||||
for (const controller of this.respawnControllers.values()) {
|
||||
controller.stop();
|
||||
controller.removeAllListeners();
|
||||
}
|
||||
this.respawnControllers.clear();
|
||||
|
||||
// Stop all sessions
|
||||
for (const session of this.sessions.values()) {
|
||||
await session.stop();
|
||||
}
|
||||
|
||||
// Stop all scheduled runs
|
||||
// Stop all scheduled runs first (they have their own session cleanup)
|
||||
for (const [id] of this.scheduledRuns) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,7 +101,7 @@ describe('Session Cleanup', () => {
|
||||
for (const id of sessionIds) {
|
||||
expect(sessions.find((s: any) => s.id === id)).toBeUndefined();
|
||||
}
|
||||
});
|
||||
}, 60000); // Extended timeout for multi-session test
|
||||
});
|
||||
|
||||
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