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
+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>
);
}