chore: bump version to 0.1427

- Remove TUI from git (moved to .gitignore for local development)
- Remove React/Ink dependencies (unused in published version)
- Remove tui command from CLI

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-01-29 12:47:23 +01:00
co-authored by Claude Opus 4.5
parent a73f7c3a4f
commit db20f4f63d
22 changed files with 414 additions and 3311 deletions
+3
View File
@@ -37,6 +37,9 @@ Thumbs.db
# Temporary files
*.tmp
*.temp
# Unfinished TUI (local development only)
src/tui/
.claude/
media-assets/
commands
+1 -1
View File
@@ -16,7 +16,7 @@ When user says "COM":
1. Increment version in BOTH `package.json` AND `CLAUDE.md`
2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web`
**Version**: 0.1426 (must match `package.json`)
**Version**: 0.1427 (must match `package.json`)
## Project Overview
+1 -7
View File
@@ -1,6 +1,6 @@
{
"name": "claudeman",
"version": "0.1426",
"version": "0.1427",
"description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence",
"type": "module",
"main": "dist/index.js",
@@ -14,7 +14,6 @@
"start": "node dist/index.js",
"dev": "tsx src/index.ts",
"web": "node dist/index.js web",
"tui": "tsx src/index.ts tui",
"clean": "rm -rf dist",
"test": "vitest run",
"test:watch": "vitest",
@@ -45,18 +44,13 @@
"chokidar": "^3.6.0",
"commander": "^12.1.0",
"fastify": "^5.1.0",
"ink": "^6.6.0",
"ink-spinner": "^5.0.0",
"ink-text-input": "^6.0.0",
"node-pty": "^1.1.0",
"react": "^19.2.3",
"uuid": "^10.0.0",
"zod": "^4.3.6"
},
"devDependencies": {
"@types/node": "^20.14.0",
"@types/pngjs": "^6.0.5",
"@types/react": "^19.2.9",
"@types/uuid": "^10.0.0",
"@vitest/coverage-v8": "^4.0.18",
"agent-browser": "^0.6.0",
-22
View File
@@ -515,26 +515,4 @@ program
}
});
// TUI command
program
.command('tui')
.description('Start the terminal user interface')
.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');
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: ${getErrorMessage(err)}`));
process.exit(1);
}
});
export { program };
-460
View File
@@ -1,460 +0,0 @@
/**
* @fileoverview Main TUI App component
*
* The root component that manages the overall TUI layout and navigation.
*
* @description
* Provides two primary views:
* - **StartScreen**: Session discovery and selection interface
* - **Main view**: Active session management with:
* - TabBar: Session tabs with switching
* - TerminalView: Live terminal output display
* - RalphPanel: Ralph loop tracking (conditional)
* - StatusBar: Session info and keyboard hints
*
* Keyboard shortcuts are handled globally via Ink's useInput hook.
*
* @example
* ```tsx
* import { render } from 'ink';
* import { App } from './App.js';
*
* render(<App />);
* ```
*/
import React, { useState, useEffect, useCallback } from 'react';
import { Box, useApp, useInput, useStdout } from 'ink';
import { StartScreen } from './components/StartScreen.js';
import { TabBar } from './components/TabBar.js';
import { TerminalView } from './components/TerminalView.js';
import { StatusBar } from './components/StatusBar.js';
import { HelpOverlay } from './components/HelpOverlay.js';
import { RalphPanel } from './components/RalphPanel.js';
import { useSessionManager } from './hooks/useSessionManager.js';
import type { ScreenSession } from '../types.js';
/**
* Pending screen attachment request.
* Used to communicate between App component and the entry point.
*/
export interface PendingAttach {
mode: 'tabs' | 'direct';
sessions: ScreenSession[];
index: number;
session?: ScreenSession;
}
// Module-level state for pending attachment (shared between App and entry point)
let pendingAttach: PendingAttach | null = null;
/**
* Set a pending screen attachment request.
* Called by App when user wants to attach to a screen.
*/
export function setPendingAttach(attach: PendingAttach): void {
pendingAttach = attach;
}
/**
* Get the current pending attachment request.
* Called by entry point after App exits.
*/
export function getPendingAttach(): PendingAttach | null {
return pendingAttach;
}
/**
* Clear the pending attachment request.
* Called by entry point after handling the attachment.
*/
export function clearPendingAttach(): void {
pendingAttach = null;
}
type ViewMode = 'start' | 'main';
/**
* Main TUI application component.
*
* @description
* Renders either the StartScreen or Main view based on current state.
* Handles all global keyboard shortcuts and manages terminal dimensions.
*
* **Global Shortcuts:**
* - `?` or `Ctrl+H`: Show help overlay
* - `Ctrl+C`: Exit application
* - `Ctrl+Tab/Shift+Tab`: Navigate sessions
* - `Ctrl+1-9`: Direct session access
* - `Ctrl+W`: Close current session
* - `Ctrl+K`: Kill all sessions
* - `Ctrl+N`: Create new session
* - `Escape`: Return to start screen
*
* @returns The rendered TUI application
*/
export function App(): React.ReactElement {
const { exit } = useApp();
const { stdout } = useStdout();
const [viewMode, setViewMode] = useState<ViewMode>('start');
const [showHelp, setShowHelp] = useState(false);
const [terminalHeight, setTerminalHeight] = useState(20);
const {
sessions,
activeSessionId,
activeSession,
refreshSessions,
refreshCases,
selectSession,
createSession,
createCase,
killSession,
killAllSessions,
killAllScreensAndClaude,
nextSession,
prevSession,
sendInput,
terminalOutput,
ralphLoopState,
ralphTodos,
respawnStatus,
cases,
lastUsedCase,
toggleRespawn,
} = useSessionManager();
// Calculate terminal height based on stdout dimensions
useEffect(() => {
const updateHeight = () => {
// Reserve: 3 for TabBar, 3 for StatusBar, 2 for borders
const rows = stdout?.rows || 24;
setTerminalHeight(Math.max(10, rows - 8));
};
updateHeight();
stdout?.on('resize', updateHeight);
return () => {
stdout?.off('resize', updateHeight);
};
}, [stdout]);
// Handle keyboard input
useInput((input, key) => {
// Help overlay takes priority
if (showHelp) {
if (key.escape || input === 'q') {
setShowHelp(false);
}
return;
}
// Global shortcuts - use Ctrl+H for help (? is a normal character)
if (key.ctrl && input === 'h') {
setShowHelp(true);
return;
}
// Exit on Ctrl+C
if (key.ctrl && input === 'c') {
exit();
return;
}
// Start screen specific inputs - handled by StartScreen component
// Only number keys for quick session selection are handled here
if (viewMode === 'start') {
const num = parseInt(input, 10);
if (!isNaN(num) && num >= 1 && num <= sessions.length) {
handleSelectSession(sessions[num - 1]);
return;
}
return;
}
// === SESSION SWITCHING SHORTCUTS ===
// Ctrl+Tab / Ctrl+Shift+Tab for session switching (if terminal supports it)
// Plain Tab is sent to the session for auto-complete
if (key.ctrl && key.tab) {
if (key.shift) {
prevSession();
} else {
nextSession();
}
return;
}
// Alt+Left/Right arrow keys for session switching
if (key.meta && key.leftArrow) {
prevSession();
return;
}
if (key.meta && key.rightArrow) {
nextSession();
return;
}
// Ctrl+[ and Ctrl+] for previous/next session (vim-style, requires Ctrl)
// Note: Ctrl+[ is often Escape, so this may not work on all terminals
if (key.ctrl && input === '[') {
prevSession();
return;
}
if (key.ctrl && input === ']') {
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);
return;
}
}
// === SESSION MANAGEMENT SHORTCUTS ===
// Ctrl+W to close current session
if (key.ctrl && input === 'w') {
if (activeSessionId) {
killSession(activeSessionId);
if (sessions.length <= 1) {
setViewMode('start');
}
}
return;
}
// Ctrl+K to kill all sessions
if (key.ctrl && input === 'k') {
killAllSessions();
setViewMode('start');
return;
}
// Ctrl+N for new session
if (key.ctrl && input === 'n') {
handleCreateSession();
return;
}
// 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 INPUT TO SESSION ===
// Forward all other input to the active screen session
if (activeSessionId) {
// Handle special keys
if (key.return) {
sendInput(activeSessionId, '\r');
return;
}
if (key.backspace || key.delete) {
sendInput(activeSessionId, '\x7f');
return;
}
// Tab key - send to session for auto-complete
if (key.tab && !key.ctrl) {
sendInput(activeSessionId, '\t');
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;
}
}
});
/**
* Select a session and enter direct attach mode with tab bar.
* Sets pending attachment and exits Ink so the entry point can handle it.
*/
const handleSelectSession = useCallback((session: ScreenSession) => {
const sessionIndex = sessions.findIndex(s => s.sessionId === session.sessionId);
if (sessionIndex === -1) return;
// Set pending attachment and exit Ink
// The entry point will handle the actual screen attachment
setPendingAttach({
mode: 'tabs',
sessions: [...sessions], // Copy to avoid stale reference
index: sessionIndex,
});
exit();
}, [sessions, exit]);
const handleCreateSession = useCallback(async (caseName?: string, count?: number, mode: 'claude' | 'shell' = 'claude') => {
const sessionsToCreate = Math.min(Math.max(count || 1, 1), 20);
let lastSessionId: string | null = null;
// Create sessions sequentially
for (let i = 0; i < sessionsToCreate; i++) {
const sessionId = await createSession(caseName || 'default', mode);
if (sessionId) {
lastSessionId = sessionId;
// Wait a moment for screen to be ready
await new Promise(resolve => setTimeout(resolve, 500));
refreshSessions();
}
if (i < sessionsToCreate - 1) {
await new Promise(resolve => setTimeout(resolve, 300));
}
}
// Refresh to get updated session list
await new Promise(resolve => setTimeout(resolve, 200));
refreshSessions();
// Wait for state to update, then attach to the new session
await new Promise(resolve => setTimeout(resolve, 300));
if (lastSessionId) {
// Re-fetch sessions to get the fresh list with the new session
// We need to read the screens file directly since state may not have updated yet
const { existsSync, readFileSync } = await import('fs');
const { homedir } = await import('os');
const { join } = await import('path');
const screensFile = join(homedir(), '.claudeman', 'screens.json');
let freshSessions: ScreenSession[] = [];
try {
if (existsSync(screensFile)) {
freshSessions = JSON.parse(readFileSync(screensFile, 'utf-8'));
}
} catch {
freshSessions = sessions;
}
if (freshSessions.length > 0) {
const sessionIndex = freshSessions.findIndex(s => s.sessionId === lastSessionId);
const targetIndex = sessionIndex >= 0 ? sessionIndex : freshSessions.length - 1;
// Set pending attachment and exit Ink
setPendingAttach({
mode: 'tabs',
sessions: freshSessions,
index: targetIndex,
});
exit();
}
}
}, [createSession, refreshSessions, sessions, exit]);
/**
* Attach directly to a screen session (skipping tab menu).
* Sets pending attachment and exits Ink so the entry point can handle it.
*/
const handleAttachSession = useCallback((session: ScreenSession) => {
// Set pending attachment for direct mode and exit Ink
setPendingAttach({
mode: 'direct',
sessions: [...sessions],
index: sessions.findIndex(s => s.sessionId === session.sessionId),
session: session,
});
exit();
}, [sessions, exit]);
// Render help overlay if shown
if (showHelp) {
return <HelpOverlay onClose={() => setShowHelp(false)} />;
}
// Handle delete session from start screen
const handleDeleteSession = useCallback((session: ScreenSession) => {
killSession(session.sessionId);
}, [killSession]);
// Render start screen
if (viewMode === 'start') {
return (
<StartScreen
sessions={sessions}
cases={cases}
lastUsedCase={lastUsedCase}
onSelectSession={handleSelectSession}
onAttachSession={handleAttachSession}
onDeleteSession={handleDeleteSession}
onDeleteAll={killAllScreensAndClaude}
onCreateSession={handleCreateSession}
onCreateCase={createCase}
onRefresh={refreshSessions}
onRefreshCases={refreshCases}
onExit={exit}
/>
);
}
// Check if Ralph panel should be visible (enabled and has data)
const showRalphPanel = ralphLoopState?.enabled && (ralphLoopState.active || ralphTodos.length > 0);
// Adjust terminal height if Ralph panel is shown
const adjustedTerminalHeight = showRalphPanel ? terminalHeight - 6 : terminalHeight;
// Render main view with tabs, terminal, ralph panel, and status bar
return (
<Box flexDirection="column" height="100%">
<TabBar
sessions={sessions}
activeSessionId={activeSessionId}
onSelectSession={(id) => selectSession(id)}
/>
<TerminalView
output={terminalOutput}
height={adjustedTerminalHeight}
session={activeSession}
/>
{showRalphPanel && (
<RalphPanel
loopState={ralphLoopState}
todos={ralphTodos}
/>
)}
<StatusBar session={activeSession} respawnStatus={respawnStatus} />
</Box>
);
}
-262
View File
@@ -1,262 +0,0 @@
/**
* @fileoverview Direct screen attachment with tab switching
*
* Provides direct full-screen console access to screen sessions.
* Tab menu shown between sessions for switching.
*/
import { spawnSync, execSync } from 'child_process';
import * as fs from 'fs';
import type { ScreenSession } from '../types.js';
// ANSI escape codes
const ESC = '\x1b';
const CSI = `${ESC}[`;
const RESET = `${CSI}0m`;
const BOLD = `${CSI}1m`;
const DIM = `${CSI}2m`;
const BG_BLUE = `${CSI}44m`;
const FG_WHITE = `${CSI}37m`;
const FG_CYAN = `${CSI}36m`;
const FG_YELLOW = `${CSI}33m`;
/** Pre-compiled regex for parsing `screen -ls` output */
const SCREEN_PATTERN = /(\d+)\.(claudeman-[a-f0-9-]+)/g;
/**
* Get full screen identifier (PID.screenName) from screen -ls output.
* This is needed because `screen -x screenName` can fail when multiple
* screens exist, but `screen -x PID.screenName` is unambiguous.
*
* @param screenName - The screen name without PID (e.g., "claudeman-abc123")
* @returns The full screen ID (e.g., "12345.claudeman-abc123") or null if not found
*/
export function getFullScreenId(screenName: string): string | null {
try {
const output = execSync('screen -ls', {
encoding: 'utf-8',
timeout: 5000,
});
// Reset lastIndex before using global regex
SCREEN_PATTERN.lastIndex = 0;
let match;
while ((match = SCREEN_PATTERN.exec(output)) !== null) {
if (match[2] === screenName) {
return `${match[1]}.${match[2]}`;
}
}
return null;
} catch {
return null;
}
}
/**
* Check if a screen session is alive
*/
function isScreenAlive(screenName: string): boolean {
return getFullScreenId(screenName) !== null;
}
/**
* Clear screen and move cursor to top-left
*/
function clearScreen(): void {
process.stdout.write(`${CSI}2J${CSI}H`);
}
/**
* Move cursor to position (1-indexed)
*/
function moveTo(row: number, col: number): void {
process.stdout.write(`${CSI}${row};${col}H`);
}
/**
* Get terminal dimensions
*/
function getTerminalSize(): { rows: number; cols: number } {
return {
rows: process.stdout.rows || 24,
cols: process.stdout.columns || 80,
};
}
/**
* Read a single keypress synchronously
*/
function readKeySync(): string {
const buffer = Buffer.alloc(3);
if (process.stdin.isTTY) {
process.stdin.setRawMode(true);
}
try {
const bytesRead = fs.readSync(process.stdin.fd, buffer, 0, 3, null);
return buffer.toString('utf8', 0, bytesRead);
} catch {
return '\r';
} finally {
if (process.stdin.isTTY) {
process.stdin.setRawMode(false);
}
}
}
/**
* Show tab selection menu
*/
function showTabMenu(
sessions: ScreenSession[],
currentIndex: number
): number | null {
const { cols } = getTerminalSize();
clearScreen();
// Header
moveTo(1, 1);
process.stdout.write(`${BG_BLUE}${FG_WHITE}${BOLD}`);
process.stdout.write(' Switch Session '.padEnd(cols, ' '));
process.stdout.write(RESET);
// Session list
moveTo(3, 1);
process.stdout.write(`${FG_YELLOW}Select session (1-${sessions.length}), Enter for current, q to quit:${RESET}\n\n`);
sessions.forEach((session, i) => {
const isActive = i === currentIndex;
const prefix = isActive ? `${FG_CYAN}> ` : ' ';
const name = session.name || session.screenName.replace('claudeman-', '');
const mode = session.mode === 'shell' ? ' [shell]' : '';
const alive = isScreenAlive(session.screenName);
const status = alive ? `${FG_CYAN}alive${RESET}` : `${DIM}dead${RESET}`;
process.stdout.write(`${prefix}${i + 1}. ${name}${mode} (${status})${RESET}\n`);
});
process.stdout.write(`\n${DIM}Ctrl+A D to detach and return here${RESET}\n`);
// Read keypress
const input = readKeySync();
if (input === 'q' || input === '\x1b') {
return null;
}
if (input === '\r' || input === '\n') {
return currentIndex;
}
const num = parseInt(input, 10);
if (!isNaN(num) && num >= 1 && num <= sessions.length) {
return num - 1;
}
return currentIndex;
}
/**
* Attach to screen sessions with tab switching between detaches.
*
* Flow:
* 1. If single session, attach directly (skip tab menu)
* 2. Show tab menu to select session (if multiple)
* 3. Full-screen attach to selected session
* 4. On detach (Ctrl+A D), show tab menu again
* 5. Repeat until user presses 'q' to return to TUI
*/
export function attachWithTabs(
sessions: ScreenSession[],
initialIndex: number,
onExit: () => void
): void {
if (sessions.length === 0) {
onExit();
return;
}
let currentIndex = Math.min(initialIndex, sessions.length - 1);
const runAttachLoop = (): void => {
const session = sessions[currentIndex];
// Get full screen ID (PID.screenName) for unambiguous attachment
const fullScreenId = session ? getFullScreenId(session.screenName) : null;
if (!session || !fullScreenId) {
// Session dead, show menu to pick another (or exit if only one)
if (sessions.length === 1) {
clearScreen();
onExit();
return;
}
const newIndex = showTabMenu(sessions, currentIndex);
if (newIndex === null) {
clearScreen();
onExit();
return;
}
currentIndex = newIndex;
runAttachLoop();
return;
}
// Clear and attach directly - full screen
clearScreen();
// Spawn screen with full ID (PID.screenName) to avoid ambiguity when multiple screens exist
const result = spawnSync('screen', ['-x', '-A', fullScreenId], {
stdio: 'inherit',
env: {
...process.env,
TERM: process.env.TERM || 'xterm-256color',
},
});
// If screen failed to attach, show error briefly
if (result.status !== 0 && result.status !== null) {
process.stdout.write(`\n${FG_YELLOW}Screen exited with code ${result.status}${RESET}\n`);
process.stdout.write(`${DIM}Press any key to continue...${RESET}`);
readKeySync();
}
// After detach, show tab menu (or exit if only one session)
if (sessions.length === 1) {
clearScreen();
onExit();
return;
}
const newIndex = showTabMenu(sessions, currentIndex);
if (newIndex === null) {
clearScreen();
onExit();
return;
}
currentIndex = newIndex;
runAttachLoop();
};
// For single session, skip tab menu and attach directly
if (sessions.length === 1) {
runAttachLoop();
return;
}
// Start with tab menu so user sees available sessions
const initialSelection = showTabMenu(sessions, currentIndex);
if (initialSelection === null) {
clearScreen();
onExit();
return;
}
currentIndex = initialSelection;
runAttachLoop();
}
-124
View File
@@ -1,124 +0,0 @@
/**
* @fileoverview HelpOverlay component
*
* Full-screen help overlay displaying all TUI keyboard shortcuts.
*
* @description
* Shows a comprehensive reference of keyboard shortcuts organized by context:
* - Start Screen: Session list navigation and actions
* - Main View - Navigation: Tab switching and screen navigation
* - Main View - Session Management: Create, close, kill sessions
* - General: Help toggle and exit
*
* Dismissible with Escape, q, or ? keys.
*/
import React from 'react';
import { Box, Text } from 'ink';
interface HelpOverlayProps {
onClose: () => void;
}
interface ShortcutGroup {
title: string;
shortcuts: Array<{ key: string; description: string }>;
}
const SHORTCUT_GROUPS: ShortcutGroup[] = [
{
title: 'Start Screen - Sessions',
shortcuts: [
{ key: '\u2191/\u2193', description: 'Navigate list' },
{ key: 'Enter', description: 'Tab switcher \u2192 full attach' },
{ key: 'a', description: 'Direct attach (skip tab menu)' },
{ key: 'd', description: 'Delete/kill selected session' },
{ key: 'D (Shift+d)', description: 'Delete ALL screens & Claude processes' },
{ 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: 'Tab Switcher Menu',
shortcuts: [
{ key: '1-9', description: 'Select and attach to session N' },
{ key: 'Enter', description: 'Attach to current session' },
{ key: 'q/Esc', description: 'Return to TUI start screen' },
{ key: 'Ctrl+A D', description: '(While attached) Detach to menu' },
],
},
{
title: 'General',
shortcuts: [
{ key: 'Ctrl+H', description: 'Toggle this help' },
{ key: 'Ctrl+C', description: 'Exit TUI' },
],
},
];
/**
* Help overlay component displaying keyboard shortcuts reference.
*
* @description
* Renders a full-screen overlay with categorized keyboard shortcuts.
* Uses consistent styling with bordered header and grouped sections.
*
* @param props - Component props
* @param props.onClose - Callback invoked when overlay should close
* @returns The help overlay element
*/
export function HelpOverlay({ onClose: _onClose }: HelpOverlayProps): React.ReactElement {
return (
<Box flexDirection="column" padding={2}>
{/* Header */}
<Box
borderStyle="double"
borderColor="cyan"
paddingX={2}
paddingY={1}
marginBottom={1}
justifyContent="center"
>
<Text bold color="cyan">
Keyboard Shortcuts
</Text>
</Box>
{/* Shortcut groups */}
{SHORTCUT_GROUPS.map((group, groupIndex) => (
<Box key={groupIndex} flexDirection="column" marginBottom={1}>
<Text bold color="yellow">
{group.title}
</Text>
<Box flexDirection="column" marginLeft={2}>
{group.shortcuts.map((shortcut, index) => (
<Box key={index}>
<Text color="green">{shortcut.key.padEnd(20)}</Text>
<Text>{shortcut.description}</Text>
</Box>
))}
</Box>
</Box>
))}
{/* Footer */}
<Box marginTop={1} borderStyle="single" borderColor="gray" paddingX={1}>
<Text dimColor>Press Escape or q to close</Text>
</Box>
</Box>
);
}
-143
View File
@@ -1,143 +0,0 @@
/**
* @fileoverview RalphPanel component
*
* Displays Ralph Wiggum autonomous loop tracking information.
*
* @description
* The Ralph Wiggum loop is an autonomous work mode where Claude
* continues iterating on tasks until completion criteria are met.
* This panel provides visibility into:
* - Loop status (active/idle) and completion phrase
* - Progress through todos with visual indicators
* - Cycle count and elapsed time tracking
*
* @see {@link file://./../../ralph-tracker.ts} for detection logic
* @see {@link file://./../../../docs/ralph-wiggum-guide.md} for full documentation
*/
import React from 'react';
import { Box, Text } from 'ink';
import type { RalphTrackerState, RalphTodoItem } from '../../types.js';
interface RalphPanelProps {
loopState: RalphTrackerState | null;
todos: RalphTodoItem[];
visible?: boolean;
}
/**
* Returns the visual indicator for a todo item's status.
*
* @param status - The todo status: 'completed', 'in_progress', or 'pending'
* @returns Object with Unicode icon and color name
*/
function getStatusIcon(status: string): { icon: string; color: string } {
switch (status) {
case 'completed':
return { icon: '\u2713', color: 'green' };
case 'in_progress':
return { icon: '\u25CF', color: 'yellow' };
case 'pending':
default:
return { icon: '\u25CB', color: 'gray' };
}
}
/**
* Formats elapsed hours to a compact human-readable string.
*
* @param hours - Elapsed time in hours (can be fractional)
* @returns Formatted string like "45m" or "2.5h"
*/
function formatElapsed(hours: number | null): string {
if (hours === null) return '-';
if (hours < 1) {
const minutes = Math.round(hours * 60);
return `${minutes}m`;
}
return `${hours.toFixed(1)}h`;
}
/**
* Panel component displaying Ralph Wiggum loop status and progress.
*
* @description
* Renders a bordered panel with:
* - Header showing loop status and completion phrase
* - Stats row with cycle count, elapsed time, and todo progress
* - Todo list (max 5 visible) with status icons
*
* Only renders when loop is enabled and has relevant data to show.
*
* @param props - Component props
* @param props.loopState - Current loop state from RalphTracker
* @param props.todos - Array of todo items being tracked
* @param props.visible - Whether the panel should be visible (default: true)
* @returns The panel element or null if hidden/disabled
*/
export function RalphPanel({
loopState,
todos,
visible = true,
}: RalphPanelProps): React.ReactElement | null {
// Don't render if not visible or no loop state
if (!visible || !loopState || !loopState.enabled) {
return null;
}
const completedCount = todos.filter((t) => t.status === 'completed').length;
const totalCount = todos.length;
const progressPercent = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0;
return (
<Box flexDirection="column" borderStyle="single" borderColor="magenta" marginBottom={1}>
{/* Header */}
<Box paddingX={1} justifyContent="space-between">
<Text color="magenta" bold>
Ralph Loop {loopState.active ? '(active)' : '(idle)'}
</Text>
{loopState.completionPhrase && (
<Text dimColor>
phrase: {loopState.completionPhrase}
</Text>
)}
</Box>
{/* Stats row */}
<Box paddingX={1}>
<Text>
<Text dimColor>cycles:</Text> {loopState.cycleCount}
{loopState.maxIterations && <Text>/{loopState.maxIterations}</Text>}
<Text> | </Text>
<Text dimColor>elapsed:</Text> {formatElapsed(loopState.elapsedHours)}
<Text> | </Text>
<Text dimColor>todos:</Text> {completedCount}/{totalCount} ({progressPercent}%)
</Text>
</Box>
{/* Todo list (max 5 items) */}
{todos.length > 0 && (
<Box flexDirection="column" paddingX={1} marginTop={1}>
{todos.slice(0, 5).map((todo) => {
const { icon, color } = getStatusIcon(todo.status);
const content = todo.content.length > 50
? todo.content.slice(0, 47) + '...'
: todo.content;
return (
<Box key={todo.id}>
<Text color={color}>{icon} </Text>
<Text color={todo.status === 'completed' ? 'gray' : undefined}>
{content}
</Text>
</Box>
);
})}
{todos.length > 5 && (
<Text dimColor>... and {todos.length - 5} more</Text>
)}
</Box>
)}
</Box>
);
}
-637
View File
@@ -1,637 +0,0 @@
/**
* @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<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.
*
* @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<ScreenMode>('sessions');
const [selectedIndex, setSelectedIndex] = useState(0);
const [newCaseName, setNewCaseName] = useState('');
const [multiStartCount, setMultiStartCount] = useState('1');
const [error, setError] = useState<string | null>(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 (
<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>
);
}
// 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 (
<Box flexDirection="column" padding={1}>
{/* Header with current case */}
<Box
borderStyle="double"
borderColor="cyan"
paddingX={2}
paddingY={1}
justifyContent="space-between"
>
<Text bold color="cyan">
Claudeman TUI
</Text>
<Text>
<Text color="yellow">Case: </Text>
<Text bold color="white">{defaultCase}</Text>
{defaultCasePath && (
<Text dimColor> ({defaultCasePath.replace(process.env.HOME || '', '~')})</Text>
)}
</Text>
</Box>
{/* Mode tabs */}
<Box marginY={1}>
<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>
{/* 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>
)}
{/* 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 = getSessionName(session).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="red" bold>[D]</Text>
<Text> Delete ALL </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>
);
}
-134
View File
@@ -1,134 +0,0 @@
/**
* @fileoverview StatusBar component
*
* Bottom status bar providing session information and navigation hints.
*
* @description
* Displays real-time session metrics:
* - Session name and connection status (alive/dead)
* - Runtime duration since session creation
* - Session mode (claude/shell)
* - Respawn controller status when enabled
* - Keyboard shortcut hints for quick reference
*/
import React from 'react';
import { Box, Text } from 'ink';
import type { ScreenSession } from '../../types.js';
interface RespawnStatus {
enabled: boolean;
state: string;
cycleCount: number;
}
interface StatusBarProps {
session: ScreenSession | null;
inputMode?: boolean;
respawnStatus?: RespawnStatus | null;
}
/**
* Formats a duration from milliseconds to a human-readable string.
*
* @param ms - Duration in milliseconds
* @returns Formatted string like "45s", "5m 30s", or "2h 15m"
*/
function formatDuration(ms: number): string {
const seconds = Math.floor(ms / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
if (hours > 0) {
return `${hours}h ${minutes % 60}m`;
}
if (minutes > 0) {
return `${minutes}m ${seconds % 60}s`;
}
return `${seconds}s`;
}
/**
* Status bar component displaying session information and keyboard hints.
*
* @description
* Renders a bordered bar at the bottom of the TUI with:
* - Left side: Session name, status indicator, runtime, mode, respawn state
* - Right side: Quick keyboard shortcut reference
*
* When no session is selected, displays a minimal bar with navigation hints.
*
* @param props - Component props
* @param props.session - The currently active session or null
* @param props.inputMode - Whether input mode is active (shows yellow indicator)
* @param props.respawnStatus - Respawn controller status if enabled
* @returns The status bar element
*/
export function StatusBar({ session, inputMode = false, respawnStatus }: StatusBarProps): React.ReactElement {
if (!session) {
return (
<Box
borderStyle="single"
borderColor="gray"
paddingX={1}
justifyContent="space-between"
>
<Text dimColor>No session selected</Text>
<Text dimColor>Press ? for help | Esc to go back</Text>
</Box>
);
}
const runtime = formatDuration(Date.now() - session.createdAt);
const statusColor = session.attached ? 'green' : 'red';
const statusText = session.attached ? 'alive' : 'dead';
const sessionName = session.name || 'unnamed';
return (
<Box
borderStyle="single"
borderColor="cyan"
paddingX={1}
justifyContent="space-between"
>
{/* Left side: Session info */}
<Box>
<Text color="cyan" bold>
{sessionName}
</Text>
<Text> | </Text>
<Text color={statusColor} bold>
{'\u25CF'} {statusText}
</Text>
<Text> | </Text>
<Text>
<Text dimColor>runtime:</Text> {runtime}
</Text>
<Text> | </Text>
<Text>
<Text dimColor>mode:</Text> {session.mode}
</Text>
{inputMode && (
<>
<Text> | </Text>
<Text color="yellow" bold>INPUT MODE</Text>
</>
)}
{respawnStatus?.enabled && (
<>
<Text> | </Text>
<Text color="magenta">
respawn: {respawnStatus.state.replace(/_/g, ' ')}
{respawnStatus.cycleCount > 0 && ` (${respawnStatus.cycleCount})`}
</Text>
</>
)}
</Box>
{/* Right side: Keyboard hints */}
<Box>
<Text dimColor>? help | Esc back | type to send input</Text>
</Box>
</Box>
);
}
-87
View File
@@ -1,87 +0,0 @@
/**
* @fileoverview TabBar component
*
* Horizontal tab bar for session navigation in the TUI.
*
* @description
* Provides browser-like tab navigation for Claude sessions:
* - Active tab highlighted with blue background
* - Status indicators: green dot (alive) or red dot (dead)
* - Session names truncated to 15 characters
* - Keyboard shortcut hints on the right
*
* Tab switching is handled by parent via keyboard shortcuts (Ctrl+Tab).
*/
import React from 'react';
import { Box, Text } from 'ink';
import type { ScreenSession } from '../../types.js';
interface TabBarProps {
sessions: ScreenSession[];
activeSessionId: string | null;
onSelectSession: (sessionId: string) => void;
}
/**
* Tab bar component showing all available sessions.
*
* @description
* Renders a horizontal bar with one tab per session, showing:
* - Status indicator (filled/hollow circle for alive/dead)
* - Session name (truncated to 15 chars)
* - Visual highlighting for the active session
*
* @param props - Component props
* @param props.sessions - Array of all sessions to display as tabs
* @param props.activeSessionId - ID of currently active session for highlighting
* @param props.onSelectSession - Callback when a tab is clicked (unused, keyboard nav only)
* @returns The tab bar element
*/
export function TabBar({
sessions,
activeSessionId,
}: TabBarProps): React.ReactElement {
if (sessions.length === 0) {
return (
<Box borderStyle="single" borderColor="gray" paddingX={1}>
<Text dimColor>No sessions</Text>
</Box>
);
}
return (
<Box borderStyle="single" borderColor="cyan" paddingX={1}>
{sessions.map((session, index) => {
const isActive = session.sessionId === activeSessionId;
const name = (session.name || 'unnamed').slice(0, 15);
// Status indicator: filled circle for alive, hollow for dead
const statusIcon = session.attached ? '\u25CF' : '\u25CB';
const statusColor = session.attached ? 'green' : 'red';
return (
<Box key={session.sessionId} marginRight={1}>
{isActive ? (
<Text backgroundColor="blue" color="white" bold>
{' '}
<Text color={statusColor}>{statusIcon}</Text> {name}{' '}
</Text>
) : (
<Text>
{' '}
<Text color={statusColor}>{statusIcon}</Text>{' '}
<Text dimColor>{name}</Text>{' '}
</Text>
)}
{index < sessions.length - 1 && <Text dimColor>|</Text>}
</Box>
);
})}
{/* Shortcut hint */}
<Box flexGrow={1} justifyContent="flex-end">
<Text dimColor>Ctrl+Tab: switch | Ctrl+W: close</Text>
</Box>
</Box>
);
}
-99
View File
@@ -1,99 +0,0 @@
/**
* @fileoverview TerminalView component
*
* Primary terminal output display for Claude session monitoring.
*
* @description
* Renders the terminal output from a GNU screen session:
* - Viewport shows last N lines based on available height
* - Line splitting is memoized for performance
* - Visual border color indicates session state (green=active, gray=none)
* - Long lines are truncated to prevent wrapping issues
*
* Output is obtained via screen hardcopy polling in useSessionManager.
*/
import React, { useMemo } from 'react';
import { Box, Text } from 'ink';
import type { ScreenSession } from '../../types.js';
interface TerminalViewProps {
output: string;
height: number;
session: ScreenSession | null;
}
/**
* Terminal view component for displaying session output.
*
* @description
* Shows the last N lines of terminal output that fit within the given height.
* When no session is selected, displays a placeholder message.
*
* @param props - Component props
* @param props.output - Raw terminal output string from screen hardcopy
* @param props.height - Available height in terminal rows (includes border)
* @param props.session - The active session or null
* @returns The terminal view element
*/
export function TerminalView({
output,
height,
session,
}: TerminalViewProps): React.ReactElement {
// Split output into lines and get the last N lines that fit
const displayLines = useMemo(() => {
if (!output) return [];
const lines = output.split('\n');
// Reserve 2 lines for border
const visibleLines = height - 2;
if (lines.length <= visibleLines) {
return lines;
}
// Return last N lines
return lines.slice(-visibleLines);
}, [output, height]);
if (!session) {
return (
<Box
flexDirection="column"
borderStyle="single"
borderColor="gray"
height={height}
paddingX={1}
>
<Box flexGrow={1} alignItems="center" justifyContent="center">
<Text dimColor>No session selected</Text>
</Box>
</Box>
);
}
return (
<Box
flexDirection="column"
borderStyle="single"
borderColor="green"
height={height}
paddingX={1}
overflow="hidden"
>
{/* Terminal content */}
<Box flexDirection="column" flexGrow={1}>
{displayLines.length === 0 ? (
<Text dimColor>Waiting for output...</Text>
) : (
displayLines.map((line, index) => (
<Text key={index} wrap="truncate">
{line}
</Text>
))
)}
</Box>
</Box>
);
}
-10
View File
@@ -1,10 +0,0 @@
/**
* @fileoverview Component exports for TUI
*/
export { StartScreen } from './StartScreen.js';
export { TabBar } from './TabBar.js';
export { TerminalView } from './TerminalView.js';
export { StatusBar } from './StatusBar.js';
export { HelpOverlay } from './HelpOverlay.js';
export { RalphPanel } from './RalphPanel.js';
-5
View File
@@ -1,5 +0,0 @@
/**
* @fileoverview Hook exports for TUI
*/
export { useSessionManager } from './useSessionManager.js';
-831
View File
@@ -1,831 +0,0 @@
/**
* @fileoverview useSessionManager hook
*
* Core state management hook for the TUI application.
*
* @description
* This hook provides a complete interface for managing Claude sessions:
* - Session discovery from ~/.claudeman/screens.json
* - Terminal output polling via GNU screen hardcopy
* - Ralph state tracking for Ralph Wiggum loops
* - Respawn status monitoring via the Claudeman API
*
* @example
* ```tsx
* const {
* sessions,
* activeSession,
* terminalOutput,
* selectSession,
* createSession,
* sendInput,
* } = useSessionManager();
* ```
*/
import { useState, useEffect, useCallback, useRef } from 'react';
import { existsSync, readFileSync, watchFile, unwatchFile, unlinkSync } from 'fs';
import { homedir } from 'os';
import { join } from 'path';
import { execSync, exec } from 'child_process';
import { promisify } from 'util';
import type { ScreenSession, RalphTrackerState, RalphTodoItem, RalphSessionState } from '../../types.js';
const execAsync = promisify(exec);
const SCREENS_FILE = join(homedir(), '.claudeman', 'screens.json');
const RALPH_STATE_FILE = join(homedir(), '.claudeman', 'state-inner.json');
const SETTINGS_FILE = join(homedir(), '.claudeman', 'settings.json');
const OUTPUT_POLL_INTERVAL = 300; // Poll terminal output every 300ms (faster refresh)
/**
* 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;
activeSession: ScreenSession | null;
terminalOutput: string;
ralphLoopState: RalphTrackerState | null;
ralphTodos: RalphTodoItem[];
respawnStatus: RespawnStatus | null;
cases: CaseInfo[];
lastUsedCase: string | null;
refreshSessions: () => void;
refreshCases: () => Promise<void>;
selectSession: (sessionId: string) => void;
createSession: (caseName?: string, mode?: 'claude' | 'shell') => Promise<string | null>;
createCase: (name: string) => Promise<boolean>;
killSession: (sessionId: string) => void;
killAllSessions: () => void;
killAllScreensAndClaude: () => void;
nextSession: () => void;
prevSession: () => void;
sendInput: (sessionId: string, input: string) => void;
toggleRespawn: () => Promise<boolean>;
renameSession: (sessionId: string, name: string) => Promise<boolean>;
}
// Cache for screen -ls output to avoid repeated execSync calls
let screenListCache = '';
let screenListCacheTime = 0;
const SCREEN_CACHE_TTL = 100; // ms
/**
* Checks if a GNU screen session is currently running.
* Uses a 100ms cache to avoid repeated execSync calls when checking multiple sessions.
*
* @param screenName - The name of the screen session to check
* @returns true if the session exists and is alive, false otherwise
*/
function isScreenAlive(screenName: string): boolean {
const now = Date.now();
// Use cached result if fresh enough
if (now - screenListCacheTime > SCREEN_CACHE_TTL) {
try {
screenListCache = execSync('screen -ls', {
encoding: 'utf-8',
timeout: 5000, // 5 second timeout to prevent hang
});
screenListCacheTime = now;
} catch {
screenListCache = '';
screenListCacheTime = now;
return false;
}
}
return screenListCache.includes(screenName);
}
/**
* Loads all sessions from the Claudeman screens registry.
*
* @description
* Reads ~/.claudeman/screens.json and enriches each session with
* its current alive/dead status by checking GNU screen.
* Dead sessions are filtered out - only alive sessions are returned.
*
* @returns Array of alive screen sessions
*/
function loadSessions(): ScreenSession[] {
try {
if (!existsSync(SCREENS_FILE)) {
return [];
}
const data = readFileSync(SCREENS_FILE, 'utf-8');
const sessions: ScreenSession[] = JSON.parse(data);
// Check which sessions are alive and filter out dead ones
return sessions
.map((session) => ({
...session,
attached: isScreenAlive(session.screenName),
}))
.filter((session) => session.attached);
} catch {
return [];
}
}
/**
* Loads Ralph Wiggum loop state for a specific session.
*
* @description
* Reads ~/.claudeman/state-inner.json which contains per-session
* tracking of Ralph loops, todos, and completion phrases.
*
* @param sessionId - The UUID of the session to load state for
* @returns The Ralph session state or null if not found
*/
function loadRalphState(sessionId: string): RalphSessionState | null {
try {
if (!existsSync(RALPH_STATE_FILE)) {
return null;
}
const data = readFileSync(RALPH_STATE_FILE, 'utf-8');
const allStates = JSON.parse(data) as Record<string, RalphSessionState>;
return allStates[sessionId] || null;
} catch {
return null;
}
}
/**
* Loads the last used case name from settings.
*
* @description
* Reads ~/.claudeman/settings.json and returns the lastUsedCase field.
* This is used to default the case selection in the TUI to match the web GUI.
*
* @returns The last used case name or null if not found
*/
function loadLastUsedCase(): string | null {
try {
if (!existsSync(SETTINGS_FILE)) {
return null;
}
const data = readFileSync(SETTINGS_FILE, 'utf-8');
const settings = JSON.parse(data) as { lastUsedCase?: string };
return settings.lastUsedCase || null;
} catch {
return null;
}
}
/**
* React hook for managing Claude sessions in the TUI.
*
* @description
* Provides complete session lifecycle management:
* - Automatic session discovery and status monitoring
* - Terminal output polling (500ms interval)
* - Ralph state tracking (500ms interval)
* - Respawn status polling via API (2000ms interval)
* - Session CRUD operations via Claudeman API
*
* @returns SessionManagerState object with session data and control methods
*
* @example
* ```tsx
* function MyComponent() {
* const { sessions, selectSession, sendInput } = useSessionManager();
*
* return (
* <Box>
* {sessions.map(s => (
* <Text key={s.sessionId} onClick={() => selectSession(s.sessionId)}>
* {s.name}
* </Text>
* ))}
* </Box>
* );
* }
* ```
*/
export function useSessionManager(): SessionManagerState {
const [sessions, setSessions] = useState<ScreenSession[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [terminalOutput, setTerminalOutput] = useState<string>('');
const [ralphLoopState, setRalphTrackerState] = useState<RalphTrackerState | null>(null);
const [ralphTodos, setInnerTodos] = useState<RalphTodoItem[]>([]);
const [respawnStatus, setRespawnStatus] = useState<RespawnStatus | null>(null);
const [cases, setCases] = useState<CaseInfo[]>([]);
const [lastUsedCase, setLastUsedCase] = useState<string | null>(loadLastUsedCase);
const outputBufferRef = useRef<string>('');
// Load sessions on mount and watch for changes
useEffect(() => {
// Initial load
setSessions(loadSessions());
// Watch for changes
const handleChange = () => {
setSessions(loadSessions());
};
try {
watchFile(SCREENS_FILE, { interval: 1000 }, handleChange);
} catch {
// File may not exist yet
}
return () => {
try {
unwatchFile(SCREENS_FILE, handleChange);
} catch {
// Ignore
}
};
}, []);
// Get active session
const activeSession = sessions.find((s) => s.sessionId === activeSessionId) || null;
// Poll Ralph state for active session
useEffect(() => {
if (!activeSessionId) {
setRalphTrackerState(null);
setInnerTodos([]);
return;
}
const pollRalphState = () => {
const state = loadRalphState(activeSessionId);
if (state) {
setRalphTrackerState(state.loop);
setInnerTodos(state.todos);
} else {
setRalphTrackerState(null);
setInnerTodos([]);
}
};
// Initial poll
pollRalphState();
// Set up polling interval (same as output polling)
const intervalId = setInterval(pollRalphState, OUTPUT_POLL_INTERVAL);
return () => {
clearInterval(intervalId);
};
}, [activeSessionId]);
// Poll respawn status via API (when server is running)
useEffect(() => {
if (!activeSessionId) {
setRespawnStatus(null);
return;
}
const pollRespawnStatus = async () => {
try {
const response = await fetch(`http://localhost:3000/api/sessions/${activeSessionId}`);
if (response.ok) {
const data = await response.json() as {
success?: boolean;
session?: {
respawnEnabled?: boolean;
respawnState?: string;
respawnCycleCount?: number;
};
};
if (data.success && data.session) {
setRespawnStatus({
enabled: data.session.respawnEnabled || false,
state: data.session.respawnState || 'stopped',
cycleCount: data.session.respawnCycleCount || 0,
});
}
}
} catch {
// Server not running, that's ok
setRespawnStatus(null);
}
};
// Poll less frequently than output (every 2 seconds)
pollRespawnStatus();
const intervalId = setInterval(pollRespawnStatus, 2000);
return () => {
clearInterval(intervalId);
};
}, [activeSessionId]);
// Poll terminal output for active session (async for non-blocking)
useEffect(() => {
if (!activeSessionId || !activeSession) return;
let isMounted = true;
let pollTimeoutId: NodeJS.Timeout | null = null;
const pollOutput = async () => {
if (!isMounted || !isScreenAlive(activeSession.screenName)) return;
try {
const hardcopyFile = `/tmp/claudeman-${activeSessionId}-hardcopy`;
// Use screen with UTF-8 mode (-U) for proper character handling (async)
await execAsync(`screen -U -S ${activeSession.screenName} -X hardcopy ${hardcopyFile}`, {
timeout: 1000,
env: {
...process.env,
LANG: process.env.LANG || 'en_US.UTF-8',
LC_ALL: process.env.LC_ALL || 'en_US.UTF-8',
}
});
if (isMounted && existsSync(hardcopyFile)) {
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;
setTerminalOutput(content);
}
// Clean up temp file
try {
unlinkSync(hardcopyFile);
} catch {
// Ignore cleanup errors
}
}
} catch {
// Hardcopy may fail, that's ok
}
// Schedule next poll (using timeout instead of interval for async)
if (isMounted) {
pollTimeoutId = setTimeout(pollOutput, OUTPUT_POLL_INTERVAL);
}
};
// Initial poll
pollOutput();
return () => {
isMounted = false;
if (pollTimeoutId) {
clearTimeout(pollTimeoutId);
}
};
}, [activeSessionId, activeSession]);
// Refresh sessions manually
const refreshSessions = useCallback(() => {
setSessions(loadSessions());
}, []);
// Refresh cases from API and reload lastUsedCase from settings
const refreshCases = useCallback(async () => {
// Always reload lastUsedCase from settings
setLastUsedCase(loadLastUsedCase());
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);
setTerminalOutput('');
outputBufferRef.current = '';
}, []);
// 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 with proper naming (w1-casename, w2-casename, etc.)
const createSession = useCallback(async (caseName?: string, mode: 'claude' | 'shell' = 'claude'): Promise<string | null> => {
const actualCaseName = caseName || 'testcase';
try {
// Get or create the case
let caseRes = await fetch(`http://localhost:3000/api/cases/${actualCaseName}`);
let caseData = await caseRes.json() as { path?: string };
// Create case if it doesn't exist
if (!caseData.path) {
const createCaseRes = await fetch('http://localhost:3000/api/cases', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: actualCaseName, description: '' }),
});
const createResult = await createCaseRes.json() as { success?: boolean; case?: { path: string } };
if (!createResult.success || !createResult.case) {
throw new Error('Failed to create case');
}
caseData = createResult.case;
}
const workingDir = caseData.path;
if (!workingDir) throw new Error('Case path not found');
// Find highest existing w-number across all sessions
let startNumber = 1;
for (const session of sessions) {
const match = session.name?.match(/^w(\d+)-/);
if (match) {
const num = parseInt(match[1], 10);
if (num >= startNumber) {
startNumber = num + 1;
}
}
}
// Generate session name matching web UI convention
const sessionName = `w${startNumber}-${actualCaseName}`;
// Create session with custom name
const createRes = await fetch('http://localhost:3000/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir, name: sessionName, mode }),
});
const createData = await createRes.json() as { success?: boolean; session?: { id: string } };
if (!createData.success || !createData.session) {
throw new Error('Failed to create session');
}
const sessionId = createData.session.id;
// Start in the appropriate mode
if (mode === 'shell') {
await fetch(`http://localhost:3000/api/sessions/${sessionId}/shell`, { method: 'POST' });
} else {
await fetch(`http://localhost:3000/api/sessions/${sessionId}/interactive`, { method: 'POST' });
}
// Refresh sessions
setTimeout(() => {
refreshSessions();
refreshCases();
}, 500);
return sessionId;
} catch (err) {
setTerminalOutput(
`Failed to create session: ${err instanceof Error ? err.message : 'Unknown error'}\n` +
'Make sure the web server is running: claudeman web'
);
return null;
}
}, [sessions, refreshSessions, refreshCases]);
// Kill a session
const killSession = useCallback((sessionId: string) => {
const session = sessions.find((s) => s.sessionId === sessionId);
if (!session) return;
try {
// Kill via screen with timeout to prevent hang
execSync(`screen -S ${session.screenName} -X quit`, {
encoding: 'utf-8',
timeout: 5000,
});
} catch {
// May already be dead or timeout
}
// If this was the active session, select another
if (activeSessionId === sessionId) {
const remaining = sessions.filter((s) => s.sessionId !== sessionId);
if (remaining.length > 0) {
setActiveSessionId(remaining[0].sessionId);
} else {
setActiveSessionId(null);
}
}
// Refresh to update the list
setTimeout(refreshSessions, 500);
}, [sessions, activeSessionId, refreshSessions]);
// Kill all sessions
const killAllSessions = useCallback(() => {
for (const session of sessions) {
try {
execSync(`screen -S ${session.screenName} -X quit`, {
encoding: 'utf-8',
timeout: 5000,
});
} catch {
// Ignore - may already be dead or timeout
}
}
setActiveSessionId(null);
setTimeout(refreshSessions, 500);
}, [sessions, refreshSessions]);
// Kill ALL claudeman screens and Claude processes (nuclear option)
const killAllScreensAndClaude = useCallback(() => {
// 1. Kill all tracked sessions first
for (const session of sessions) {
try {
execSync(`screen -S ${session.screenName} -X quit`, {
encoding: 'utf-8',
timeout: 5000,
});
} catch {
// Ignore
}
}
// 2. Kill any remaining claudeman screen sessions
try {
execSync('pkill -f "SCREEN.*claudeman"', {
encoding: 'utf-8',
timeout: 5000,
});
} catch {
// May not find any, that's ok
}
// 3. Kill all Claude CLI processes
try {
execSync('pkill -f "claude"', {
encoding: 'utf-8',
timeout: 5000,
});
} catch {
// May not find any, that's ok
}
setActiveSessionId(null);
setTimeout(refreshSessions, 500);
}, [sessions, refreshSessions]);
// Navigate to next session
const nextSession = useCallback(() => {
if (sessions.length === 0) return;
const currentIndex = sessions.findIndex((s) => s.sessionId === activeSessionId);
const nextIndex = (currentIndex + 1) % sessions.length;
selectSession(sessions[nextIndex].sessionId);
}, [sessions, activeSessionId, selectSession]);
// Navigate to previous session
const prevSession = useCallback(() => {
if (sessions.length === 0) return;
const currentIndex = sessions.findIndex((s) => s.sessionId === activeSessionId);
const prevIndex = currentIndex <= 0 ? sessions.length - 1 : currentIndex - 1;
selectSession(sessions[prevIndex].sessionId);
}, [sessions, activeSessionId, selectSession]);
// Send input to a session directly via screen command (simple, reliable)
const sendInput = useCallback((sessionId: string, input: string) => {
const session = sessions.find((s) => s.sessionId === sessionId);
if (!session || !isScreenAlive(session.screenName)) return;
const screenName = session.screenName;
try {
if (input === '\r') {
// Send Enter key
execSync(`screen -S ${screenName} -p 0 -X stuff $'\\015'`, {
encoding: 'utf-8',
timeout: 1000,
});
} else if (input === '\t') {
// Send Tab key
execSync(`screen -S ${screenName} -p 0 -X stuff $'\\011'`, {
encoding: 'utf-8',
timeout: 1000,
});
} else if (input === '\x7f') {
// Send Backspace
execSync(`screen -S ${screenName} -p 0 -X stuff $'\\177'`, {
encoding: 'utf-8',
timeout: 1000,
});
} else if (input.startsWith('\x1b')) {
// Send escape sequences (arrows, etc) - use $'...' syntax
const escaped = input.replace(/'/g, "'\\''");
execSync(`screen -S ${screenName} -p 0 -X stuff $'${escaped.replace(/\x1b/g, '\\033')}'`, {
encoding: 'utf-8',
timeout: 1000,
});
} else {
// Regular character - escape single quotes
const escaped = input.replace(/'/g, "'\\''");
execSync(`screen -S ${screenName} -p 0 -X stuff '${escaped}'`, {
encoding: 'utf-8',
timeout: 1000,
});
}
} catch {
// Input may fail if screen is not ready - ignore
}
}, [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,
activeSession,
terminalOutput,
ralphLoopState,
ralphTodos,
respawnStatus,
cases,
lastUsedCase,
refreshSessions,
refreshCases,
selectSession,
createSession,
createCase,
killSession,
killAllSessions,
killAllScreensAndClaude,
nextSession,
prevSession,
sendInput,
toggleRespawn,
renameSession,
};
}
-299
View File
@@ -1,299 +0,0 @@
/**
* @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, spawnSync } from 'child_process';
import { createInterface } from 'readline';
import { App, getPendingAttach, clearPendingAttach } from './App.js';
import { attachWithTabs, getFullScreenId } from './DirectAttach.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');
// Main TUI loop - allows unmounting for screen attachment and re-rendering
let shouldContinue = true;
while (shouldContinue) {
// Clear any pending attach from previous iteration
clearPendingAttach();
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);
}
// Check if we exited to attach to a screen session
const pendingAttach = getPendingAttach();
if (pendingAttach) {
// Unmount Ink properly
unmount();
// Restore terminal state after Ink - this is critical for screen to work
// Ink leaves stdin in raw mode with event listeners, we need to clean up
if (process.stdin.isTTY) {
process.stdin.setRawMode(false);
}
process.stdin.removeAllListeners();
// Pause stdin for clean state - readKeySync uses fs.readSync which works in paused mode
process.stdin.pause();
// Clear terminal and show cursor
process.stdout.write('\x1b[?25h'); // Show cursor
process.stdout.write('\x1b[2J\x1b[H'); // Clear screen
// Handle the attachment
if (pendingAttach.mode === 'tabs') {
// Attach with tab switching between sessions
await new Promise<void>((resolve) => {
attachWithTabs(pendingAttach.sessions, pendingAttach.index, () => {
resolve();
});
});
} else {
// Direct attach to single screen
const session = pendingAttach.session!;
// Get full screen ID (PID.screenName) for unambiguous attachment
const fullScreenId = getFullScreenId(session.screenName);
if (fullScreenId) {
console.log(`Attaching to: ${session.screenName}`);
console.log('Detach with Ctrl+A D to return to TUI\n');
spawnSync('screen', ['-x', '-A', fullScreenId], {
stdio: 'inherit',
env: {
...process.env,
TERM: process.env.TERM || 'xterm-256color',
},
});
} else {
console.log(`Screen session not found: ${session.screenName}`);
console.log('Press any key to continue...');
await new Promise<void>((resolve) => {
process.stdin.once('data', () => resolve());
});
}
}
// Clear terminal before returning to TUI
process.stdout.write('\x1b[2J\x1b[H');
// Continue the loop to re-render Ink
shouldContinue = true;
} else {
// Normal exit - user quit the TUI
shouldContinue = false;
// Final cleanup
unmount();
process.stdout.write('\x1b[?25h'); // Show cursor
process.stdout.write('\x1b[2J\x1b[H'); // Clear screen
}
}
}
+225 -115
View File
@@ -431,6 +431,8 @@ class NotificationManager {
class ClaudemanApp {
constructor() {
this.sessions = new Map();
this.sessionOrder = []; // Track tab order for drag-and-drop reordering
this.draggedTabId = null; // Currently dragged tab session ID
this.cases = [];
this.currentRun = null;
this.totalTokens = 0;
@@ -842,14 +844,17 @@ class ClaudemanApp {
}
showWelcome() {
this.terminal.writeln('\x1b[1;36m Claudeman Terminal\x1b[0m');
this.terminal.writeln('');
this.terminal.writeln('\x1b[90m Each instance opens a persistent GNU Screen session running Claude or Shell.\x1b[0m');
this.terminal.writeln('\x1b[90m Sessions stay alive for autonomous work, even if you close this browser.\x1b[0m');
this.terminal.writeln('\x1b[90m Use the +/- controls to set how many instances to launch at once.\x1b[0m');
this.terminal.writeln('');
this.terminal.writeln('\x1b[90m Press \x1b[1;37mCtrl+Enter\x1b[0m\x1b[90m to start Claude\x1b[0m');
this.terminal.writeln('');
const overlay = document.getElementById('welcomeOverlay');
if (overlay) {
overlay.classList.add('visible');
}
}
hideWelcome() {
const overlay = document.getElementById('welcomeOverlay');
if (overlay) {
overlay.classList.remove('visible');
}
}
/**
@@ -1113,6 +1118,11 @@ class ClaudemanApp {
addListener('session:created', (e) => {
const data = JSON.parse(e.data);
this.sessions.set(data.id, data);
// Add new session to end of tab order
if (!this.sessionOrder.includes(data.id)) {
this.sessionOrder.push(data.id);
this.saveSessionOrder();
}
this.renderSessionTabs();
this.updateCost();
});
@@ -1120,6 +1130,8 @@ class ClaudemanApp {
addListener('session:updated', (e) => {
const data = JSON.parse(e.data);
const session = data.session || data;
const oldSession = this.sessions.get(session.id);
const claudeSessionIdJustSet = session.claudeSessionId && (!oldSession || !oldSession.claudeSessionId);
this.sessions.set(session.id, session);
this.renderSessionTabs();
this.updateCost();
@@ -1130,11 +1142,22 @@ class ClaudemanApp {
// Update parentSessionName for any subagents belonging to this session
// (fixes stale name display after session rename)
this.updateSubagentParentNames(session.id);
// If claudeSessionId was just set, re-check orphan subagents
// This connects subagents that were waiting for the session to identify itself
if (claudeSessionIdJustSet) {
this.recheckOrphanSubagents();
}
});
addListener('session:deleted', (e) => {
const data = JSON.parse(e.data);
this.sessions.delete(data.id);
// Remove from tab order
const orderIndex = this.sessionOrder.indexOf(data.id);
if (orderIndex !== -1) {
this.sessionOrder.splice(orderIndex, 1);
this.saveSessionOrder();
}
this.terminalBuffers.delete(data.id);
this.ralphStates.delete(data.id); // Clean up ralph state for this session
this.projectInsights.delete(data.id); // Clean up project insights for this session
@@ -1790,7 +1813,7 @@ class ClaudemanApp {
// ========== Subagent Events (Claude Code Background Agents) ==========
addListener('subagent:discovered', async (e) => {
addListener('subagent:discovered', (e) => {
const data = JSON.parse(e.data);
// Clear all old data for this agentId (in case of ID reuse)
this.subagents.set(data.agentId, data);
@@ -1802,8 +1825,8 @@ class ClaudemanApp {
}
this.renderSubagentPanel();
// Find which Claudeman session owns this subagent (by working directory)
await this.findParentSessionForSubagent(data.agentId);
// Find which Claudeman session owns this subagent (direct claudeSessionId match only)
this.findParentSessionForSubagent(data.agentId);
// Auto-open window for new active agents
if (data.status === 'active') {
@@ -2065,6 +2088,9 @@ class ClaudemanApp {
}
});
// Sync sessionOrder with current sessions (preserve order, add new, remove stale)
this.syncSessionOrder();
if (data.respawnStatus) {
this.respawnStatus = data.respawnStatus;
} else {
@@ -2111,10 +2137,9 @@ class ClaudemanApp {
this.restoreSubagentWindowStates();
}
// Auto-select first session if any
if (this.sessions.size > 0 && !this.activeSessionId) {
const firstSession = this.sessions.values().next().value;
this.selectSession(firstSession.id);
// Auto-select first session if any (use sessionOrder for user's preferred order)
if (this.sessionOrder.length > 0 && !this.activeSessionId) {
this.selectSession(this.sessionOrder[0]);
}
}
@@ -2225,11 +2250,6 @@ class ClaudemanApp {
this._fullRenderSessionTabs();
}
// Auto-focus: if there's exactly one session and none is active, select it
if (this.sessions.size === 1 && !this.activeSessionId) {
const [sessionId] = this.sessions.keys();
this.selectSession(sessionId);
}
}
_fullRenderSessionTabs() {
@@ -2240,8 +2260,12 @@ class ClaudemanApp {
this.cancelHideSubagentDropdown();
// Build tabs HTML using array for better string concatenation performance
// Iterate in sessionOrder to respect user's custom tab arrangement
const parts = [];
for (const [id, session] of this.sessions) {
for (const id of this.sessionOrder) {
const session = this.sessions.get(id);
if (!session) continue; // Skip if session was removed
const isActive = id === this.activeSessionId;
const status = session.status || 'idle';
const name = this.getSessionName(session);
@@ -2268,6 +2292,10 @@ class ClaudemanApp {
}
container.innerHTML = parts.join('');
// Set up drag-and-drop handlers for tab reordering
this.setupTabDragHandlers();
// Update connection lines after tabs change (positions may have shifted)
this.updateConnectionLines();
}
@@ -2307,6 +2335,129 @@ class ClaudemanApp {
`;
}
// ========== Tab Order and Drag-and-Drop ==========
// Sync sessionOrder with current sessions (preserve order for existing, add new at end)
syncSessionOrder() {
const currentIds = new Set(this.sessions.keys());
// Load saved order from localStorage
const savedOrder = this.loadSessionOrder();
// Start with saved order, keeping only sessions that still exist
const preserved = savedOrder.filter(id => currentIds.has(id));
const preservedSet = new Set(preserved);
// Add any new sessions at the end
const newSessions = [...currentIds].filter(id => !preservedSet.has(id));
this.sessionOrder = [...preserved, ...newSessions];
}
// Load session order from localStorage
loadSessionOrder() {
try {
const saved = localStorage.getItem('claudeman-session-order');
return saved ? JSON.parse(saved) : [];
} catch {
return [];
}
}
// Save session order to localStorage
saveSessionOrder() {
try {
localStorage.setItem('claudeman-session-order', JSON.stringify(this.sessionOrder));
} catch {
// Ignore storage errors
}
}
// Set up drag-and-drop handlers on tab elements
setupTabDragHandlers() {
const container = this.$('sessionTabs');
const tabs = container.querySelectorAll('.session-tab[data-id]');
tabs.forEach(tab => {
tab.setAttribute('draggable', 'true');
tab.addEventListener('dragstart', (e) => {
this.draggedTabId = tab.dataset.id;
tab.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', tab.dataset.id);
});
tab.addEventListener('dragend', () => {
tab.classList.remove('dragging');
this.draggedTabId = null;
// Remove all drag-over indicators
container.querySelectorAll('.session-tab').forEach(t => {
t.classList.remove('drag-over-left', 'drag-over-right');
});
});
tab.addEventListener('dragover', (e) => {
e.preventDefault();
if (!this.draggedTabId || this.draggedTabId === tab.dataset.id) return;
e.dataTransfer.dropEffect = 'move';
// Determine drop position based on mouse position
const rect = tab.getBoundingClientRect();
const midpoint = rect.left + rect.width / 2;
const isLeftHalf = e.clientX < midpoint;
// Update visual indicator
tab.classList.toggle('drag-over-left', isLeftHalf);
tab.classList.toggle('drag-over-right', !isLeftHalf);
});
tab.addEventListener('dragleave', () => {
tab.classList.remove('drag-over-left', 'drag-over-right');
});
tab.addEventListener('drop', (e) => {
e.preventDefault();
tab.classList.remove('drag-over-left', 'drag-over-right');
if (!this.draggedTabId || this.draggedTabId === tab.dataset.id) return;
const targetId = tab.dataset.id;
const draggedId = this.draggedTabId;
// Determine insertion position
const rect = tab.getBoundingClientRect();
const midpoint = rect.left + rect.width / 2;
const insertBefore = e.clientX < midpoint;
// Reorder sessionOrder array
const fromIndex = this.sessionOrder.indexOf(draggedId);
let toIndex = this.sessionOrder.indexOf(targetId);
if (fromIndex === -1 || toIndex === -1) return;
// Remove dragged item
this.sessionOrder.splice(fromIndex, 1);
// Recalculate target index after removal
toIndex = this.sessionOrder.indexOf(targetId);
if (toIndex === -1) return;
// Insert at correct position
if (insertBefore) {
this.sessionOrder.splice(toIndex, 0, draggedId);
} else {
this.sessionOrder.splice(toIndex + 1, 0, draggedId);
}
// Save and re-render
this.saveSessionOrder();
this._fullRenderSessionTabs();
});
});
}
// Restore a minimized subagent window
restoreMinimizedSubagent(agentId, sessionId) {
// Remove from minimized set
@@ -2443,6 +2594,7 @@ class ClaudemanApp {
if (this.activeSessionId === sessionId) return;
this.activeSessionId = sessionId;
this.hideWelcome();
// Clear idle hooks on view, but keep action hooks until user interacts
this.clearPendingHooks(sessionId, 'idle_prompt');
this.renderSessionTabs();
@@ -2552,16 +2704,22 @@ class ClaudemanApp {
try {
await fetch(`/api/sessions/${sessionId}?killScreen=${killScreen}`, { method: 'DELETE' });
this.sessions.delete(sessionId);
// Remove from tab order
const orderIndex = this.sessionOrder.indexOf(sessionId);
if (orderIndex !== -1) {
this.sessionOrder.splice(orderIndex, 1);
this.saveSessionOrder();
}
this.terminalBuffers.delete(sessionId);
this.ralphStates.delete(sessionId);
this.clearCountdownTimers(sessionId);
if (this.activeSessionId === sessionId) {
this.activeSessionId = null;
// Select another session or show welcome
if (this.sessions.size > 0) {
const nextSession = this.sessions.values().next().value;
this.selectSession(nextSession.id);
// Select another session or show welcome (use sessionOrder for consistent ordering)
if (this.sessionOrder.length > 0 && this.sessions.size > 0) {
const nextSessionId = this.sessionOrder[0];
this.selectSession(nextSessionId);
} else {
this.terminal.clear();
this.showWelcome();
@@ -2611,12 +2769,11 @@ class ClaudemanApp {
}
nextSession() {
const ids = Array.from(this.sessions.keys());
if (ids.length <= 1) return;
if (this.sessionOrder.length <= 1) return;
const currentIndex = ids.indexOf(this.activeSessionId);
const nextIndex = (currentIndex + 1) % ids.length;
this.selectSession(ids[nextIndex]);
const currentIndex = this.sessionOrder.indexOf(this.activeSessionId);
const nextIndex = (currentIndex + 1) % this.sessionOrder.length;
this.selectSession(this.sessionOrder[nextIndex]);
}
// ========== Navigation ==========
@@ -7442,15 +7599,11 @@ class ClaudemanApp {
// First, discover parent sessions for all subagents to establish correct mapping
// This is important after server restart when parentSessionId isn't set
const parentDiscoveryPromises = [];
for (const [agentId, agent] of this.subagents) {
if (!agent.parentSessionId) {
parentDiscoveryPromises.push(this.findParentSessionForSubagent(agentId));
this.findParentSessionForSubagent(agentId);
}
}
if (parentDiscoveryPromises.length > 0) {
await Promise.all(parentDiscoveryPromises);
}
// Restore minimized state, but verify session mapping is correct
// Skip old agents from previous runs to avoid confusion
@@ -9239,92 +9392,50 @@ class ClaudemanApp {
// ========== Subagent Parent Session Tracking ==========
async findParentSessionForSubagent(agentId) {
/**
* Find and assign the parent Claudeman session for a subagent.
*
* ROCK-SOLID MATCHING: Only uses claudeSessionId.
* - agent.sessionId = Claude session ID that spawned this subagent (from file path)
* - session.claudeSessionId = Claude session ID running in this Claudeman session
*
* If they match, the subagent belongs to that tab. No fallbacks, no heuristics.
* If no match is found, the subagent waits - when a session's claudeSessionId gets
* set (via session:updated), we re-check all orphan subagents.
*/
findParentSessionForSubagent(agentId) {
const agent = this.subagents.get(agentId);
if (!agent) return;
// Strategy 1: Check if another subagent with the same Claude sessionId already has a parent
// This ensures subagents from the same Claude session go to the same Claudeman session
if (agent.sessionId) {
for (const [otherAgentId, otherAgent] of this.subagents) {
if (otherAgentId !== agentId &&
otherAgent.sessionId === agent.sessionId &&
otherAgent.parentSessionId &&
this.sessions.has(otherAgent.parentSessionId)) {
// Found a sibling subagent with an assigned parent
agent.parentSessionId = otherAgent.parentSessionId;
agent.parentSessionName = otherAgent.parentSessionName;
this.subagents.set(agentId, agent);
this.updateSubagentWindowParent(agentId);
this.updateSubagentWindowVisibility();
return;
}
}
}
// Can't match without a sessionId
if (!agent.sessionId) return;
// Strategy 2: Match by claudeSessionId (most reliable)
// The subagent's sessionId is the Claude session that spawned it
// Match this to the Claudeman session's claudeSessionId
if (agent.sessionId) {
for (const [sessionId, session] of this.sessions) {
if (session.claudeSessionId === agent.sessionId) {
agent.parentSessionId = sessionId;
agent.parentSessionName = this.getSessionName(session);
this.subagents.set(agentId, agent);
this.updateSubagentWindowParent(agentId);
this.updateSubagentWindowVisibility();
return;
}
}
}
// Strategy 3: Fall back to workingDir matching (for sessions without claudeSessionId yet)
const matchingSessions = [];
// Direct match: subagent.sessionId === session.claudeSessionId
for (const [sessionId, session] of this.sessions) {
try {
const resp = await fetch(`/api/sessions/${sessionId}/subagents`);
if (!resp.ok) continue;
const result = await resp.json();
const subagents = result.data || result.subagents || [];
if (subagents.some(s => s.agentId === agentId)) {
matchingSessions.push({ sessionId, session });
}
} catch (err) {
// Ignore errors
if (session.claudeSessionId === agent.sessionId) {
agent.parentSessionId = sessionId;
agent.parentSessionName = this.getSessionName(session);
this.subagents.set(agentId, agent);
this.updateSubagentWindowParent(agentId);
this.updateSubagentWindowVisibility();
return;
}
}
if (matchingSessions.length === 0) {
// No matching session found
return;
}
// No match found - the session's claudeSessionId might not be set yet.
// When session:updated fires with claudeSessionId, we'll re-check orphans.
}
// If only one session matches, use it
let chosen = matchingSessions[0];
if (matchingSessions.length > 1) {
// Multiple sessions with same workingDir - prefer session with matching claudeSessionId
// If no match, fall back to oldest session (first created is more likely the original)
const byClaudeSession = matchingSessions.find(m => m.session.claudeSessionId === agent.sessionId);
if (byClaudeSession) {
chosen = byClaudeSession;
} else {
// Fall back to oldest session (not newest - oldest is more likely the original workspace)
matchingSessions.sort((a, b) => {
const aTime = new Date(a.session.createdAt || 0).getTime();
const bTime = new Date(b.session.createdAt || 0).getTime();
return aTime - bTime; // Oldest first
});
chosen = matchingSessions[0];
/**
* Re-check all orphan subagents (those without a parentSessionId) when a session updates.
* Called when session:updated fires, in case claudeSessionId was just set.
*/
recheckOrphanSubagents() {
for (const [agentId, agent] of this.subagents) {
if (!agent.parentSessionId && agent.sessionId) {
this.findParentSessionForSubagent(agentId);
}
}
// Assign the parent
agent.parentSessionId = chosen.sessionId;
agent.parentSessionName = this.getSessionName(chosen.session);
this.subagents.set(agentId, agent);
this.updateSubagentWindowParent(agentId);
this.updateSubagentWindowVisibility();
}
/**
@@ -9821,7 +9932,7 @@ class ClaudemanApp {
this.saveSubagentWindowStates();
}
async closeSubagentWindow(agentId) {
closeSubagentWindow(agentId) {
const windowData = this.subagentWindows.get(agentId);
if (!windowData) return;
@@ -9830,12 +9941,11 @@ class ClaudemanApp {
// Try to discover parent session if not already known
// This ensures we minimize to the correct tab
if (agent && !agent.parentSessionId) {
await this.findParentSessionForSubagent(agentId);
this.findParentSessionForSubagent(agentId);
}
// Now get the correct parent session (re-read agent after discovery)
const updatedAgent = this.subagents.get(agentId);
const parentSessionId = updatedAgent?.parentSessionId || this.activeSessionId;
// Get the parent session (use active session if no parent found)
const parentSessionId = agent?.parentSessionId || this.activeSessionId;
// Always minimize to tab (use active session if no parent found)
windowData.element.style.display = 'none';
+27 -12
View File
@@ -190,6 +190,26 @@
<!-- Main Terminal Area -->
<main class="main">
<div class="terminal-container" id="terminalContainer"></div>
<!-- Welcome Overlay (shown when no session active) -->
<div class="welcome-overlay" id="welcomeOverlay">
<div class="welcome-content">
<h1 class="welcome-title">Claudeman Terminal</h1>
<p class="welcome-desc">Each instance opens a persistent GNU Screen session running Claude or Shell.</p>
<p class="welcome-desc">Sessions stay alive for autonomous work, even if you close this browser.</p>
<div class="welcome-actions">
<button class="welcome-btn welcome-btn-primary" onclick="app.runClaude()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
Run Claude
</button>
<button class="welcome-btn welcome-btn-ralph" onclick="app.showRalphWizard()">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
Start Ralph Loop Wizard
</button>
</div>
<p class="welcome-hint">Or press <kbd>Ctrl</kbd>+<kbd>Enter</kbd> to start Claude</p>
</div>
</div>
</main>
<!-- Project Insights Panel (shows file-viewing Bash commands) -->
@@ -242,24 +262,22 @@
<!-- Run Claude -->
<div class="toolbar-group">
<button class="btn-toolbar btn-claude" onclick="app.runClaude()" title="Run Claude (Ctrl+Enter)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polygon points="5 3 19 12 5 21 5 3"/></svg>
Run Claude
</button>
<div class="tab-count-group" title="Instance count">
<button class="tab-count-btn" onclick="app.decrementTabCount()" title="Instance count">−</button>
<input type="number" id="tabCount" class="tab-count-input" value="1" min="1" max="20" readonly title="Instance count">
<button class="tab-count-btn" onclick="app.incrementTabCount()" title="Instance count">+</button>
<button class="tab-count-btn" onclick="app.decrementTabCount()">−</button>
<input type="number" id="tabCount" class="tab-count-input" value="1" min="1" max="20" readonly>
<button class="tab-count-btn" onclick="app.incrementTabCount()">+</button>
</div>
<button class="btn-toolbar btn-shell" onclick="app.runShell()" title="Run Shell">
Run Shell
</button>
<div class="tab-count-group" title="Instance count">
<button class="tab-count-btn" onclick="app.decrementShellCount()" title="Instance count">−</button>
<input type="number" id="shellCount" class="tab-count-input" value="1" min="1" max="20" readonly title="Instance count">
<button class="tab-count-btn" onclick="app.incrementShellCount()" title="Instance count">+</button>
<button class="tab-count-btn" onclick="app.decrementShellCount()">−</button>
<input type="number" id="shellCount" class="tab-count-input" value="1" min="1" max="20" readonly>
<button class="tab-count-btn" onclick="app.incrementShellCount()">+</button>
</div>
<button class="btn-toolbar btn-ralph" onclick="app.showRalphWizard()" title="Start Ralph Loop">
Ralph Loop
</button>
<div class="case-select-group">
<select id="quickStartCase" class="toolbar-select" title="Select case">
<option value="testcase">testcase</option>
@@ -1295,9 +1313,6 @@
<div class="notif-drawer-empty" id="notifEmpty">No notifications</div>
</div>
<!-- Help Button -->
<button class="help-btn" onclick="app.showHelp()" title="Keyboard shortcuts (Ctrl+?)">?</button>
<!-- Token Stats Modal -->
<div class="modal" id="tokenStatsModal">
<div class="modal-backdrop" onclick="app.closeTokenStats()"></div>
+144 -57
View File
@@ -215,6 +215,24 @@ body {
50% { opacity: 0.4; }
}
/* Tab drag-and-drop reordering */
.session-tab[draggable="true"] {
cursor: grab;
}
.session-tab.dragging {
opacity: 0.5;
cursor: grabbing;
}
.session-tab.drag-over-left {
box-shadow: -2px 0 0 0 var(--accent);
}
.session-tab.drag-over-right {
box-shadow: 2px 0 0 0 var(--accent);
}
/* Header Right */
.header-right {
display: flex;
@@ -1502,6 +1520,7 @@ body {
display: flex;
overflow: hidden;
background: var(--bg-dark);
position: relative;
}
.terminal-container {
@@ -1522,6 +1541,111 @@ body {
background: transparent !important;
}
/* Welcome Overlay */
.welcome-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-dark);
z-index: 10;
opacity: 0;
pointer-events: none;
transition: opacity 0.2s ease;
}
.welcome-overlay.visible {
opacity: 1;
pointer-events: auto;
}
.welcome-content {
text-align: center;
max-width: 500px;
padding: 2rem;
}
.welcome-title {
font-size: 1.75rem;
font-weight: 600;
color: var(--accent-hover);
margin-bottom: 1rem;
letter-spacing: -0.02em;
}
.welcome-desc {
color: var(--text-dim);
font-size: 0.9rem;
line-height: 1.5;
margin-bottom: 0.5rem;
}
.welcome-actions {
display: flex;
gap: 1rem;
justify-content: center;
margin-top: 2rem;
margin-bottom: 1.5rem;
}
.welcome-btn {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
font-size: 0.9rem;
font-weight: 500;
border-radius: 6px;
border: 1px solid transparent;
cursor: pointer;
transition: all 0.15s ease;
}
.welcome-btn-primary {
background: linear-gradient(135deg, #1e3a5f 0%, #2563eb 100%);
border-color: #3b82f6;
color: #e0e7ff;
}
.welcome-btn-primary:hover {
background: linear-gradient(135deg, #2563eb 0%, #3b82f6 100%);
box-shadow: 0 0 12px rgba(59, 130, 246, 0.4);
color: #fff;
}
.welcome-btn-ralph {
background: linear-gradient(135deg, #3d3415 0%, #4a3f1a 100%);
border-color: #6b5a1e;
color: #eab308;
}
.welcome-btn-ralph:hover {
background: linear-gradient(135deg, #4a3f1a 0%, #5a4d20 100%);
border-color: #8b7a2e;
box-shadow: 0 0 12px rgba(234, 179, 8, 0.4);
color: #fcd34d;
}
.welcome-hint {
color: var(--text-muted);
font-size: 0.8rem;
}
.welcome-hint kbd {
display: inline-block;
padding: 0.15rem 0.4rem;
background: var(--bg-input);
border: 1px solid var(--border);
border-radius: 3px;
font-family: inherit;
font-size: 0.75rem;
color: var(--text-dim);
}
/* Bottom Toolbar */
.toolbar {
display: flex;
@@ -1539,7 +1663,7 @@ body {
.toolbar-right {
display: flex;
align-items: center;
gap: 0.25rem;
gap: 0.5rem;
}
.toolbar-center {
@@ -1608,23 +1732,25 @@ body {
background: var(--accent-hover);
}
/* Claude Button - Special styling */
/* Claude Button - Special styling (matches welcome-btn-primary) */
.btn-toolbar.btn-claude {
background: linear-gradient(135deg, #1e3a5f 0%, #3d2a5c 100%);
border-color: #2d4a6f;
color: var(--accent-hover);
font-weight: 700;
background: linear-gradient(135deg, #1e3a5f 0%, #2563eb 100%);
border-color: #3b82f6;
color: #e0e7ff;
font-weight: 500;
padding: 0.4rem 0.85rem;
gap: 0.4rem;
}
.btn-toolbar.btn-claude:hover {
background: linear-gradient(135deg, #2a4d7a 0%, #4d3a6c 100%);
border-color: #3d5a7f;
box-shadow: 0 0 12px rgba(61, 90, 127, 0.3);
background: linear-gradient(135deg, #2563eb 0%, #3b82f6 100%);
border-color: #3b82f6;
box-shadow: 0 0 12px rgba(59, 130, 246, 0.4);
color: #fff;
}
.btn-toolbar.btn-claude .btn-icon {
font-size: 0.9rem;
.btn-toolbar.btn-claude svg {
flex-shrink: 0;
}
/* Shell Button - Special styling */
@@ -1642,21 +1768,6 @@ body {
color: #a8d8a8;
}
/* Ralph Loop button - golden/yellow theme */
.btn-toolbar.btn-ralph {
background: linear-gradient(135deg, #3d3415 0%, #4a3f1a 100%);
border-color: #6b5a1e;
color: #eab308;
font-weight: 500;
}
.btn-toolbar.btn-ralph:hover {
background: linear-gradient(135deg, #4a3f1a 0%, #5a4d20 100%);
border-color: #8b7a2e;
box-shadow: 0 0 8px rgba(234, 179, 8, 0.4);
color: #fcd34d;
}
/* Button icon styling */
.btn-toolbar .btn-icon {
display: inline-flex;
@@ -1779,18 +1890,19 @@ body {
border: 1px solid var(--border);
border-radius: 4px;
overflow: hidden;
margin: 0 0.25rem;
}
.tab-count-btn {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 28px;
width: 22px;
height: 26px;
background: transparent;
border: none;
color: var(--text-dim);
font-size: 0.9rem;
font-size: 0.85rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s;
@@ -1807,14 +1919,14 @@ body {
}
.tab-count-input {
width: 28px;
height: 28px;
width: 24px;
height: 26px;
background: transparent;
border: none;
border-left: 1px solid var(--border);
border-right: 1px solid var(--border);
color: var(--text);
font-size: 0.8rem;
font-size: 0.75rem;
font-weight: 600;
text-align: center;
font-family: 'SF Mono', Monaco, monospace;
@@ -3188,31 +3300,6 @@ kbd {
color: var(--text);
}
/* Help Button */
.help-btn {
position: fixed;
bottom: calc(var(--toolbar-height) + 0.5rem);
right: 0.5rem;
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--bg-card);
border: 1px solid var(--border);
color: var(--text-muted);
font-size: 0.85rem;
cursor: pointer;
z-index: 50;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.help-btn:hover {
background: var(--bg-hover);
color: var(--text);
}
/* Toast Notifications */
.toast-container {
position: fixed;
+12 -3
View File
@@ -4942,19 +4942,28 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
const screens = this.screenManager.getScreens();
for (const screen of screens) {
if (!this.sessions.has(screen.sessionId)) {
// Restore session settings from state.json (single source of truth)
const savedState = this.store.getSession(screen.sessionId);
// Determine the correct session name (priority: savedState > screen > screenName)
// This ensures renamed sessions keep their name after server restart
const sessionName = savedState?.name || screen.name || screen.screenName;
// Create a session object for this screen with the existing screenSession
const session = new Session({
id: screen.sessionId, // Preserve the original session ID
workingDir: screen.workingDir,
mode: screen.mode,
name: screen.name || screen.screenName,
name: sessionName,
screenManager: this.screenManager,
useScreen: true,
screenSession: screen // Pass the existing screen so startInteractive() can attach to it
});
// Restore ALL session settings from state.json (single source of truth)
const savedState = this.store.getSession(screen.sessionId);
// Update screen name if it was a "Restored:" placeholder or doesn't match saved name
if (savedState?.name && screen.name !== savedState.name) {
this.screenManager.updateScreenName(screen.sessionId, savedState.name);
}
if (savedState) {
// Auto-compact
if (savedState.autoCompactEnabled !== undefined || savedState.autoCompactThreshold !== undefined) {
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

+1 -3
View File
@@ -14,8 +14,6 @@
"declaration": true,
"declarationMap": true,
"sourceMap": true,
"jsx": "react-jsx",
"jsxImportSource": "react",
"noUnusedLocals": true,
"noUnusedParameters": true,
"noImplicitReturns": true,
@@ -25,5 +23,5 @@
"allowUnusedLabels": false
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
"exclude": ["node_modules", "dist", "src/tui"]
}