/** * @fileoverview StartScreen component * * Session discovery and selection interface for the TUI. * * @description * The initial screen displayed when launching `claudeman tui`: * - Shows existing sessions and available cases * - Supports two modes: session list and case selection * - Arrow key navigation with visual selection highlight * - 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, 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[]; lastUsedCase: string | null; onSelectSession: (session: ScreenSession) => void; onAttachSession: (session: ScreenSession) => void; onDeleteSession: (session: ScreenSession) => void; onDeleteAll: () => void; onCreateSession: (caseName?: string, count?: number, mode?: 'claude' | 'shell') => void; onCreateCase: (name: string) => Promise; 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. * * @param ms - Duration in milliseconds * @returns Formatted string like "45s", "5m", "2h 15m", or "3d 5h" */ function formatDuration(ms: number): string { const seconds = Math.floor(ms / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); if (days > 0) { return `${days}d ${hours % 24}h`; } if (hours > 0) { return `${hours}h ${minutes % 60}m`; } if (minutes > 0) { return `${minutes}m`; } return `${seconds}s`; } /** * Gets display name for a session (matches web interface logic). * Priority: custom name > directory name (case) > session ID */ function getSessionName(session: ScreenSession): string { if (session.name) { return session.name; } if (session.workingDir) { return session.workingDir.split('/').pop() || session.workingDir; } return session.sessionId.slice(0, 8); } /** * Start screen component for session discovery and selection. * * @description * Renders two views: * - Sessions view: List of active sessions * - Cases view: List of available cases to start a new session * * @param props - Component props * @returns The start screen element */ export function StartScreen({ sessions, cases, lastUsedCase, onSelectSession, onAttachSession, onDeleteSession, onDeleteAll, onCreateSession, onCreateCase, onRefresh, onRefreshCases, onExit, }: StartScreenProps): React.ReactElement { const now = Date.now(); const [mode, setMode] = useState('sessions'); const [selectedIndex, setSelectedIndex] = useState(0); const [newCaseName, setNewCaseName] = useState(''); const [multiStartCount, setMultiStartCount] = useState('1'); const [error, setError] = useState(null); // Reset selection when switching modes - default to lastUsedCase for cases view useEffect(() => { if (mode === 'cases' && lastUsedCase && cases.length > 0) { const lastUsedIndex = cases.findIndex(c => c.name === lastUsedCase); setSelectedIndex(lastUsedIndex >= 0 ? lastUsedIndex : 0); } else { setSelectedIndex(0); } setError(null); if (mode !== 'multi-start') { setMultiStartCount('1'); } }, [mode, lastUsedCase, cases]); // 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 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 && listLength > 0) { setSelectedIndex((prev) => (prev < listLength - 1 ? prev + 1 : 0)); return; } // 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; } // 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; } // 'D' (shift+d) to delete ALL screens and Claude processes if (input === 'D') { onDeleteAll(); 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; } } // 'r' to refresh if (input === 'r') { if (mode === 'sessions') { onRefresh(); } else { onRefreshCases(); } return; } // 'q' to quit if (input === 'q') { onExit(); return; } }); // 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 ( Create New Case Enter case name (letters, numbers, hyphens, underscores): > {error && ( {error} )} [Enter] Create [Esc] Cancel ); } // Render multi-start input mode if (mode === 'multi-start') { const selectedCase = cases[selectedIndex]; return ( Start Multiple Sessions {/* Show selected case */} Selected Case: {selectedCase?.name || 'none'} How many sessions to start? (1-20): > {error && ( {error} )} [Enter] Start Sessions [Esc] Cancel ); } // Get the default case to display (lastUsedCase or first case or 'testcase') const defaultCase = lastUsedCase || (cases.length > 0 ? cases[0].name : 'testcase'); const defaultCasePath = cases.find(c => c.name === defaultCase)?.path; return ( {/* Header with current case */} Claudeman TUI Case: {defaultCase} {defaultCasePath && ( ({defaultCasePath.replace(process.env.HOME || '', '~')}) )} {/* Mode tabs */} {' [s] Sessions '} {' [c] Cases '} | Press ? for help {/* Selected item display */} {selectedItem && ( Selected: {selectedItem.name} {selectedItem.path && ( ({selectedItem.path.replace(process.env.HOME || '', '~')}) )} )} {/* Sessions view */} {mode === 'sessions' && ( <> {sessions.length === 0 ? ( No sessions found Press [n] to create a new session, or [c] to select a case ) : ( {/* Table header */} {' '} {'NAME'.padEnd(22)} {'RUNTIME'.padEnd(12)} {'STATUS'.padEnd(10)} {'MODE'.padEnd(10)} {/* 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 = getSessionName(session).slice(0, 20); const isSelected = index === selectedIndex; return ( {isSelected ? ( {' \u25B6 '} {name.padEnd(22)} {runtime.padEnd(12)} {statusIcon} {statusText.padEnd(8)} {session.mode.padEnd(10)} ) : ( {' '} {name.padEnd(22)} {runtime.padEnd(12)} {statusIcon} {statusText.padEnd(8)} {session.mode.padEnd(10)} )} ); })} )} {/* Sessions footer */} [n] New [c] Cases [{'\u2191\u2193'}] Navigate [Enter] View [a] Attach [d] Delete [D] Delete ALL [q] Quit )} {/* Cases view */} {mode === 'cases' && ( <> {cases.length === 0 ? ( No cases found Press [n] to create a new case ) : ( {/* Table header */} {' '} {'CASE NAME'.padEnd(30)} {'PATH'.padEnd(40)} {/* 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 ( {isSelected ? ( {' \u25B6 '} {name.padEnd(30)} {path.padEnd(40)} ) : ( {' '} {name.padEnd(30)} {path.padEnd(40)} )} ); })} )} {/* Cases footer */} [Enter] Claude [h] Shell [m] Multi (1-20) [n] New Case [s] Sessions [r] Refresh [q] Quit )} ); }