diff --git a/CLAUDE.md b/CLAUDE.md index d77708af..729ee465 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -417,7 +417,7 @@ npx agent-browser close | GET | `/api/sessions/:id/inner-state` | Get Ralph loop state + todos | | POST | `/api/sessions/:id/auto-compact` | Configure auto-compact threshold | | POST | `/api/sessions/:id/auto-clear` | Configure auto-clear threshold | -| POST | `/api/quick-start` | Create case + start session (claude or shell mode) | +| POST | `/api/quick-start` | Create case + start session. Body: `{mode?: 'claude'\|'shell'}` | | GET | `/api/cases` | List available cases | | POST | `/api/cases` | Create new case | | GET | `/api/screens` | List screen sessions with stats | @@ -441,9 +441,10 @@ npx agent-browser close | Key | Action | |-----|--------| | `↑`/`↓` | Navigate list | -| `Enter` | View session in TUI | -| `a` | Attach directly to screen (full terminal) | +| `Enter` | Open tab switcher menu, then full-screen attach | +| `a` | Direct attach (skip tab menu, Ctrl+A D returns to TUI) | | `d` | Delete/kill selected session | +| `D` (Shift+d) | Delete ALL screens & Claude processes | | `c` | Switch to cases view | | `n` | Quick-start new session | | `r` | Refresh list | @@ -460,18 +461,17 @@ npx agent-browser close | `s` | Switch to sessions view | | `r` | Refresh list | -**Main View (Session Active):** +**Tab Switcher Menu (between attaches):** | Key | Action | |-----|--------| -| `Tab`/`Shift+Tab` | Next/previous session tab | -| `Ctrl+1-9` | Go to session N | -| `[`/`]` | Previous/next session (vim-style) | -| `Ctrl+N` | New session | -| `Ctrl+W` | Close current session | -| `Ctrl+K` | Kill all sessions | -| `Ctrl+R` | Toggle respawn (Claude sessions only) | -| `Escape` | Back to start screen | -| `?` | Toggle help overlay | +| `1-9` | Select and attach to session N | +| `Enter` | Attach to current session | +| `q` / `Esc` | Return to TUI start screen | + +**While attached to screen:** +| Key | Action | +|-----|--------| +| `Ctrl+A D` | Detach and return to tab switcher | ## State Files @@ -539,26 +539,8 @@ See [`docs/ralph-wiggum-guide.md`](docs/ralph-wiggum-guide.md) for full document ## Optimization Status -Most critical optimizations have been implemented. See `.claude/optimization-todos.md` for details. +Most critical optimizations implemented. Full details with file:line references in `.claude/optimization-todos.md`. -**Completed:** -| Area | Implementation | -|------|----------------| -| Buffer management | `BufferAccumulator` with auto-trimming | -| Pre-compiled regex | Module-level patterns with lastIndex resets | -| Event listener cleanup | `cleanupTrackerListeners()` in session.ts | -| Event debouncing | 50ms debounce in `inner-loop-tracker.ts` | -| State update debouncing | 500ms batching in `server.ts` | -| Regex pre-checks | String.includes() before pattern.test() | -| Frontend render debouncing | 50-100ms for panels, tabs | -| CSS containment | `contain` property on terminal, panels, modals | -| Input batching | 60fps keystroke coalescing | -| Incremental DOM updates | Session tabs and Ralph todos | +**Key optimizations**: Buffer auto-trimming, pre-compiled regex with lastIndex resets, event debouncing (50-500ms), CSS containment, 60fps input batching, incremental DOM updates. -**Remaining:** -| Priority | Area | Files | -|----------|------|-------| -| Low | Task lookup optimization | `task-tracker.ts` | -| Low | Buffer pagination API | `server.ts` | - -Run `cat .claude/optimization-todos.md` for the full list with file:line references. +**Remaining (low priority)**: Task lookup optimization in `task-tracker.ts`, buffer pagination API. diff --git a/README.md b/README.md index 6de3ae59..65d2c97f 100644 --- a/README.md +++ b/README.md @@ -265,43 +265,6 @@ claudeman web [-p 8080] # Start web interface claudeman status # Show overall status ``` -### Terminal User Interface (TUI) - -Full-featured TUI built with Ink (React for CLI): - -```bash -claudeman tui # Launch TUI -npx tsx src/index.ts tui # Dev mode -``` - -**Start Screen:** -| Key | Action | -|-----|--------| -| `↑`/`↓` | Navigate sessions | -| `Enter` | View session in TUI | -| `a` | Attach directly to screen | -| `d` | Delete session | -| `n` | New session | -| `r` | Refresh | -| `q` | Quit | - -**Main View:** -| Key | Action | -|-----|--------| -| `Ctrl+Tab` | Next session | -| `Ctrl+1-9` | Go to session N | -| `Ctrl+W` | Close session | -| `Escape` | Back to start screen | -| `?` | Show help | - -Features: -- Real-time terminal output (polls screen every 500ms) -- Tab-based session navigation -- Direct screen attachment for full interactivity -- Arrow key navigation with visual selection -- **Ralph Loop tracking** with live progress panel -- Respawn status indicator in status bar - ### Screen Manager Script Interactive bash script for direct screen management: @@ -447,7 +410,6 @@ npm install npx tsx src/index.ts web # Dev mode (no build needed) npm run build # Production build npm test # Run test suite -npm run tui # Launch TUI npx tsc --noEmit # Type check ``` diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 281a5088..7cc30409 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -25,7 +25,6 @@ import React, { useState, useEffect, useCallback } from 'react'; import { Box, useApp, useInput, useStdout } from 'ink'; -import { spawnSync } from 'child_process'; import { StartScreen } from './components/StartScreen.js'; import { TabBar } from './components/TabBar.js'; import { TerminalView } from './components/TerminalView.js'; @@ -35,6 +34,44 @@ 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'; /** @@ -83,6 +120,7 @@ export function App(): React.ReactElement { innerTodos, respawnStatus, cases, + lastUsedCase, toggleRespawn, renameSession, } = useSessionManager(); @@ -106,14 +144,14 @@ export function App(): React.ReactElement { useInput((input, key) => { // Help overlay takes priority if (showHelp) { - if (key.escape || input === 'q' || input === '?') { + if (key.escape || input === 'q') { setShowHelp(false); } return; } - // Global shortcuts - if (input === '?' || (key.ctrl && input === 'h')) { + // Global shortcuts - use Ctrl+H for help (? is a normal character) + if (key.ctrl && input === 'h') { setShowHelp(true); return; } @@ -137,17 +175,8 @@ export function App(): React.ReactElement { // === SESSION SWITCHING SHORTCUTS === - // Tab / Shift+Tab to switch sessions (most intuitive) - if (key.tab) { - if (key.shift) { - prevSession(); - } else { - nextSession(); - } - return; - } - - // Ctrl+Tab / Ctrl+Shift+Tab (if terminal supports it) + // 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(); @@ -167,12 +196,13 @@ export function App(): React.ReactElement { return; } - // [ and ] for previous/next session (vim-like) - if (input === '[' && !key.ctrl && !key.meta) { + // 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 (input === ']' && !key.ctrl && !key.meta) { + if (key.ctrl && input === ']') { nextSession(); return; } @@ -238,6 +268,11 @@ export function App(): React.ReactElement { 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'); @@ -272,54 +307,95 @@ export function App(): React.ReactElement { } }); + /** + * 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) => { - selectSession(session.sessionId); - setViewMode('main'); - }, [selectSession]); + 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') => { - // Default to 'default' case if no case name provided (like web UI) const sessionsToCreate = Math.min(Math.max(count || 1, 1), 20); let lastSessionId: string | null = null; - // Create sessions sequentially to avoid overwhelming the server + // 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(); } - // Small delay between session creations to allow server to process 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) { - setViewMode('main'); + // 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]); + }, [createSession, refreshSessions, sessions, exit]); /** - * Attach directly to a screen session - * This exits the TUI and attaches to GNU screen + * 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) => { - // Clear screen and restore terminal - process.stdout.write('\x1b[2J\x1b[H'); - console.log(`Attaching to screen: ${session.screenName}`); - console.log('Press Ctrl+A D to detach and return to terminal\n'); - - // Use spawnSync to attach to screen (inherits stdio) - const result = spawnSync('screen', ['-r', session.screenName], { - stdio: 'inherit', + // Set pending attachment for direct mode and exit Ink + setPendingAttach({ + mode: 'direct', + sessions: [...sessions], + index: sessions.findIndex(s => s.sessionId === session.sessionId), + session: session, }); - - // After detaching, exit the TUI - if (result.status === 0) { - console.log('\nDetached from screen. Run "claudeman tui" to return.'); - } exit(); - }, [exit]); + }, [sessions, exit]); // Render help overlay if shown if (showHelp) { @@ -337,6 +413,7 @@ export function App(): React.ReactElement { { + 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 index e45c6d79..4726af68 100644 --- a/src/tui/components/HelpOverlay.tsx +++ b/src/tui/components/HelpOverlay.tsx @@ -30,8 +30,8 @@ const SHORTCUT_GROUPS: ShortcutGroup[] = [ title: 'Start Screen - Sessions', shortcuts: [ { key: '\u2191/\u2193', description: 'Navigate list' }, - { key: 'Enter', description: 'View session in TUI' }, - { key: 'a', description: 'Attach to screen (full terminal)' }, + { 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' }, @@ -53,27 +53,18 @@ const SHORTCUT_GROUPS: ShortcutGroup[] = [ ], }, { - title: 'Main View - Navigation', + title: 'Tab Switcher Menu', shortcuts: [ - { key: 'Ctrl+Tab', description: 'Next session tab' }, - { key: 'Ctrl+Shift+Tab', description: 'Previous session tab' }, - { key: 'Ctrl+1-9', description: 'Go to session N' }, - { key: 'Escape', description: 'Back to start screen' }, - ], - }, - { - title: 'Main View - Session Management', - shortcuts: [ - { key: 'Ctrl+N', description: 'New session' }, - { key: 'Ctrl+W', description: 'Close current session' }, - { key: 'Ctrl+K', description: 'Kill all sessions' }, - { key: 'Ctrl+R', description: 'Toggle respawn (Claude sessions only)' }, + { 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: '?', description: 'Toggle this help' }, + { key: 'Ctrl+H', description: 'Toggle this help' }, { key: 'Ctrl+C', description: 'Exit TUI' }, ], }, @@ -126,7 +117,7 @@ export function HelpOverlay({ onClose: _onClose }: HelpOverlayProps): React.Reac {/* Footer */} - Press Escape, q, or ? to close + Press Escape or q to close ); diff --git a/src/tui/components/StartScreen.tsx b/src/tui/components/StartScreen.tsx index df922367..a9d30890 100644 --- a/src/tui/components/StartScreen.tsx +++ b/src/tui/components/StartScreen.tsx @@ -27,6 +27,7 @@ interface CaseInfo { interface StartScreenProps { sessions: ScreenSession[]; cases: CaseInfo[]; + lastUsedCase: string | null; onSelectSession: (session: ScreenSession) => void; onAttachSession: (session: ScreenSession) => void; onDeleteSession: (session: ScreenSession) => void; @@ -64,6 +65,20 @@ function formatDuration(ms: number): string { 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. * @@ -78,6 +93,7 @@ function formatDuration(ms: number): string { export function StartScreen({ sessions, cases, + lastUsedCase, onSelectSession, onAttachSession, onDeleteSession, @@ -95,14 +111,19 @@ export function StartScreen({ const [multiStartCount, setMultiStartCount] = useState('1'); const [error, setError] = useState(null); - // Reset selection when switching modes + // Reset selection when switching modes - default to lastUsedCase for cases view useEffect(() => { - setSelectedIndex(0); + 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]); + }, [mode, lastUsedCase, cases]); // Ensure selectedIndex is valid when list changes useEffect(() => { @@ -396,19 +417,30 @@ export function StartScreen({ ); } + // 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 */} + {/* Header with current case */} Claudeman TUI + + Case: + {defaultCase} + {defaultCasePath && ( + ({defaultCasePath.replace(process.env.HOME || '', '~')}) + )} + {/* Mode tabs */} @@ -473,7 +505,7 @@ export function StartScreen({ const statusColor = session.attached ? 'green' : 'red'; const statusIcon = session.attached ? '\u25CF' : '\u25CB'; const statusText = session.attached ? 'alive' : 'dead'; - const name = (session.name || 'unnamed').slice(0, 20); + const name = getSessionName(session).slice(0, 20); const isSelected = index === selectedIndex; return ( diff --git a/src/tui/hooks/useSessionManager.ts b/src/tui/hooks/useSessionManager.ts index 79d85a08..facdcf54 100644 --- a/src/tui/hooks/useSessionManager.ts +++ b/src/tui/hooks/useSessionManager.ts @@ -27,12 +27,17 @@ 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 } from 'child_process'; +import { execSync, exec } from 'child_process'; +import { promisify } from 'util'; import type { ScreenSession, InnerLoopState, InnerTodoItem, InnerSessionState } from '../../types.js'; +const execAsync = promisify(exec); + const SCREENS_FILE = join(homedir(), '.claudeman', 'screens.json'); const INNER_STATE_FILE = join(homedir(), '.claudeman', 'state-inner.json'); -const OUTPUT_POLL_INTERVAL = 500; // Poll terminal output every 500ms +const SETTINGS_FILE = join(homedir(), '.claudeman', 'settings.json'); +const OUTPUT_POLL_INTERVAL = 300; // Poll terminal output every 300ms (faster refresh) +const INPUT_BATCH_INTERVAL = 16; // Batch input every 16ms (60fps) /** * Emoji to ASCII replacement map for screen hardcopy output. @@ -149,6 +154,7 @@ interface SessionManagerState { innerTodos: InnerTodoItem[]; respawnStatus: RespawnStatus | null; cases: CaseInfo[]; + lastUsedCase: string | null; refreshSessions: () => void; refreshCases: () => Promise; selectSession: (sessionId: string) => void; @@ -203,8 +209,9 @@ function isScreenAlive(screenName: string): boolean { * @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 screen sessions with updated attached status + * @returns Array of alive screen sessions */ function loadSessions(): ScreenSession[] { try { @@ -214,11 +221,13 @@ function loadSessions(): ScreenSession[] { const data = readFileSync(SCREENS_FILE, 'utf-8'); const sessions: ScreenSession[] = JSON.parse(data); - // Check which sessions are alive - return sessions.map((session) => ({ - ...session, - attached: isScreenAlive(session.screenName), - })); + // 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 []; } @@ -247,6 +256,28 @@ function loadInnerState(sessionId: string): InnerSessionState | 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. * @@ -285,6 +316,7 @@ export function useSessionManager(): SessionManagerState { const [innerTodos, 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 @@ -387,18 +419,20 @@ export function useSessionManager(): SessionManagerState { }; }, [activeSessionId]); - // Poll terminal output for active session + // Poll terminal output for active session (async for non-blocking) useEffect(() => { if (!activeSessionId || !activeSession) return; - const pollOutput = () => { - if (!isScreenAlive(activeSession.screenName)) 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 - execSync(`screen -U -S ${activeSession.screenName} -X hardcopy ${hardcopyFile}`, { - encoding: 'utf-8', + // 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, @@ -406,7 +440,7 @@ export function useSessionManager(): SessionManagerState { LC_ALL: process.env.LC_ALL || 'en_US.UTF-8', } }); - if (existsSync(hardcopyFile)) { + if (isMounted && existsSync(hardcopyFile)) { const rawContent = readFileSync(hardcopyFile, 'utf-8'); // Sanitize emoji/unicode that screen hardcopy corrupts const content = sanitizeHardcopyOutput(rawContent); @@ -425,16 +459,21 @@ export function useSessionManager(): SessionManagerState { } 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(); - // Set up polling interval - const intervalId = setInterval(pollOutput, OUTPUT_POLL_INTERVAL); - return () => { - clearInterval(intervalId); + isMounted = false; + if (pollTimeoutId) { + clearTimeout(pollTimeoutId); + } }; }, [activeSessionId, activeSession]); @@ -443,8 +482,11 @@ export function useSessionManager(): SessionManagerState { setSessions(loadSessions()); }, []); - // Refresh cases from API + // 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) { @@ -467,7 +509,6 @@ export function useSessionManager(): SessionManagerState { setActiveSessionId(sessionId); setTerminalOutput(''); outputBufferRef.current = ''; - // Polling effect will handle fetching output }, []); // Create a new case @@ -492,45 +533,83 @@ export function useSessionManager(): SessionManagerState { } }, [refreshCases]); - // Create new session (optionally with case name and mode) + // Create new session with proper naming (w1-casename, w2-casename, etc.) const createSession = useCallback(async (caseName?: string, mode: 'claude' | 'shell' = 'claude'): Promise => { - try { - // Use the web API to create a session if server is running - const response = await fetch('http://localhost:3000/api/quick-start', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ caseName: caseName || `case-${Date.now()}`, mode }), - }); + const actualCaseName = caseName || 'testcase'; - if (response.ok) { - const data = await response.json() as { success?: boolean; sessionId?: string; caseName?: string }; - if (data.success && data.sessionId) { - // Refresh and select new session - const sessionId = data.sessionId; - setTimeout(() => { - refreshSessions(); - refreshCases(); - setActiveSessionId(sessionId); - }, 500); - return sessionId; + 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; + } } } - // Fallback: Just report that web server isn't running + // 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( - 'Web server not running. Start it with: claudeman web\n' + - 'Then use this TUI to manage sessions.' - ); - return null; - } catch { - setTerminalOutput( - 'Cannot connect to Claudeman server.\n' + - 'Start the server with: claudeman web\n' + - 'Then use this TUI to manage sessions.' + `Failed to create session: ${err instanceof Error ? err.message : 'Unknown error'}\n` + + 'Make sure the web server is running: claudeman web' ); return null; } - }, [refreshSessions, refreshCases]); + }, [sessions, refreshSessions, refreshCases]); // Kill a session const killSession = useCallback((sessionId: string) => { @@ -631,30 +710,49 @@ export function useSessionManager(): SessionManagerState { selectSession(sessions[prevIndex].sessionId); }, [sessions, activeSessionId, selectSession]); - // Send input to a session + // 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; - try { - // Send input via screen stuff command - // Escape special characters for shell - const escaped = input.replace(/'/g, "'\\''"); + const screenName = session.screenName; + try { if (input === '\r') { // Send Enter key - execSync(`screen -S ${session.screenName} -p 0 -X stuff $'\\015'`, { + execSync(`screen -S ${screenName} -p 0 -X stuff $'\\015'`, { encoding: 'utf-8', - timeout: 5000, + 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 { - execSync(`screen -S ${session.screenName} -p 0 -X stuff '${escaped}'`, { + // Regular character - escape single quotes + const escaped = input.replace(/'/g, "'\\''"); + execSync(`screen -S ${screenName} -p 0 -X stuff '${escaped}'`, { encoding: 'utf-8', - timeout: 5000, + timeout: 1000, }); } } catch { - // Input may fail if screen is not ready or timeout + // Input may fail if screen is not ready - ignore } }, [sessions]); @@ -716,6 +814,7 @@ export function useSessionManager(): SessionManagerState { innerTodos, respawnStatus, cases, + lastUsedCase, refreshSessions, refreshCases, selectSession, diff --git a/src/tui/index.tsx b/src/tui/index.tsx index 03188a8a..ad30b7ef 100644 --- a/src/tui/index.tsx +++ b/src/tui/index.tsx @@ -26,9 +26,10 @@ */ import { render } from 'ink'; -import { spawn } from 'child_process'; +import { spawn, spawnSync } from 'child_process'; import { createInterface } from 'readline'; -import { App } from './App.js'; +import { App, getPendingAttach, clearPendingAttach } from './App.js'; +import { attachWithTabs, getFullScreenId } from './DirectAttach.js'; /** * Checks if the terminal supports raw mode input. @@ -200,24 +201,99 @@ export async function startTUI(options: TUIOptions = {}): Promise { // Clear the terminal for full-screen experience process.stdout.write('\x1b[2J\x1b[H'); - const { waitUntilExit, unmount } = render(); + // Main TUI loop - allows unmounting for screen attachment and re-rendering + let shouldContinue = true; - // 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 - }; + while (shouldContinue) { + // Clear any pending attach from previous iteration + clearPendingAttach(); - process.on('SIGINT', cleanup); - process.on('SIGTERM', cleanup); + const { waitUntilExit, unmount } = render(); - try { - await waitUntilExit(); - } finally { - process.off('SIGINT', cleanup); - process.off('SIGTERM', cleanup); - cleanup(); + // 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/types.ts b/src/types.ts index a32a81bc..1e6299d6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -187,6 +187,8 @@ export interface AppConfig { stateFilePath: string; /** Respawn controller configuration */ respawn: RespawnConfig; + /** Last used case name (for default selection) */ + lastUsedCase: string | null; } // ========== Output Types ========== @@ -490,6 +492,7 @@ export const DEFAULT_CONFIG: AppConfig = { sendClear: true, // send /clear after update prompt sendInit: true, // send /init after /clear }, + lastUsedCase: null, }; // ========== Inner Loop Tracking Types ========== diff --git a/src/web/public/app.js b/src/web/public/app.js index c77b0098..42983ec5 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -892,11 +892,23 @@ class ClaudemanApp { async loadQuickStartCases(selectCaseName = null) { try { + // Load settings to get lastUsedCase + let lastUsedCase = null; + try { + const settingsRes = await fetch('/api/settings'); + if (settingsRes.ok) { + const settings = await settingsRes.json(); + lastUsedCase = settings.lastUsedCase || null; + } + } catch { + // Ignore settings load errors + } + // Add cache-busting to ensure fresh data const res = await fetch('/api/cases?_t=' + Date.now()); const cases = await res.json(); this.cases = cases; - console.log('[loadQuickStartCases] Loaded cases:', cases.map(c => c.name)); + console.log('[loadQuickStartCases] Loaded cases:', cases.map(c => c.name), 'lastUsedCase:', lastUsedCase); const select = document.getElementById('quickStartCase'); @@ -920,8 +932,12 @@ class ClaudemanApp { if (selectCaseName) { select.value = selectCaseName; this.updateDirDisplayForCase(selectCaseName); + } else if (lastUsedCase && cases.some(c => c.name === lastUsedCase)) { + // Use lastUsedCase if available and exists + select.value = lastUsedCase; + this.updateDirDisplayForCase(lastUsedCase); } else if (cases.length > 0) { - // Auto-select first case + // Fallback to testcase or first case const firstCase = cases.find(c => c.name === 'testcase') || cases[0]; select.value = firstCase.name; this.updateDirDisplayForCase(firstCase.name); diff --git a/src/web/server.ts b/src/web/server.ts index 6b091fa7..fa44aba1 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -854,6 +854,23 @@ export class WebServer extends EventEmitter { } this.broadcast('session:updated', { session: session.toDetailedState() }); + // Save lastUsedCase to settings for TUI/web sync + try { + const settingsFilePath = join(homedir(), '.claudeman', 'settings.json'); + let settings: Record = {}; + if (existsSync(settingsFilePath)) { + settings = JSON.parse(readFileSync(settingsFilePath, 'utf-8')); + } + settings.lastUsedCase = caseName; + const dir = dirname(settingsFilePath); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } + writeFileSync(settingsFilePath, JSON.stringify(settings, null, 2)); + } catch { + // Non-critical, ignore settings save errors + } + return { success: true, sessionId: session.id,