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