diff --git a/.gitignore b/.gitignore index 808d43c6..2f9d7715 100644 --- a/.gitignore +++ b/.gitignore @@ -37,6 +37,9 @@ Thumbs.db # Temporary files *.tmp *.temp + +# Unfinished TUI (local development only) +src/tui/ .claude/ media-assets/ commands diff --git a/CLAUDE.md b/CLAUDE.md index 65841163..8bc2b0cd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/package.json b/package.json index fb220d81..a2acb2f2 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/cli.ts b/src/cli.ts index df43f83e..e7c3fcf1 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -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 ', '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 }; diff --git a/src/tui/App.tsx b/src/tui/App.tsx deleted file mode 100644 index d7cf313d..00000000 --- a/src/tui/App.tsx +++ /dev/null @@ -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(); - * ``` - */ - -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('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 setShowHelp(false)} />; - } - - // Handle delete session from start screen - const handleDeleteSession = useCallback((session: ScreenSession) => { - killSession(session.sessionId); - }, [killSession]); - - // Render start screen - if (viewMode === 'start') { - return ( - - ); - } - - // 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 ( - - selectSession(id)} - /> - - - - {showRalphPanel && ( - - )} - - - - ); -} diff --git a/src/tui/DirectAttach.ts b/src/tui/DirectAttach.ts deleted file mode 100644 index 401daa88..00000000 --- a/src/tui/DirectAttach.ts +++ /dev/null @@ -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(); -} diff --git a/src/tui/components/HelpOverlay.tsx b/src/tui/components/HelpOverlay.tsx deleted file mode 100644 index 4726af68..00000000 --- a/src/tui/components/HelpOverlay.tsx +++ /dev/null @@ -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 ( - - {/* Header */} - - - Keyboard Shortcuts - - - - {/* Shortcut groups */} - {SHORTCUT_GROUPS.map((group, groupIndex) => ( - - - {group.title} - - - {group.shortcuts.map((shortcut, index) => ( - - {shortcut.key.padEnd(20)} - {shortcut.description} - - ))} - - - ))} - - {/* Footer */} - - Press Escape or q to close - - - ); -} diff --git a/src/tui/components/RalphPanel.tsx b/src/tui/components/RalphPanel.tsx deleted file mode 100644 index c7aac66e..00000000 --- a/src/tui/components/RalphPanel.tsx +++ /dev/null @@ -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 ( - - {/* Header */} - - - Ralph Loop {loopState.active ? '(active)' : '(idle)'} - - {loopState.completionPhrase && ( - - phrase: {loopState.completionPhrase} - - )} - - - {/* Stats row */} - - - cycles: {loopState.cycleCount} - {loopState.maxIterations && /{loopState.maxIterations}} - | - elapsed: {formatElapsed(loopState.elapsedHours)} - | - todos: {completedCount}/{totalCount} ({progressPercent}%) - - - - {/* Todo list (max 5 items) */} - {todos.length > 0 && ( - - {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 ( - - {icon} - - {content} - - - ); - })} - {todos.length > 5 && ( - ... and {todos.length - 5} more - )} - - )} - - ); -} diff --git a/src/tui/components/StartScreen.tsx b/src/tui/components/StartScreen.tsx deleted file mode 100644 index a9d30890..00000000 --- a/src/tui/components/StartScreen.tsx +++ /dev/null @@ -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; - onRefresh: () => void; - onRefreshCases: () => void; - onExit: () => void; -} - -type ScreenMode = 'sessions' | 'cases' | 'new-case' | 'multi-start'; - -/** - * Formats a duration from milliseconds to a compact human-readable string. - * - * @param ms - Duration in milliseconds - * @returns Formatted string like "45s", "5m", "2h 15m", or "3d 5h" - */ -function formatDuration(ms: number): string { - const seconds = Math.floor(ms / 1000); - const minutes = Math.floor(seconds / 60); - const hours = Math.floor(minutes / 60); - const days = Math.floor(hours / 24); - - if (days > 0) { - return `${days}d ${hours % 24}h`; - } - if (hours > 0) { - return `${hours}h ${minutes % 60}m`; - } - if (minutes > 0) { - return `${minutes}m`; - } - return `${seconds}s`; -} - -/** - * Gets display name for a session (matches web interface logic). - * Priority: custom name > directory name (case) > session ID - */ -function getSessionName(session: ScreenSession): string { - if (session.name) { - return session.name; - } - if (session.workingDir) { - return session.workingDir.split('/').pop() || session.workingDir; - } - return session.sessionId.slice(0, 8); -} - -/** - * Start screen component for session discovery and selection. - * - * @description - * Renders two views: - * - Sessions view: List of active sessions - * - Cases view: List of available cases to start a new session - * - * @param props - Component props - * @returns The start screen element - */ -export function StartScreen({ - sessions, - cases, - lastUsedCase, - onSelectSession, - onAttachSession, - onDeleteSession, - onDeleteAll, - onCreateSession, - onCreateCase, - onRefresh, - onRefreshCases, - onExit, -}: StartScreenProps): React.ReactElement { - const now = Date.now(); - const [mode, setMode] = useState('sessions'); - const [selectedIndex, setSelectedIndex] = useState(0); - const [newCaseName, setNewCaseName] = useState(''); - const [multiStartCount, setMultiStartCount] = useState('1'); - const [error, setError] = useState(null); - - // Reset selection when switching modes - default to lastUsedCase for cases view - useEffect(() => { - if (mode === 'cases' && lastUsedCase && cases.length > 0) { - const lastUsedIndex = cases.findIndex(c => c.name === lastUsedCase); - setSelectedIndex(lastUsedIndex >= 0 ? lastUsedIndex : 0); - } else { - setSelectedIndex(0); - } - setError(null); - if (mode !== 'multi-start') { - setMultiStartCount('1'); - } - }, [mode, lastUsedCase, cases]); - - // Ensure selectedIndex is valid when list changes - useEffect(() => { - const maxIndex = mode === 'sessions' ? sessions.length - 1 : cases.length - 1; - if (selectedIndex > maxIndex && maxIndex >= 0) { - setSelectedIndex(maxIndex); - } - }, [sessions.length, cases.length, selectedIndex, mode]); - - // Handle keyboard input for navigation - useInput((input, key) => { - // New case input mode - only handle escape and return - if (mode === 'new-case') { - if (key.escape) { - setMode('cases'); - setNewCaseName(''); - setError(null); - } - return; - } - - // Multi-start input mode - only handle escape and return - if (mode === 'multi-start') { - if (key.escape) { - setMode('cases'); - setMultiStartCount('1'); - setError(null); - } - return; - } - - // Arrow key navigation - const listLength = mode === 'sessions' ? sessions.length : cases.length; - if (key.upArrow && listLength > 0) { - setSelectedIndex((prev) => (prev > 0 ? prev - 1 : listLength - 1)); - return; - } - if (key.downArrow && listLength > 0) { - setSelectedIndex((prev) => (prev < listLength - 1 ? prev + 1 : 0)); - return; - } - - // Enter to select - if (key.return && listLength > 0) { - if (mode === 'sessions') { - onSelectSession(sessions[selectedIndex]); - } else if (mode === 'cases') { - // Start session with selected case - onCreateSession(cases[selectedIndex].name); - } - return; - } - - // Mode-specific shortcuts - if (mode === 'sessions') { - // 'a' to attach directly to screen - if (input === 'a' && sessions.length > 0 && sessions[selectedIndex].attached) { - onAttachSession(sessions[selectedIndex]); - return; - } - - // 'd' or 'x' to delete/kill session - if ((input === 'd' || input === 'x') && sessions.length > 0) { - onDeleteSession(sessions[selectedIndex]); - return; - } - - // 'D' (shift+d) to delete ALL screens and Claude processes - if (input === 'D') { - onDeleteAll(); - return; - } - - // 'c' to switch to cases view - if (input === 'c') { - onRefreshCases(); - setMode('cases'); - return; - } - - // 'n' to create new session (quick start with auto name) - if (input === 'n') { - onCreateSession(); - return; - } - } else if (mode === 'cases') { - // 's' to switch back to sessions view - if (input === 's') { - setMode('sessions'); - return; - } - - // 'n' to create new case - if (input === 'n') { - setMode('new-case'); - setNewCaseName(''); - return; - } - - // 'm' to start multiple sessions with selected case - if (input === 'm' && cases.length > 0) { - setMode('multi-start'); - setMultiStartCount('1'); - setError(null); - return; - } - - // 'h' to start a shell session with selected case - if (input === 'h' && cases.length > 0) { - onCreateSession(cases[selectedIndex].name, 1, 'shell'); - return; - } - } - - // 'r' to refresh - if (input === 'r') { - if (mode === 'sessions') { - onRefresh(); - } else { - onRefreshCases(); - } - return; - } - - // 'q' to quit - if (input === 'q') { - onExit(); - return; - } - }); - - // Handle new case name submission - const handleNewCaseSubmit = async (value: string) => { - const trimmed = value.trim(); - if (!trimmed) { - setError('Case name cannot be empty'); - return; - } - if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) { - setError('Use only letters, numbers, hyphens, underscores'); - return; - } - - const success = await onCreateCase(trimmed); - if (success) { - setMode('cases'); - setNewCaseName(''); - setError(null); - } else { - setError('Failed to create case (may already exist)'); - } - }; - - // Handle multi-start submission - const handleMultiStartSubmit = (value: string) => { - const count = parseInt(value.trim(), 10); - if (isNaN(count) || count < 1) { - setError('Enter a number from 1 to 20'); - return; - } - if (count > 20) { - setError('Maximum 20 sessions at once'); - return; - } - - // Get the selected case name - const selectedCase = cases[selectedIndex]; - if (!selectedCase) { - setError('No case selected'); - return; - } - - // Start the sessions - onCreateSession(selectedCase.name, count); - setMode('cases'); - setMultiStartCount('1'); - setError(null); - }; - - // Get currently selected item for display - const getSelectedItem = (): { type: 'session' | 'case'; name: string; path?: string } | null => { - if (mode === 'sessions' && sessions.length > 0 && selectedIndex < sessions.length) { - const session = sessions[selectedIndex]; - return { type: 'session', name: session.name || 'unnamed', path: session.workingDir }; - } - if ((mode === 'cases' || mode === 'multi-start') && cases.length > 0 && selectedIndex < cases.length) { - const caseInfo = cases[selectedIndex]; - return { type: 'case', name: caseInfo.name, path: caseInfo.path }; - } - return null; - }; - - const selectedItem = getSelectedItem(); - - // Render new case input mode - if (mode === 'new-case') { - return ( - - - - Create New Case - - - - - Enter case name (letters, numbers, hyphens, underscores): - - > - - - {error && ( - - {error} - - )} - - - - - [Enter] - Create - [Esc] - Cancel - - - - ); - } - - // Render multi-start input mode - if (mode === 'multi-start') { - const selectedCase = cases[selectedIndex]; - return ( - - - - Start Multiple Sessions - - - - {/* Show selected case */} - - - Selected Case: - {selectedCase?.name || 'none'} - - - - - How many sessions to start? (1-20): - - > - - - {error && ( - - {error} - - )} - - - - - [Enter] - Start Sessions - [Esc] - Cancel - - - - ); - } - - // Get the default case to display (lastUsedCase or first case or 'testcase') - const defaultCase = lastUsedCase || (cases.length > 0 ? cases[0].name : 'testcase'); - const defaultCasePath = cases.find(c => c.name === defaultCase)?.path; - - return ( - - {/* Header with current case */} - - - Claudeman TUI - - - Case: - {defaultCase} - {defaultCasePath && ( - ({defaultCasePath.replace(process.env.HOME || '', '~')}) - )} - - - - {/* Mode tabs */} - - - - {' [s] Sessions '} - - - - {' [c] Cases '} - - | Press ? for help - - - - {/* Selected item display */} - {selectedItem && ( - - - Selected: - {selectedItem.name} - {selectedItem.path && ( - ({selectedItem.path.replace(process.env.HOME || '', '~')}) - )} - - - )} - - {/* Sessions view */} - {mode === 'sessions' && ( - <> - {sessions.length === 0 ? ( - - No sessions found - Press [n] to create a new session, or [c] to select a case - - ) : ( - - {/* Table header */} - - - {' '} - {'NAME'.padEnd(22)} - {'RUNTIME'.padEnd(12)} - {'STATUS'.padEnd(10)} - {'MODE'.padEnd(10)} - - - - {/* Session rows */} - {sessions.map((session, index) => { - const runtime = formatDuration(now - session.createdAt); - const statusColor = session.attached ? 'green' : 'red'; - const statusIcon = session.attached ? '\u25CF' : '\u25CB'; - const statusText = session.attached ? 'alive' : 'dead'; - const name = getSessionName(session).slice(0, 20); - const isSelected = index === selectedIndex; - - return ( - - {isSelected ? ( - - {' \u25B6 '} - {name.padEnd(22)} - {runtime.padEnd(12)} - - {statusIcon} {statusText.padEnd(8)} - - {session.mode.padEnd(10)} - - ) : ( - - {' '} - {name.padEnd(22)} - {runtime.padEnd(12)} - - {statusIcon} {statusText.padEnd(8)} - - {session.mode.padEnd(10)} - - )} - - ); - })} - - )} - - {/* Sessions footer */} - - - - [n] - New - [c] - Cases - [{'\u2191\u2193'}] - Navigate - [Enter] - View - [a] - Attach - [d] - Delete - [D] - Delete ALL - [q] - Quit - - - - - )} - - {/* Cases view */} - {mode === 'cases' && ( - <> - {cases.length === 0 ? ( - - No cases found - Press [n] to create a new case - - ) : ( - - {/* Table header */} - - - {' '} - {'CASE NAME'.padEnd(30)} - {'PATH'.padEnd(40)} - - - - {/* Case rows */} - {cases.map((caseInfo, index) => { - const isSelected = index === selectedIndex; - const name = caseInfo.name.slice(0, 28); - const path = caseInfo.path.replace(process.env.HOME || '', '~').slice(0, 38); - - return ( - - {isSelected ? ( - - {' \u25B6 '} - {name.padEnd(30)} - {path.padEnd(40)} - - ) : ( - - {' '} - {name.padEnd(30)} - {path.padEnd(40)} - - )} - - ); - })} - - )} - - {/* Cases footer */} - - - - [Enter] - Claude - [h] - Shell - [m] - Multi (1-20) - [n] - New Case - [s] - Sessions - [r] - Refresh - [q] - Quit - - - - - )} - - ); -} diff --git a/src/tui/components/StatusBar.tsx b/src/tui/components/StatusBar.tsx deleted file mode 100644 index dd604665..00000000 --- a/src/tui/components/StatusBar.tsx +++ /dev/null @@ -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 ( - - No session selected - Press ? for help | Esc to go back - - ); - } - - 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 ( - - {/* Left side: Session info */} - - - {sessionName} - - | - - {'\u25CF'} {statusText} - - | - - runtime: {runtime} - - | - - mode: {session.mode} - - {inputMode && ( - <> - | - INPUT MODE - - )} - {respawnStatus?.enabled && ( - <> - | - - respawn: {respawnStatus.state.replace(/_/g, ' ')} - {respawnStatus.cycleCount > 0 && ` (${respawnStatus.cycleCount})`} - - - )} - - - {/* Right side: Keyboard hints */} - - ? help | Esc back | type to send input - - - ); -} diff --git a/src/tui/components/TabBar.tsx b/src/tui/components/TabBar.tsx deleted file mode 100644 index f873376b..00000000 --- a/src/tui/components/TabBar.tsx +++ /dev/null @@ -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 ( - - No sessions - - ); - } - - return ( - - {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 ( - - {isActive ? ( - - {' '} - {statusIcon} {name}{' '} - - ) : ( - - {' '} - {statusIcon}{' '} - {name}{' '} - - )} - {index < sessions.length - 1 && |} - - ); - })} - - {/* Shortcut hint */} - - Ctrl+Tab: switch | Ctrl+W: close - - - ); -} diff --git a/src/tui/components/TerminalView.tsx b/src/tui/components/TerminalView.tsx deleted file mode 100644 index 99245b55..00000000 --- a/src/tui/components/TerminalView.tsx +++ /dev/null @@ -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 ( - - - No session selected - - - ); - } - - return ( - - {/* Terminal content */} - - {displayLines.length === 0 ? ( - Waiting for output... - ) : ( - displayLines.map((line, index) => ( - - {line} - - )) - )} - - - ); -} diff --git a/src/tui/components/index.ts b/src/tui/components/index.ts deleted file mode 100644 index 005f9c84..00000000 --- a/src/tui/components/index.ts +++ /dev/null @@ -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'; diff --git a/src/tui/hooks/index.ts b/src/tui/hooks/index.ts deleted file mode 100644 index ffc6c8a4..00000000 --- a/src/tui/hooks/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** - * @fileoverview Hook exports for TUI - */ - -export { useSessionManager } from './useSessionManager.js'; diff --git a/src/tui/hooks/useSessionManager.ts b/src/tui/hooks/useSessionManager.ts deleted file mode 100644 index 84dd5bdf..00000000 --- a/src/tui/hooks/useSessionManager.ts +++ /dev/null @@ -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, ''], // ↵ return symbol -> - [/\u{23CE}/gu, ''], // ⏎ return symbol -> - - // 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; - selectSession: (sessionId: string) => void; - createSession: (caseName?: string, mode?: 'claude' | 'shell') => Promise; - createCase: (name: string) => Promise; - killSession: (sessionId: string) => void; - killAllSessions: () => void; - killAllScreensAndClaude: () => void; - nextSession: () => void; - prevSession: () => void; - sendInput: (sessionId: string, input: string) => void; - toggleRespawn: () => Promise; - renameSession: (sessionId: string, name: string) => Promise; -} - -// 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; - 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 ( - * - * {sessions.map(s => ( - * selectSession(s.sessionId)}> - * {s.name} - * - * ))} - * - * ); - * } - * ``` - */ -export function useSessionManager(): SessionManagerState { - const [sessions, setSessions] = useState([]); - const [activeSessionId, setActiveSessionId] = useState(null); - const [terminalOutput, setTerminalOutput] = useState(''); - const [ralphLoopState, setRalphTrackerState] = useState(null); - const [ralphTodos, setInnerTodos] = useState([]); - const [respawnStatus, setRespawnStatus] = useState(null); - const [cases, setCases] = useState([]); - const [lastUsedCase, setLastUsedCase] = useState(loadLastUsedCase); - const outputBufferRef = useRef(''); - - // 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 => { - 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 => { - 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 => { - 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 => { - 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, - }; -} diff --git a/src/tui/index.tsx b/src/tui/index.tsx deleted file mode 100644 index ad30b7ef..00000000 --- a/src/tui/index.tsx +++ /dev/null @@ -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 { - 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 { - 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 { - 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(); - - // 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((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((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 - } - } -} diff --git a/src/web/public/app.js b/src/web/public/app.js index 77411da2..a8c9f0b9 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -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'; diff --git a/src/web/public/index.html b/src/web/public/index.html index 9afda529..5d024c78 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -190,6 +190,26 @@
+ + +
+
+

Claudeman Terminal

+

Each instance opens a persistent GNU Screen session running Claude or Shell.

+

Sessions stay alive for autonomous work, even if you close this browser.

+
+ + +
+

Or press Ctrl+Enter to start Claude

+
+
@@ -242,24 +262,22 @@
- - - + + +
- - - + + +
-