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:
arkon
2026-01-21 09:54:42 +01:00
co-authored by Claude Opus 4.5
parent 3782ec2a74
commit d5ae376e07
13 changed files with 1258 additions and 192 deletions
+44 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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}
/>
);
+18 -4
View File
@@ -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
View File
@@ -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">&gt; </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">&gt; </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>
);
}
+218 -8
View File
@@ -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
View File
@@ -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');
+2
View File
@@ -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
View File
@@ -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();
}
}
+1 -1
View File
@@ -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', () => {
+34
View File
@@ -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)