Files
Codeman/src/tui/index.tsx
T
arkonandClaude Opus 4.5 d5ae376e07 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>
2026-01-21 09:54:42 +01:00

224 lines
6.5 KiB
TypeScript

/**
* @fileoverview TUI entry point for Claudeman
*
* Entry point for the terminal user interface, providing a full-screen
* session manager similar to the web interface but entirely in the terminal.
*
* @description
* Built with Ink (React for CLI), the TUI offers:
* - Session discovery from ~/.claudeman/screens.json
* - Tab-based session navigation (like browser tabs)
* - 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
* # Start the TUI
* claudeman tui
* # Or via npm
* npm run tui
* ```
*
* @see {@link ./App.tsx} for main application component
* @see {@link ./hooks/useSessionManager.ts} for state management
*/
import { render } from 'ink';
import { spawn } from 'child_process';
import { createInterface } from 'readline';
import { App } from './App.js';
/**
* Checks if the terminal supports raw mode input.
*
* @description
* Raw mode is required for Ink to capture keyboard input directly.
* This check fails when stdin is piped or redirected (e.g., `echo | claudeman tui`).
*
* @returns true if raw mode is available, false otherwise
*/
function isRawModeSupported(): boolean {
return Boolean(
process.stdin.isTTY &&
typeof process.stdin.setRawMode === 'function'
);
}
/**
* 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.
*
* @description
* Initializes the Ink renderer and displays the TUI.
* 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(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.');
console.error('Make sure you are running this command in a real terminal, not piped.');
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');
const { waitUntilExit, unmount } = render(<App />);
// Handle graceful exit on SIGINT/SIGTERM
const cleanup = () => {
unmount();
// Restore terminal state
process.stdout.write('\x1b[?25h'); // Show cursor
process.stdout.write('\x1b[2J\x1b[H'); // Clear screen
};
process.on('SIGINT', cleanup);
process.on('SIGTERM', cleanup);
try {
await waitUntilExit();
} finally {
process.off('SIGINT', cleanup);
process.off('SIGTERM', cleanup);
cleanup();
}
}