mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
chore: bump version to 0.1427
- Remove TUI from git (moved to .gitignore for local development) - Remove React/Ink dependencies (unused in published version) - Remove tui command from CLI Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -37,6 +37,9 @@ Thumbs.db
|
||||
# Temporary files
|
||||
*.tmp
|
||||
*.temp
|
||||
|
||||
# Unfinished TUI (local development only)
|
||||
src/tui/
|
||||
.claude/
|
||||
media-assets/
|
||||
commands
|
||||
|
||||
@@ -16,7 +16,7 @@ When user says "COM":
|
||||
1. Increment version in BOTH `package.json` AND `CLAUDE.md`
|
||||
2. Run: `git add -A && git commit -m "chore: bump version to X.XXXX" && git push && npm run build && systemctl --user restart claudeman-web`
|
||||
|
||||
**Version**: 0.1426 (must match `package.json`)
|
||||
**Version**: 0.1427 (must match `package.json`)
|
||||
|
||||
## Project Overview
|
||||
|
||||
|
||||
+1
-7
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "claudeman",
|
||||
"version": "0.1426",
|
||||
"version": "0.1427",
|
||||
"description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -14,7 +14,6 @@
|
||||
"start": "node dist/index.js",
|
||||
"dev": "tsx src/index.ts",
|
||||
"web": "node dist/index.js web",
|
||||
"tui": "tsx src/index.ts tui",
|
||||
"clean": "rm -rf dist",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
@@ -45,18 +44,13 @@
|
||||
"chokidar": "^3.6.0",
|
||||
"commander": "^12.1.0",
|
||||
"fastify": "^5.1.0",
|
||||
"ink": "^6.6.0",
|
||||
"ink-spinner": "^5.0.0",
|
||||
"ink-text-input": "^6.0.0",
|
||||
"node-pty": "^1.1.0",
|
||||
"react": "^19.2.3",
|
||||
"uuid": "^10.0.0",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.14.0",
|
||||
"@types/pngjs": "^6.0.5",
|
||||
"@types/react": "^19.2.9",
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@vitest/coverage-v8": "^4.0.18",
|
||||
"agent-browser": "^0.6.0",
|
||||
|
||||
-22
@@ -515,26 +515,4 @@ program
|
||||
}
|
||||
});
|
||||
|
||||
// TUI command
|
||||
program
|
||||
.command('tui')
|
||||
.description('Start the terminal user interface')
|
||||
.option('--with-web', 'Automatically start the web server if not running (no prompt)')
|
||||
.option('--no-web', 'Skip web server check entirely')
|
||||
.option('-p, --port <port>', 'Web server port (default: 3000)', '3000')
|
||||
.action(async (options: { withWeb?: boolean; web?: boolean; port?: string }) => {
|
||||
try {
|
||||
const { startTUI } = await import('./tui/index.js');
|
||||
const port = parseInt(options.port || '3000', 10);
|
||||
await startTUI({
|
||||
autoStartWeb: options.withWeb || false,
|
||||
skipWebCheck: options.web === false,
|
||||
port,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`✗ Failed to start TUI: ${getErrorMessage(err)}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
export { program };
|
||||
|
||||
-460
@@ -1,460 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Main TUI App component
|
||||
*
|
||||
* The root component that manages the overall TUI layout and navigation.
|
||||
*
|
||||
* @description
|
||||
* Provides two primary views:
|
||||
* - **StartScreen**: Session discovery and selection interface
|
||||
* - **Main view**: Active session management with:
|
||||
* - TabBar: Session tabs with switching
|
||||
* - TerminalView: Live terminal output display
|
||||
* - RalphPanel: Ralph loop tracking (conditional)
|
||||
* - StatusBar: Session info and keyboard hints
|
||||
*
|
||||
* Keyboard shortcuts are handled globally via Ink's useInput hook.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* import { render } from 'ink';
|
||||
* import { App } from './App.js';
|
||||
*
|
||||
* render(<App />);
|
||||
* ```
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Box, useApp, useInput, useStdout } from 'ink';
|
||||
import { StartScreen } from './components/StartScreen.js';
|
||||
import { TabBar } from './components/TabBar.js';
|
||||
import { TerminalView } from './components/TerminalView.js';
|
||||
import { StatusBar } from './components/StatusBar.js';
|
||||
import { HelpOverlay } from './components/HelpOverlay.js';
|
||||
import { RalphPanel } from './components/RalphPanel.js';
|
||||
import { useSessionManager } from './hooks/useSessionManager.js';
|
||||
import type { ScreenSession } from '../types.js';
|
||||
|
||||
/**
|
||||
* Pending screen attachment request.
|
||||
* Used to communicate between App component and the entry point.
|
||||
*/
|
||||
export interface PendingAttach {
|
||||
mode: 'tabs' | 'direct';
|
||||
sessions: ScreenSession[];
|
||||
index: number;
|
||||
session?: ScreenSession;
|
||||
}
|
||||
|
||||
// Module-level state for pending attachment (shared between App and entry point)
|
||||
let pendingAttach: PendingAttach | null = null;
|
||||
|
||||
/**
|
||||
* Set a pending screen attachment request.
|
||||
* Called by App when user wants to attach to a screen.
|
||||
*/
|
||||
export function setPendingAttach(attach: PendingAttach): void {
|
||||
pendingAttach = attach;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current pending attachment request.
|
||||
* Called by entry point after App exits.
|
||||
*/
|
||||
export function getPendingAttach(): PendingAttach | null {
|
||||
return pendingAttach;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the pending attachment request.
|
||||
* Called by entry point after handling the attachment.
|
||||
*/
|
||||
export function clearPendingAttach(): void {
|
||||
pendingAttach = null;
|
||||
}
|
||||
|
||||
type ViewMode = 'start' | 'main';
|
||||
|
||||
/**
|
||||
* Main TUI application component.
|
||||
*
|
||||
* @description
|
||||
* Renders either the StartScreen or Main view based on current state.
|
||||
* Handles all global keyboard shortcuts and manages terminal dimensions.
|
||||
*
|
||||
* **Global Shortcuts:**
|
||||
* - `?` or `Ctrl+H`: Show help overlay
|
||||
* - `Ctrl+C`: Exit application
|
||||
* - `Ctrl+Tab/Shift+Tab`: Navigate sessions
|
||||
* - `Ctrl+1-9`: Direct session access
|
||||
* - `Ctrl+W`: Close current session
|
||||
* - `Ctrl+K`: Kill all sessions
|
||||
* - `Ctrl+N`: Create new session
|
||||
* - `Escape`: Return to start screen
|
||||
*
|
||||
* @returns The rendered TUI application
|
||||
*/
|
||||
export function App(): React.ReactElement {
|
||||
const { exit } = useApp();
|
||||
const { stdout } = useStdout();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('start');
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const [terminalHeight, setTerminalHeight] = useState(20);
|
||||
|
||||
const {
|
||||
sessions,
|
||||
activeSessionId,
|
||||
activeSession,
|
||||
refreshSessions,
|
||||
refreshCases,
|
||||
selectSession,
|
||||
createSession,
|
||||
createCase,
|
||||
killSession,
|
||||
killAllSessions,
|
||||
killAllScreensAndClaude,
|
||||
nextSession,
|
||||
prevSession,
|
||||
sendInput,
|
||||
terminalOutput,
|
||||
ralphLoopState,
|
||||
ralphTodos,
|
||||
respawnStatus,
|
||||
cases,
|
||||
lastUsedCase,
|
||||
toggleRespawn,
|
||||
} = useSessionManager();
|
||||
|
||||
// Calculate terminal height based on stdout dimensions
|
||||
useEffect(() => {
|
||||
const updateHeight = () => {
|
||||
// Reserve: 3 for TabBar, 3 for StatusBar, 2 for borders
|
||||
const rows = stdout?.rows || 24;
|
||||
setTerminalHeight(Math.max(10, rows - 8));
|
||||
};
|
||||
updateHeight();
|
||||
|
||||
stdout?.on('resize', updateHeight);
|
||||
return () => {
|
||||
stdout?.off('resize', updateHeight);
|
||||
};
|
||||
}, [stdout]);
|
||||
|
||||
// Handle keyboard input
|
||||
useInput((input, key) => {
|
||||
// Help overlay takes priority
|
||||
if (showHelp) {
|
||||
if (key.escape || input === 'q') {
|
||||
setShowHelp(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Global shortcuts - use Ctrl+H for help (? is a normal character)
|
||||
if (key.ctrl && input === 'h') {
|
||||
setShowHelp(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Exit on Ctrl+C
|
||||
if (key.ctrl && input === 'c') {
|
||||
exit();
|
||||
return;
|
||||
}
|
||||
|
||||
// Start screen specific inputs - handled by StartScreen component
|
||||
// Only number keys for quick session selection are handled here
|
||||
if (viewMode === 'start') {
|
||||
const num = parseInt(input, 10);
|
||||
if (!isNaN(num) && num >= 1 && num <= sessions.length) {
|
||||
handleSelectSession(sessions[num - 1]);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// === SESSION SWITCHING SHORTCUTS ===
|
||||
|
||||
// Ctrl+Tab / Ctrl+Shift+Tab for session switching (if terminal supports it)
|
||||
// Plain Tab is sent to the session for auto-complete
|
||||
if (key.ctrl && key.tab) {
|
||||
if (key.shift) {
|
||||
prevSession();
|
||||
} else {
|
||||
nextSession();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Alt+Left/Right arrow keys for session switching
|
||||
if (key.meta && key.leftArrow) {
|
||||
prevSession();
|
||||
return;
|
||||
}
|
||||
if (key.meta && key.rightArrow) {
|
||||
nextSession();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+[ and Ctrl+] for previous/next session (vim-style, requires Ctrl)
|
||||
// Note: Ctrl+[ is often Escape, so this may not work on all terminals
|
||||
if (key.ctrl && input === '[') {
|
||||
prevSession();
|
||||
return;
|
||||
}
|
||||
if (key.ctrl && input === ']') {
|
||||
nextSession();
|
||||
return;
|
||||
}
|
||||
|
||||
// Alt+1-9 or Ctrl+1-9 for direct tab access
|
||||
if (key.ctrl || key.meta) {
|
||||
const num = parseInt(input, 10);
|
||||
if (!isNaN(num) && num >= 1 && num <= 9 && num <= sessions.length) {
|
||||
selectSession(sessions[num - 1].sessionId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// === SESSION MANAGEMENT SHORTCUTS ===
|
||||
|
||||
// Ctrl+W to close current session
|
||||
if (key.ctrl && input === 'w') {
|
||||
if (activeSessionId) {
|
||||
killSession(activeSessionId);
|
||||
if (sessions.length <= 1) {
|
||||
setViewMode('start');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+K to kill all sessions
|
||||
if (key.ctrl && input === 'k') {
|
||||
killAllSessions();
|
||||
setViewMode('start');
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+N for new session
|
||||
if (key.ctrl && input === 'n') {
|
||||
handleCreateSession();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+R to toggle respawn on active session
|
||||
if (key.ctrl && input === 'r') {
|
||||
if (activeSessionId && activeSession?.mode === 'claude') {
|
||||
toggleRespawn();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape to go back to start screen (doesn't close session)
|
||||
if (key.escape) {
|
||||
setViewMode('start');
|
||||
return;
|
||||
}
|
||||
|
||||
// === FORWARD INPUT TO SESSION ===
|
||||
// Forward all other input to the active screen session
|
||||
if (activeSessionId) {
|
||||
// Handle special keys
|
||||
if (key.return) {
|
||||
sendInput(activeSessionId, '\r');
|
||||
return;
|
||||
}
|
||||
if (key.backspace || key.delete) {
|
||||
sendInput(activeSessionId, '\x7f');
|
||||
return;
|
||||
}
|
||||
// Tab key - send to session for auto-complete
|
||||
if (key.tab && !key.ctrl) {
|
||||
sendInput(activeSessionId, '\t');
|
||||
return;
|
||||
}
|
||||
// Arrow keys - send ANSI escape sequences
|
||||
if (key.upArrow) {
|
||||
sendInput(activeSessionId, '\x1b[A');
|
||||
return;
|
||||
}
|
||||
if (key.downArrow) {
|
||||
sendInput(activeSessionId, '\x1b[B');
|
||||
return;
|
||||
}
|
||||
if (key.rightArrow && !key.meta) {
|
||||
sendInput(activeSessionId, '\x1b[C');
|
||||
return;
|
||||
}
|
||||
if (key.leftArrow && !key.meta) {
|
||||
sendInput(activeSessionId, '\x1b[D');
|
||||
return;
|
||||
}
|
||||
// Ctrl+key combinations (send as control characters)
|
||||
if (key.ctrl && input) {
|
||||
// Convert to control character (Ctrl+A = 0x01, Ctrl+B = 0x02, etc.)
|
||||
const code = input.toLowerCase().charCodeAt(0) - 96;
|
||||
if (code >= 1 && code <= 26) {
|
||||
sendInput(activeSessionId, String.fromCharCode(code));
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Regular character input
|
||||
if (input && !key.ctrl && !key.meta) {
|
||||
sendInput(activeSessionId, input);
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Select a session and enter direct attach mode with tab bar.
|
||||
* Sets pending attachment and exits Ink so the entry point can handle it.
|
||||
*/
|
||||
const handleSelectSession = useCallback((session: ScreenSession) => {
|
||||
const sessionIndex = sessions.findIndex(s => s.sessionId === session.sessionId);
|
||||
if (sessionIndex === -1) return;
|
||||
|
||||
// Set pending attachment and exit Ink
|
||||
// The entry point will handle the actual screen attachment
|
||||
setPendingAttach({
|
||||
mode: 'tabs',
|
||||
sessions: [...sessions], // Copy to avoid stale reference
|
||||
index: sessionIndex,
|
||||
});
|
||||
exit();
|
||||
}, [sessions, exit]);
|
||||
|
||||
const handleCreateSession = useCallback(async (caseName?: string, count?: number, mode: 'claude' | 'shell' = 'claude') => {
|
||||
const sessionsToCreate = Math.min(Math.max(count || 1, 1), 20);
|
||||
let lastSessionId: string | null = null;
|
||||
|
||||
// Create sessions sequentially
|
||||
for (let i = 0; i < sessionsToCreate; i++) {
|
||||
const sessionId = await createSession(caseName || 'default', mode);
|
||||
if (sessionId) {
|
||||
lastSessionId = sessionId;
|
||||
// Wait a moment for screen to be ready
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
refreshSessions();
|
||||
}
|
||||
if (i < sessionsToCreate - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh to get updated session list
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
refreshSessions();
|
||||
|
||||
// Wait for state to update, then attach to the new session
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
if (lastSessionId) {
|
||||
// Re-fetch sessions to get the fresh list with the new session
|
||||
// We need to read the screens file directly since state may not have updated yet
|
||||
const { existsSync, readFileSync } = await import('fs');
|
||||
const { homedir } = await import('os');
|
||||
const { join } = await import('path');
|
||||
const screensFile = join(homedir(), '.claudeman', 'screens.json');
|
||||
|
||||
let freshSessions: ScreenSession[] = [];
|
||||
try {
|
||||
if (existsSync(screensFile)) {
|
||||
freshSessions = JSON.parse(readFileSync(screensFile, 'utf-8'));
|
||||
}
|
||||
} catch {
|
||||
freshSessions = sessions;
|
||||
}
|
||||
|
||||
if (freshSessions.length > 0) {
|
||||
const sessionIndex = freshSessions.findIndex(s => s.sessionId === lastSessionId);
|
||||
const targetIndex = sessionIndex >= 0 ? sessionIndex : freshSessions.length - 1;
|
||||
|
||||
// Set pending attachment and exit Ink
|
||||
setPendingAttach({
|
||||
mode: 'tabs',
|
||||
sessions: freshSessions,
|
||||
index: targetIndex,
|
||||
});
|
||||
exit();
|
||||
}
|
||||
}
|
||||
}, [createSession, refreshSessions, sessions, exit]);
|
||||
|
||||
/**
|
||||
* Attach directly to a screen session (skipping tab menu).
|
||||
* Sets pending attachment and exits Ink so the entry point can handle it.
|
||||
*/
|
||||
const handleAttachSession = useCallback((session: ScreenSession) => {
|
||||
// Set pending attachment for direct mode and exit Ink
|
||||
setPendingAttach({
|
||||
mode: 'direct',
|
||||
sessions: [...sessions],
|
||||
index: sessions.findIndex(s => s.sessionId === session.sessionId),
|
||||
session: session,
|
||||
});
|
||||
exit();
|
||||
}, [sessions, exit]);
|
||||
|
||||
// Render help overlay if shown
|
||||
if (showHelp) {
|
||||
return <HelpOverlay onClose={() => setShowHelp(false)} />;
|
||||
}
|
||||
|
||||
// Handle delete session from start screen
|
||||
const handleDeleteSession = useCallback((session: ScreenSession) => {
|
||||
killSession(session.sessionId);
|
||||
}, [killSession]);
|
||||
|
||||
// Render start screen
|
||||
if (viewMode === 'start') {
|
||||
return (
|
||||
<StartScreen
|
||||
sessions={sessions}
|
||||
cases={cases}
|
||||
lastUsedCase={lastUsedCase}
|
||||
onSelectSession={handleSelectSession}
|
||||
onAttachSession={handleAttachSession}
|
||||
onDeleteSession={handleDeleteSession}
|
||||
onDeleteAll={killAllScreensAndClaude}
|
||||
onCreateSession={handleCreateSession}
|
||||
onCreateCase={createCase}
|
||||
onRefresh={refreshSessions}
|
||||
onRefreshCases={refreshCases}
|
||||
onExit={exit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Check if Ralph panel should be visible (enabled and has data)
|
||||
const showRalphPanel = ralphLoopState?.enabled && (ralphLoopState.active || ralphTodos.length > 0);
|
||||
|
||||
// Adjust terminal height if Ralph panel is shown
|
||||
const adjustedTerminalHeight = showRalphPanel ? terminalHeight - 6 : terminalHeight;
|
||||
|
||||
// Render main view with tabs, terminal, ralph panel, and status bar
|
||||
return (
|
||||
<Box flexDirection="column" height="100%">
|
||||
<TabBar
|
||||
sessions={sessions}
|
||||
activeSessionId={activeSessionId}
|
||||
onSelectSession={(id) => selectSession(id)}
|
||||
/>
|
||||
|
||||
<TerminalView
|
||||
output={terminalOutput}
|
||||
height={adjustedTerminalHeight}
|
||||
session={activeSession}
|
||||
/>
|
||||
|
||||
{showRalphPanel && (
|
||||
<RalphPanel
|
||||
loopState={ralphLoopState}
|
||||
todos={ralphTodos}
|
||||
/>
|
||||
)}
|
||||
|
||||
<StatusBar session={activeSession} respawnStatus={respawnStatus} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
/**
|
||||
* @fileoverview HelpOverlay component
|
||||
*
|
||||
* Full-screen help overlay displaying all TUI keyboard shortcuts.
|
||||
*
|
||||
* @description
|
||||
* Shows a comprehensive reference of keyboard shortcuts organized by context:
|
||||
* - Start Screen: Session list navigation and actions
|
||||
* - Main View - Navigation: Tab switching and screen navigation
|
||||
* - Main View - Session Management: Create, close, kill sessions
|
||||
* - General: Help toggle and exit
|
||||
*
|
||||
* Dismissible with Escape, q, or ? keys.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
|
||||
interface HelpOverlayProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface ShortcutGroup {
|
||||
title: string;
|
||||
shortcuts: Array<{ key: string; description: string }>;
|
||||
}
|
||||
|
||||
const SHORTCUT_GROUPS: ShortcutGroup[] = [
|
||||
{
|
||||
title: 'Start Screen - Sessions',
|
||||
shortcuts: [
|
||||
{ key: '\u2191/\u2193', description: 'Navigate list' },
|
||||
{ key: 'Enter', description: 'Tab switcher \u2192 full attach' },
|
||||
{ key: 'a', description: 'Direct attach (skip tab menu)' },
|
||||
{ key: 'd', description: 'Delete/kill selected session' },
|
||||
{ key: 'D (Shift+d)', description: 'Delete ALL screens & Claude processes' },
|
||||
{ key: 'c', description: 'Switch to cases view' },
|
||||
{ key: 'n', description: 'Quick-start new session' },
|
||||
{ key: 'r', description: 'Refresh list' },
|
||||
{ key: 'q', description: 'Quit' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Start Screen - Cases',
|
||||
shortcuts: [
|
||||
{ key: '\u2191/\u2193', description: 'Navigate list' },
|
||||
{ key: 'Enter', description: 'Start Claude session with case' },
|
||||
{ key: 'h', description: 'Start Shell session with case' },
|
||||
{ key: 'm', description: 'Multi-start (1-20 sessions)' },
|
||||
{ key: 'n', description: 'Create new case' },
|
||||
{ key: 's', description: 'Switch to sessions view' },
|
||||
{ key: 'r', description: 'Refresh list' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Tab Switcher Menu',
|
||||
shortcuts: [
|
||||
{ key: '1-9', description: 'Select and attach to session N' },
|
||||
{ key: 'Enter', description: 'Attach to current session' },
|
||||
{ key: 'q/Esc', description: 'Return to TUI start screen' },
|
||||
{ key: 'Ctrl+A D', description: '(While attached) Detach to menu' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'General',
|
||||
shortcuts: [
|
||||
{ key: 'Ctrl+H', description: 'Toggle this help' },
|
||||
{ key: 'Ctrl+C', description: 'Exit TUI' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Help overlay component displaying keyboard shortcuts reference.
|
||||
*
|
||||
* @description
|
||||
* Renders a full-screen overlay with categorized keyboard shortcuts.
|
||||
* Uses consistent styling with bordered header and grouped sections.
|
||||
*
|
||||
* @param props - Component props
|
||||
* @param props.onClose - Callback invoked when overlay should close
|
||||
* @returns The help overlay element
|
||||
*/
|
||||
export function HelpOverlay({ onClose: _onClose }: HelpOverlayProps): React.ReactElement {
|
||||
return (
|
||||
<Box flexDirection="column" padding={2}>
|
||||
{/* Header */}
|
||||
<Box
|
||||
borderStyle="double"
|
||||
borderColor="cyan"
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
marginBottom={1}
|
||||
justifyContent="center"
|
||||
>
|
||||
<Text bold color="cyan">
|
||||
Keyboard Shortcuts
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Shortcut groups */}
|
||||
{SHORTCUT_GROUPS.map((group, groupIndex) => (
|
||||
<Box key={groupIndex} flexDirection="column" marginBottom={1}>
|
||||
<Text bold color="yellow">
|
||||
{group.title}
|
||||
</Text>
|
||||
<Box flexDirection="column" marginLeft={2}>
|
||||
{group.shortcuts.map((shortcut, index) => (
|
||||
<Box key={index}>
|
||||
<Text color="green">{shortcut.key.padEnd(20)}</Text>
|
||||
<Text>{shortcut.description}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{/* Footer */}
|
||||
<Box marginTop={1} borderStyle="single" borderColor="gray" paddingX={1}>
|
||||
<Text dimColor>Press Escape or q to close</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
/**
|
||||
* @fileoverview RalphPanel component
|
||||
*
|
||||
* Displays Ralph Wiggum autonomous loop tracking information.
|
||||
*
|
||||
* @description
|
||||
* The Ralph Wiggum loop is an autonomous work mode where Claude
|
||||
* continues iterating on tasks until completion criteria are met.
|
||||
* This panel provides visibility into:
|
||||
* - Loop status (active/idle) and completion phrase
|
||||
* - Progress through todos with visual indicators
|
||||
* - Cycle count and elapsed time tracking
|
||||
*
|
||||
* @see {@link file://./../../ralph-tracker.ts} for detection logic
|
||||
* @see {@link file://./../../../docs/ralph-wiggum-guide.md} for full documentation
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { RalphTrackerState, RalphTodoItem } from '../../types.js';
|
||||
|
||||
interface RalphPanelProps {
|
||||
loopState: RalphTrackerState | null;
|
||||
todos: RalphTodoItem[];
|
||||
visible?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the visual indicator for a todo item's status.
|
||||
*
|
||||
* @param status - The todo status: 'completed', 'in_progress', or 'pending'
|
||||
* @returns Object with Unicode icon and color name
|
||||
*/
|
||||
function getStatusIcon(status: string): { icon: string; color: string } {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return { icon: '\u2713', color: 'green' };
|
||||
case 'in_progress':
|
||||
return { icon: '\u25CF', color: 'yellow' };
|
||||
case 'pending':
|
||||
default:
|
||||
return { icon: '\u25CB', color: 'gray' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats elapsed hours to a compact human-readable string.
|
||||
*
|
||||
* @param hours - Elapsed time in hours (can be fractional)
|
||||
* @returns Formatted string like "45m" or "2.5h"
|
||||
*/
|
||||
function formatElapsed(hours: number | null): string {
|
||||
if (hours === null) return '-';
|
||||
if (hours < 1) {
|
||||
const minutes = Math.round(hours * 60);
|
||||
return `${minutes}m`;
|
||||
}
|
||||
return `${hours.toFixed(1)}h`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Panel component displaying Ralph Wiggum loop status and progress.
|
||||
*
|
||||
* @description
|
||||
* Renders a bordered panel with:
|
||||
* - Header showing loop status and completion phrase
|
||||
* - Stats row with cycle count, elapsed time, and todo progress
|
||||
* - Todo list (max 5 visible) with status icons
|
||||
*
|
||||
* Only renders when loop is enabled and has relevant data to show.
|
||||
*
|
||||
* @param props - Component props
|
||||
* @param props.loopState - Current loop state from RalphTracker
|
||||
* @param props.todos - Array of todo items being tracked
|
||||
* @param props.visible - Whether the panel should be visible (default: true)
|
||||
* @returns The panel element or null if hidden/disabled
|
||||
*/
|
||||
export function RalphPanel({
|
||||
loopState,
|
||||
todos,
|
||||
visible = true,
|
||||
}: RalphPanelProps): React.ReactElement | null {
|
||||
// Don't render if not visible or no loop state
|
||||
if (!visible || !loopState || !loopState.enabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const completedCount = todos.filter((t) => t.status === 'completed').length;
|
||||
const totalCount = todos.length;
|
||||
const progressPercent = totalCount > 0 ? Math.round((completedCount / totalCount) * 100) : 0;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" borderStyle="single" borderColor="magenta" marginBottom={1}>
|
||||
{/* Header */}
|
||||
<Box paddingX={1} justifyContent="space-between">
|
||||
<Text color="magenta" bold>
|
||||
Ralph Loop {loopState.active ? '(active)' : '(idle)'}
|
||||
</Text>
|
||||
{loopState.completionPhrase && (
|
||||
<Text dimColor>
|
||||
phrase: {loopState.completionPhrase}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Stats row */}
|
||||
<Box paddingX={1}>
|
||||
<Text>
|
||||
<Text dimColor>cycles:</Text> {loopState.cycleCount}
|
||||
{loopState.maxIterations && <Text>/{loopState.maxIterations}</Text>}
|
||||
<Text> | </Text>
|
||||
<Text dimColor>elapsed:</Text> {formatElapsed(loopState.elapsedHours)}
|
||||
<Text> | </Text>
|
||||
<Text dimColor>todos:</Text> {completedCount}/{totalCount} ({progressPercent}%)
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Todo list (max 5 items) */}
|
||||
{todos.length > 0 && (
|
||||
<Box flexDirection="column" paddingX={1} marginTop={1}>
|
||||
{todos.slice(0, 5).map((todo) => {
|
||||
const { icon, color } = getStatusIcon(todo.status);
|
||||
const content = todo.content.length > 50
|
||||
? todo.content.slice(0, 47) + '...'
|
||||
: todo.content;
|
||||
|
||||
return (
|
||||
<Box key={todo.id}>
|
||||
<Text color={color}>{icon} </Text>
|
||||
<Text color={todo.status === 'completed' ? 'gray' : undefined}>
|
||||
{content}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
{todos.length > 5 && (
|
||||
<Text dimColor>... and {todos.length - 5} more</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,637 +0,0 @@
|
||||
/**
|
||||
* @fileoverview StartScreen component
|
||||
*
|
||||
* Session discovery and selection interface for the TUI.
|
||||
*
|
||||
* @description
|
||||
* The initial screen displayed when launching `claudeman tui`:
|
||||
* - Shows existing sessions and available cases
|
||||
* - Supports two modes: session list and case selection
|
||||
* - Arrow key navigation with visual selection highlight
|
||||
* - Actions: Enter (view/select), a (attach), d (delete), n (new), c (cases), r (refresh), q (quit)
|
||||
*
|
||||
* This is the "home screen" users return to with Escape from the main view.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Box, Text, useInput } from 'ink';
|
||||
import TextInput from 'ink-text-input';
|
||||
import type { ScreenSession } from '../../types.js';
|
||||
|
||||
interface CaseInfo {
|
||||
name: string;
|
||||
path: string;
|
||||
hasClaudeMd: boolean;
|
||||
}
|
||||
|
||||
interface StartScreenProps {
|
||||
sessions: ScreenSession[];
|
||||
cases: CaseInfo[];
|
||||
lastUsedCase: string | null;
|
||||
onSelectSession: (session: ScreenSession) => void;
|
||||
onAttachSession: (session: ScreenSession) => void;
|
||||
onDeleteSession: (session: ScreenSession) => void;
|
||||
onDeleteAll: () => void;
|
||||
onCreateSession: (caseName?: string, count?: number, mode?: 'claude' | 'shell') => void;
|
||||
onCreateCase: (name: string) => Promise<boolean>;
|
||||
onRefresh: () => void;
|
||||
onRefreshCases: () => void;
|
||||
onExit: () => void;
|
||||
}
|
||||
|
||||
type ScreenMode = 'sessions' | 'cases' | 'new-case' | 'multi-start';
|
||||
|
||||
/**
|
||||
* Formats a duration from milliseconds to a compact human-readable string.
|
||||
*
|
||||
* @param ms - Duration in milliseconds
|
||||
* @returns Formatted string like "45s", "5m", "2h 15m", or "3d 5h"
|
||||
*/
|
||||
function formatDuration(ms: number): string {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 0) {
|
||||
return `${days}d ${hours % 24}h`;
|
||||
}
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes % 60}m`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets display name for a session (matches web interface logic).
|
||||
* Priority: custom name > directory name (case) > session ID
|
||||
*/
|
||||
function getSessionName(session: ScreenSession): string {
|
||||
if (session.name) {
|
||||
return session.name;
|
||||
}
|
||||
if (session.workingDir) {
|
||||
return session.workingDir.split('/').pop() || session.workingDir;
|
||||
}
|
||||
return session.sessionId.slice(0, 8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start screen component for session discovery and selection.
|
||||
*
|
||||
* @description
|
||||
* Renders two views:
|
||||
* - Sessions view: List of active sessions
|
||||
* - Cases view: List of available cases to start a new session
|
||||
*
|
||||
* @param props - Component props
|
||||
* @returns The start screen element
|
||||
*/
|
||||
export function StartScreen({
|
||||
sessions,
|
||||
cases,
|
||||
lastUsedCase,
|
||||
onSelectSession,
|
||||
onAttachSession,
|
||||
onDeleteSession,
|
||||
onDeleteAll,
|
||||
onCreateSession,
|
||||
onCreateCase,
|
||||
onRefresh,
|
||||
onRefreshCases,
|
||||
onExit,
|
||||
}: StartScreenProps): React.ReactElement {
|
||||
const now = Date.now();
|
||||
const [mode, setMode] = useState<ScreenMode>('sessions');
|
||||
const [selectedIndex, setSelectedIndex] = useState(0);
|
||||
const [newCaseName, setNewCaseName] = useState('');
|
||||
const [multiStartCount, setMultiStartCount] = useState('1');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Reset selection when switching modes - default to lastUsedCase for cases view
|
||||
useEffect(() => {
|
||||
if (mode === 'cases' && lastUsedCase && cases.length > 0) {
|
||||
const lastUsedIndex = cases.findIndex(c => c.name === lastUsedCase);
|
||||
setSelectedIndex(lastUsedIndex >= 0 ? lastUsedIndex : 0);
|
||||
} else {
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
setError(null);
|
||||
if (mode !== 'multi-start') {
|
||||
setMultiStartCount('1');
|
||||
}
|
||||
}, [mode, lastUsedCase, cases]);
|
||||
|
||||
// Ensure selectedIndex is valid when list changes
|
||||
useEffect(() => {
|
||||
const maxIndex = mode === 'sessions' ? sessions.length - 1 : cases.length - 1;
|
||||
if (selectedIndex > maxIndex && maxIndex >= 0) {
|
||||
setSelectedIndex(maxIndex);
|
||||
}
|
||||
}, [sessions.length, cases.length, selectedIndex, mode]);
|
||||
|
||||
// Handle keyboard input for navigation
|
||||
useInput((input, key) => {
|
||||
// New case input mode - only handle escape and return
|
||||
if (mode === 'new-case') {
|
||||
if (key.escape) {
|
||||
setMode('cases');
|
||||
setNewCaseName('');
|
||||
setError(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Multi-start input mode - only handle escape and return
|
||||
if (mode === 'multi-start') {
|
||||
if (key.escape) {
|
||||
setMode('cases');
|
||||
setMultiStartCount('1');
|
||||
setError(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Arrow key navigation
|
||||
const listLength = mode === 'sessions' ? sessions.length : cases.length;
|
||||
if (key.upArrow && listLength > 0) {
|
||||
setSelectedIndex((prev) => (prev > 0 ? prev - 1 : listLength - 1));
|
||||
return;
|
||||
}
|
||||
if (key.downArrow && listLength > 0) {
|
||||
setSelectedIndex((prev) => (prev < listLength - 1 ? prev + 1 : 0));
|
||||
return;
|
||||
}
|
||||
|
||||
// Enter to select
|
||||
if (key.return && listLength > 0) {
|
||||
if (mode === 'sessions') {
|
||||
onSelectSession(sessions[selectedIndex]);
|
||||
} else if (mode === 'cases') {
|
||||
// Start session with selected case
|
||||
onCreateSession(cases[selectedIndex].name);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Mode-specific shortcuts
|
||||
if (mode === 'sessions') {
|
||||
// 'a' to attach directly to screen
|
||||
if (input === 'a' && sessions.length > 0 && sessions[selectedIndex].attached) {
|
||||
onAttachSession(sessions[selectedIndex]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 'd' or 'x' to delete/kill session
|
||||
if ((input === 'd' || input === 'x') && sessions.length > 0) {
|
||||
onDeleteSession(sessions[selectedIndex]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 'D' (shift+d) to delete ALL screens and Claude processes
|
||||
if (input === 'D') {
|
||||
onDeleteAll();
|
||||
return;
|
||||
}
|
||||
|
||||
// 'c' to switch to cases view
|
||||
if (input === 'c') {
|
||||
onRefreshCases();
|
||||
setMode('cases');
|
||||
return;
|
||||
}
|
||||
|
||||
// 'n' to create new session (quick start with auto name)
|
||||
if (input === 'n') {
|
||||
onCreateSession();
|
||||
return;
|
||||
}
|
||||
} else if (mode === 'cases') {
|
||||
// 's' to switch back to sessions view
|
||||
if (input === 's') {
|
||||
setMode('sessions');
|
||||
return;
|
||||
}
|
||||
|
||||
// 'n' to create new case
|
||||
if (input === 'n') {
|
||||
setMode('new-case');
|
||||
setNewCaseName('');
|
||||
return;
|
||||
}
|
||||
|
||||
// 'm' to start multiple sessions with selected case
|
||||
if (input === 'm' && cases.length > 0) {
|
||||
setMode('multi-start');
|
||||
setMultiStartCount('1');
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// 'h' to start a shell session with selected case
|
||||
if (input === 'h' && cases.length > 0) {
|
||||
onCreateSession(cases[selectedIndex].name, 1, 'shell');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 'r' to refresh
|
||||
if (input === 'r') {
|
||||
if (mode === 'sessions') {
|
||||
onRefresh();
|
||||
} else {
|
||||
onRefreshCases();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 'q' to quit
|
||||
if (input === 'q') {
|
||||
onExit();
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
// Handle new case name submission
|
||||
const handleNewCaseSubmit = async (value: string) => {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
setError('Case name cannot be empty');
|
||||
return;
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) {
|
||||
setError('Use only letters, numbers, hyphens, underscores');
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await onCreateCase(trimmed);
|
||||
if (success) {
|
||||
setMode('cases');
|
||||
setNewCaseName('');
|
||||
setError(null);
|
||||
} else {
|
||||
setError('Failed to create case (may already exist)');
|
||||
}
|
||||
};
|
||||
|
||||
// Handle multi-start submission
|
||||
const handleMultiStartSubmit = (value: string) => {
|
||||
const count = parseInt(value.trim(), 10);
|
||||
if (isNaN(count) || count < 1) {
|
||||
setError('Enter a number from 1 to 20');
|
||||
return;
|
||||
}
|
||||
if (count > 20) {
|
||||
setError('Maximum 20 sessions at once');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the selected case name
|
||||
const selectedCase = cases[selectedIndex];
|
||||
if (!selectedCase) {
|
||||
setError('No case selected');
|
||||
return;
|
||||
}
|
||||
|
||||
// Start the sessions
|
||||
onCreateSession(selectedCase.name, count);
|
||||
setMode('cases');
|
||||
setMultiStartCount('1');
|
||||
setError(null);
|
||||
};
|
||||
|
||||
// Get currently selected item for display
|
||||
const getSelectedItem = (): { type: 'session' | 'case'; name: string; path?: string } | null => {
|
||||
if (mode === 'sessions' && sessions.length > 0 && selectedIndex < sessions.length) {
|
||||
const session = sessions[selectedIndex];
|
||||
return { type: 'session', name: session.name || 'unnamed', path: session.workingDir };
|
||||
}
|
||||
if ((mode === 'cases' || mode === 'multi-start') && cases.length > 0 && selectedIndex < cases.length) {
|
||||
const caseInfo = cases[selectedIndex];
|
||||
return { type: 'case', name: caseInfo.name, path: caseInfo.path };
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const selectedItem = getSelectedItem();
|
||||
|
||||
// Render new case input mode
|
||||
if (mode === 'new-case') {
|
||||
return (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
<Box
|
||||
borderStyle="double"
|
||||
borderColor="cyan"
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
justifyContent="center"
|
||||
>
|
||||
<Text bold color="cyan">
|
||||
Create New Case
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginY={1} flexDirection="column">
|
||||
<Text>Enter case name (letters, numbers, hyphens, underscores):</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text color="green">> </Text>
|
||||
<TextInput
|
||||
value={newCaseName}
|
||||
onChange={setNewCaseName}
|
||||
onSubmit={handleNewCaseSubmit}
|
||||
/>
|
||||
</Box>
|
||||
{error && (
|
||||
<Box marginTop={1}>
|
||||
<Text color="red">{error}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box marginTop={2} borderStyle="single" borderColor="gray" paddingX={1}>
|
||||
<Text>
|
||||
<Text color="green">[Enter]</Text>
|
||||
<Text> Create </Text>
|
||||
<Text color="yellow">[Esc]</Text>
|
||||
<Text> Cancel</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Render multi-start input mode
|
||||
if (mode === 'multi-start') {
|
||||
const selectedCase = cases[selectedIndex];
|
||||
return (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
<Box
|
||||
borderStyle="double"
|
||||
borderColor="cyan"
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
justifyContent="center"
|
||||
>
|
||||
<Text bold color="cyan">
|
||||
Start Multiple Sessions
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Show selected case */}
|
||||
<Box marginY={1} borderStyle="single" borderColor="green" paddingX={2}>
|
||||
<Text>
|
||||
<Text color="green" bold>Selected Case: </Text>
|
||||
<Text bold color="white">{selectedCase?.name || 'none'}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginY={1} flexDirection="column">
|
||||
<Text>How many sessions to start? (1-20):</Text>
|
||||
<Box marginTop={1}>
|
||||
<Text color="green">> </Text>
|
||||
<TextInput
|
||||
value={multiStartCount}
|
||||
onChange={setMultiStartCount}
|
||||
onSubmit={handleMultiStartSubmit}
|
||||
/>
|
||||
</Box>
|
||||
{error && (
|
||||
<Box marginTop={1}>
|
||||
<Text color="red">{error}</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Box marginTop={2} borderStyle="single" borderColor="gray" paddingX={1}>
|
||||
<Text>
|
||||
<Text color="green">[Enter]</Text>
|
||||
<Text> Start Sessions </Text>
|
||||
<Text color="yellow">[Esc]</Text>
|
||||
<Text> Cancel</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Get the default case to display (lastUsedCase or first case or 'testcase')
|
||||
const defaultCase = lastUsedCase || (cases.length > 0 ? cases[0].name : 'testcase');
|
||||
const defaultCasePath = cases.find(c => c.name === defaultCase)?.path;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
{/* Header with current case */}
|
||||
<Box
|
||||
borderStyle="double"
|
||||
borderColor="cyan"
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<Text bold color="cyan">
|
||||
Claudeman TUI
|
||||
</Text>
|
||||
<Text>
|
||||
<Text color="yellow">Case: </Text>
|
||||
<Text bold color="white">{defaultCase}</Text>
|
||||
{defaultCasePath && (
|
||||
<Text dimColor> ({defaultCasePath.replace(process.env.HOME || '', '~')})</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Mode tabs */}
|
||||
<Box marginY={1}>
|
||||
<Text>
|
||||
<Text
|
||||
backgroundColor={mode === 'sessions' ? 'blue' : undefined}
|
||||
color={mode === 'sessions' ? 'white' : 'gray'}
|
||||
bold={mode === 'sessions'}
|
||||
>
|
||||
{' [s] Sessions '}
|
||||
</Text>
|
||||
<Text> </Text>
|
||||
<Text
|
||||
backgroundColor={mode === 'cases' ? 'blue' : undefined}
|
||||
color={mode === 'cases' ? 'white' : 'gray'}
|
||||
bold={mode === 'cases'}
|
||||
>
|
||||
{' [c] Cases '}
|
||||
</Text>
|
||||
<Text dimColor> | Press ? for help</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Selected item display */}
|
||||
{selectedItem && (
|
||||
<Box marginBottom={1} borderStyle="single" borderColor="green" paddingX={2}>
|
||||
<Text>
|
||||
<Text color="green" bold>Selected: </Text>
|
||||
<Text bold color="white">{selectedItem.name}</Text>
|
||||
{selectedItem.path && (
|
||||
<Text dimColor> ({selectedItem.path.replace(process.env.HOME || '', '~')})</Text>
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Sessions view */}
|
||||
{mode === 'sessions' && (
|
||||
<>
|
||||
{sessions.length === 0 ? (
|
||||
<Box flexDirection="column" marginY={1}>
|
||||
<Text color="yellow">No sessions found</Text>
|
||||
<Text dimColor>Press [n] to create a new session, or [c] to select a case</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Box flexDirection="column">
|
||||
{/* Table header */}
|
||||
<Box marginBottom={1}>
|
||||
<Text bold>
|
||||
<Text color="gray">{' '}</Text>
|
||||
<Text>{'NAME'.padEnd(22)}</Text>
|
||||
<Text>{'RUNTIME'.padEnd(12)}</Text>
|
||||
<Text>{'STATUS'.padEnd(10)}</Text>
|
||||
<Text>{'MODE'.padEnd(10)}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Session rows */}
|
||||
{sessions.map((session, index) => {
|
||||
const runtime = formatDuration(now - session.createdAt);
|
||||
const statusColor = session.attached ? 'green' : 'red';
|
||||
const statusIcon = session.attached ? '\u25CF' : '\u25CB';
|
||||
const statusText = session.attached ? 'alive' : 'dead';
|
||||
const name = getSessionName(session).slice(0, 20);
|
||||
const isSelected = index === selectedIndex;
|
||||
|
||||
return (
|
||||
<Box key={session.sessionId}>
|
||||
{isSelected ? (
|
||||
<Text backgroundColor="blue" color="white">
|
||||
<Text color="cyan" bold>{' \u25B6 '}</Text>
|
||||
<Text bold>{name.padEnd(22)}</Text>
|
||||
<Text>{runtime.padEnd(12)}</Text>
|
||||
<Text color={statusColor}>
|
||||
{statusIcon} {statusText.padEnd(8)}
|
||||
</Text>
|
||||
<Text>{session.mode.padEnd(10)}</Text>
|
||||
</Text>
|
||||
) : (
|
||||
<Text>
|
||||
<Text color="gray">{' '}</Text>
|
||||
<Text>{name.padEnd(22)}</Text>
|
||||
<Text dimColor>{runtime.padEnd(12)}</Text>
|
||||
<Text color={statusColor}>
|
||||
{statusIcon} {statusText.padEnd(8)}
|
||||
</Text>
|
||||
<Text>{session.mode.padEnd(10)}</Text>
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Sessions footer */}
|
||||
<Box marginTop={2} flexDirection="column">
|
||||
<Box borderStyle="single" borderColor="gray" paddingX={1}>
|
||||
<Text>
|
||||
<Text color="green">[n]</Text>
|
||||
<Text> New </Text>
|
||||
<Text color="green">[c]</Text>
|
||||
<Text> Cases </Text>
|
||||
<Text color="green">[{'\u2191\u2193'}]</Text>
|
||||
<Text> Navigate </Text>
|
||||
<Text color="green">[Enter]</Text>
|
||||
<Text> View </Text>
|
||||
<Text color="green">[a]</Text>
|
||||
<Text> Attach </Text>
|
||||
<Text color="red">[d]</Text>
|
||||
<Text> Delete </Text>
|
||||
<Text color="red" bold>[D]</Text>
|
||||
<Text> Delete ALL </Text>
|
||||
<Text color="yellow">[q]</Text>
|
||||
<Text> Quit</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Cases view */}
|
||||
{mode === 'cases' && (
|
||||
<>
|
||||
{cases.length === 0 ? (
|
||||
<Box flexDirection="column" marginY={1}>
|
||||
<Text color="yellow">No cases found</Text>
|
||||
<Text dimColor>Press [n] to create a new case</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Box flexDirection="column">
|
||||
{/* Table header */}
|
||||
<Box marginBottom={1}>
|
||||
<Text bold>
|
||||
<Text color="gray">{' '}</Text>
|
||||
<Text>{'CASE NAME'.padEnd(30)}</Text>
|
||||
<Text>{'PATH'.padEnd(40)}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Case rows */}
|
||||
{cases.map((caseInfo, index) => {
|
||||
const isSelected = index === selectedIndex;
|
||||
const name = caseInfo.name.slice(0, 28);
|
||||
const path = caseInfo.path.replace(process.env.HOME || '', '~').slice(0, 38);
|
||||
|
||||
return (
|
||||
<Box key={caseInfo.name}>
|
||||
{isSelected ? (
|
||||
<Text backgroundColor="blue" color="white">
|
||||
<Text color="cyan" bold>{' \u25B6 '}</Text>
|
||||
<Text bold>{name.padEnd(30)}</Text>
|
||||
<Text>{path.padEnd(40)}</Text>
|
||||
</Text>
|
||||
) : (
|
||||
<Text>
|
||||
<Text color="gray">{' '}</Text>
|
||||
<Text>{name.padEnd(30)}</Text>
|
||||
<Text dimColor>{path.padEnd(40)}</Text>
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Cases footer */}
|
||||
<Box marginTop={2} flexDirection="column">
|
||||
<Box borderStyle="single" borderColor="gray" paddingX={1}>
|
||||
<Text>
|
||||
<Text color="green">[Enter]</Text>
|
||||
<Text> Claude </Text>
|
||||
<Text color="yellow">[h]</Text>
|
||||
<Text> Shell </Text>
|
||||
<Text color="cyan">[m]</Text>
|
||||
<Text> Multi (1-20) </Text>
|
||||
<Text color="green">[n]</Text>
|
||||
<Text> New Case </Text>
|
||||
<Text color="green">[s]</Text>
|
||||
<Text> Sessions </Text>
|
||||
<Text color="green">[r]</Text>
|
||||
<Text> Refresh </Text>
|
||||
<Text color="yellow">[q]</Text>
|
||||
<Text> Quit</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
/**
|
||||
* @fileoverview StatusBar component
|
||||
*
|
||||
* Bottom status bar providing session information and navigation hints.
|
||||
*
|
||||
* @description
|
||||
* Displays real-time session metrics:
|
||||
* - Session name and connection status (alive/dead)
|
||||
* - Runtime duration since session creation
|
||||
* - Session mode (claude/shell)
|
||||
* - Respawn controller status when enabled
|
||||
* - Keyboard shortcut hints for quick reference
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { ScreenSession } from '../../types.js';
|
||||
|
||||
interface RespawnStatus {
|
||||
enabled: boolean;
|
||||
state: string;
|
||||
cycleCount: number;
|
||||
}
|
||||
|
||||
interface StatusBarProps {
|
||||
session: ScreenSession | null;
|
||||
inputMode?: boolean;
|
||||
respawnStatus?: RespawnStatus | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a duration from milliseconds to a human-readable string.
|
||||
*
|
||||
* @param ms - Duration in milliseconds
|
||||
* @returns Formatted string like "45s", "5m 30s", or "2h 15m"
|
||||
*/
|
||||
function formatDuration(ms: number): string {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes % 60}m`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m ${seconds % 60}s`;
|
||||
}
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Status bar component displaying session information and keyboard hints.
|
||||
*
|
||||
* @description
|
||||
* Renders a bordered bar at the bottom of the TUI with:
|
||||
* - Left side: Session name, status indicator, runtime, mode, respawn state
|
||||
* - Right side: Quick keyboard shortcut reference
|
||||
*
|
||||
* When no session is selected, displays a minimal bar with navigation hints.
|
||||
*
|
||||
* @param props - Component props
|
||||
* @param props.session - The currently active session or null
|
||||
* @param props.inputMode - Whether input mode is active (shows yellow indicator)
|
||||
* @param props.respawnStatus - Respawn controller status if enabled
|
||||
* @returns The status bar element
|
||||
*/
|
||||
export function StatusBar({ session, inputMode = false, respawnStatus }: StatusBarProps): React.ReactElement {
|
||||
if (!session) {
|
||||
return (
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderColor="gray"
|
||||
paddingX={1}
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<Text dimColor>No session selected</Text>
|
||||
<Text dimColor>Press ? for help | Esc to go back</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const runtime = formatDuration(Date.now() - session.createdAt);
|
||||
const statusColor = session.attached ? 'green' : 'red';
|
||||
const statusText = session.attached ? 'alive' : 'dead';
|
||||
const sessionName = session.name || 'unnamed';
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderColor="cyan"
|
||||
paddingX={1}
|
||||
justifyContent="space-between"
|
||||
>
|
||||
{/* Left side: Session info */}
|
||||
<Box>
|
||||
<Text color="cyan" bold>
|
||||
{sessionName}
|
||||
</Text>
|
||||
<Text> | </Text>
|
||||
<Text color={statusColor} bold>
|
||||
{'\u25CF'} {statusText}
|
||||
</Text>
|
||||
<Text> | </Text>
|
||||
<Text>
|
||||
<Text dimColor>runtime:</Text> {runtime}
|
||||
</Text>
|
||||
<Text> | </Text>
|
||||
<Text>
|
||||
<Text dimColor>mode:</Text> {session.mode}
|
||||
</Text>
|
||||
{inputMode && (
|
||||
<>
|
||||
<Text> | </Text>
|
||||
<Text color="yellow" bold>INPUT MODE</Text>
|
||||
</>
|
||||
)}
|
||||
{respawnStatus?.enabled && (
|
||||
<>
|
||||
<Text> | </Text>
|
||||
<Text color="magenta">
|
||||
respawn: {respawnStatus.state.replace(/_/g, ' ')}
|
||||
{respawnStatus.cycleCount > 0 && ` (${respawnStatus.cycleCount})`}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Right side: Keyboard hints */}
|
||||
<Box>
|
||||
<Text dimColor>? help | Esc back | type to send input</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
/**
|
||||
* @fileoverview TabBar component
|
||||
*
|
||||
* Horizontal tab bar for session navigation in the TUI.
|
||||
*
|
||||
* @description
|
||||
* Provides browser-like tab navigation for Claude sessions:
|
||||
* - Active tab highlighted with blue background
|
||||
* - Status indicators: green dot (alive) or red dot (dead)
|
||||
* - Session names truncated to 15 characters
|
||||
* - Keyboard shortcut hints on the right
|
||||
*
|
||||
* Tab switching is handled by parent via keyboard shortcuts (Ctrl+Tab).
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { ScreenSession } from '../../types.js';
|
||||
|
||||
interface TabBarProps {
|
||||
sessions: ScreenSession[];
|
||||
activeSessionId: string | null;
|
||||
onSelectSession: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tab bar component showing all available sessions.
|
||||
*
|
||||
* @description
|
||||
* Renders a horizontal bar with one tab per session, showing:
|
||||
* - Status indicator (filled/hollow circle for alive/dead)
|
||||
* - Session name (truncated to 15 chars)
|
||||
* - Visual highlighting for the active session
|
||||
*
|
||||
* @param props - Component props
|
||||
* @param props.sessions - Array of all sessions to display as tabs
|
||||
* @param props.activeSessionId - ID of currently active session for highlighting
|
||||
* @param props.onSelectSession - Callback when a tab is clicked (unused, keyboard nav only)
|
||||
* @returns The tab bar element
|
||||
*/
|
||||
export function TabBar({
|
||||
sessions,
|
||||
activeSessionId,
|
||||
}: TabBarProps): React.ReactElement {
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<Box borderStyle="single" borderColor="gray" paddingX={1}>
|
||||
<Text dimColor>No sessions</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box borderStyle="single" borderColor="cyan" paddingX={1}>
|
||||
{sessions.map((session, index) => {
|
||||
const isActive = session.sessionId === activeSessionId;
|
||||
const name = (session.name || 'unnamed').slice(0, 15);
|
||||
// Status indicator: filled circle for alive, hollow for dead
|
||||
const statusIcon = session.attached ? '\u25CF' : '\u25CB';
|
||||
const statusColor = session.attached ? 'green' : 'red';
|
||||
|
||||
return (
|
||||
<Box key={session.sessionId} marginRight={1}>
|
||||
{isActive ? (
|
||||
<Text backgroundColor="blue" color="white" bold>
|
||||
{' '}
|
||||
<Text color={statusColor}>{statusIcon}</Text> {name}{' '}
|
||||
</Text>
|
||||
) : (
|
||||
<Text>
|
||||
{' '}
|
||||
<Text color={statusColor}>{statusIcon}</Text>{' '}
|
||||
<Text dimColor>{name}</Text>{' '}
|
||||
</Text>
|
||||
)}
|
||||
{index < sessions.length - 1 && <Text dimColor>|</Text>}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Shortcut hint */}
|
||||
<Box flexGrow={1} justifyContent="flex-end">
|
||||
<Text dimColor>Ctrl+Tab: switch | Ctrl+W: close</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
/**
|
||||
* @fileoverview TerminalView component
|
||||
*
|
||||
* Primary terminal output display for Claude session monitoring.
|
||||
*
|
||||
* @description
|
||||
* Renders the terminal output from a GNU screen session:
|
||||
* - Viewport shows last N lines based on available height
|
||||
* - Line splitting is memoized for performance
|
||||
* - Visual border color indicates session state (green=active, gray=none)
|
||||
* - Long lines are truncated to prevent wrapping issues
|
||||
*
|
||||
* Output is obtained via screen hardcopy polling in useSessionManager.
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { ScreenSession } from '../../types.js';
|
||||
|
||||
interface TerminalViewProps {
|
||||
output: string;
|
||||
height: number;
|
||||
session: ScreenSession | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Terminal view component for displaying session output.
|
||||
*
|
||||
* @description
|
||||
* Shows the last N lines of terminal output that fit within the given height.
|
||||
* When no session is selected, displays a placeholder message.
|
||||
*
|
||||
* @param props - Component props
|
||||
* @param props.output - Raw terminal output string from screen hardcopy
|
||||
* @param props.height - Available height in terminal rows (includes border)
|
||||
* @param props.session - The active session or null
|
||||
* @returns The terminal view element
|
||||
*/
|
||||
export function TerminalView({
|
||||
output,
|
||||
height,
|
||||
session,
|
||||
}: TerminalViewProps): React.ReactElement {
|
||||
// Split output into lines and get the last N lines that fit
|
||||
const displayLines = useMemo(() => {
|
||||
if (!output) return [];
|
||||
|
||||
const lines = output.split('\n');
|
||||
// Reserve 2 lines for border
|
||||
const visibleLines = height - 2;
|
||||
|
||||
if (lines.length <= visibleLines) {
|
||||
return lines;
|
||||
}
|
||||
|
||||
// Return last N lines
|
||||
return lines.slice(-visibleLines);
|
||||
}, [output, height]);
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="single"
|
||||
borderColor="gray"
|
||||
height={height}
|
||||
paddingX={1}
|
||||
>
|
||||
<Box flexGrow={1} alignItems="center" justifyContent="center">
|
||||
<Text dimColor>No session selected</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="single"
|
||||
borderColor="green"
|
||||
height={height}
|
||||
paddingX={1}
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* Terminal content */}
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
{displayLines.length === 0 ? (
|
||||
<Text dimColor>Waiting for output...</Text>
|
||||
) : (
|
||||
displayLines.map((line, index) => (
|
||||
<Text key={index} wrap="truncate">
|
||||
{line}
|
||||
</Text>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -1,5 +0,0 @@
|
||||
/**
|
||||
* @fileoverview Hook exports for TUI
|
||||
*/
|
||||
|
||||
export { useSessionManager } from './useSessionManager.js';
|
||||
@@ -1,831 +0,0 @@
|
||||
/**
|
||||
* @fileoverview useSessionManager hook
|
||||
*
|
||||
* Core state management hook for the TUI application.
|
||||
*
|
||||
* @description
|
||||
* This hook provides a complete interface for managing Claude sessions:
|
||||
* - Session discovery from ~/.claudeman/screens.json
|
||||
* - Terminal output polling via GNU screen hardcopy
|
||||
* - Ralph state tracking for Ralph Wiggum loops
|
||||
* - Respawn status monitoring via the Claudeman API
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const {
|
||||
* sessions,
|
||||
* activeSession,
|
||||
* terminalOutput,
|
||||
* selectSession,
|
||||
* createSession,
|
||||
* sendInput,
|
||||
* } = useSessionManager();
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { existsSync, readFileSync, watchFile, unwatchFile, unlinkSync } from 'fs';
|
||||
import { homedir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { execSync, exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import type { ScreenSession, RalphTrackerState, RalphTodoItem, RalphSessionState } from '../../types.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const SCREENS_FILE = join(homedir(), '.claudeman', 'screens.json');
|
||||
const RALPH_STATE_FILE = join(homedir(), '.claudeman', 'state-inner.json');
|
||||
const SETTINGS_FILE = join(homedir(), '.claudeman', 'settings.json');
|
||||
const OUTPUT_POLL_INTERVAL = 300; // Poll terminal output every 300ms (faster refresh)
|
||||
|
||||
/**
|
||||
* Emoji to ASCII replacement map for screen hardcopy output.
|
||||
* GNU screen's hardcopy doesn't handle UTF-8 multi-byte characters well,
|
||||
* so we replace common Claude Code emoji with ASCII equivalents.
|
||||
*/
|
||||
const EMOJI_REPLACEMENTS: [RegExp, string][] = [
|
||||
// Claude Code logo/branding
|
||||
[/\u{1F9E0}/gu, '*'], // 🧠 brain -> *
|
||||
[/\u{2728}/gu, '*'], // ✨ sparkles -> *
|
||||
[/\u{1F4AC}/gu, '>'], // 💬 speech bubble -> >
|
||||
[/\u{1F916}/gu, '[bot]'], // 🤖 robot -> [bot]
|
||||
|
||||
// Status indicators
|
||||
[/\u{2714}/gu, '[ok]'], // ✔ check mark -> [ok]
|
||||
[/\u{2705}/gu, '[ok]'], // ✅ check mark button -> [ok]
|
||||
[/\u{274C}/gu, '[x]'], // ❌ cross mark -> [x]
|
||||
[/\u{26A0}/gu, '[!]'], // ⚠ warning -> [!]
|
||||
[/\u{2139}/gu, '[i]'], // ℹ info -> [i]
|
||||
[/\u{1F6A8}/gu, '[!]'], // 🚨 rotating light -> [!]
|
||||
|
||||
// Progress/activity
|
||||
[/\u{23F3}/gu, '...'], // ⏳ hourglass -> ...
|
||||
[/\u{231B}/gu, '...'], // ⌛ hourglass done -> ...
|
||||
[/\u{1F504}/gu, '(...)'], // 🔄 refresh -> (...)
|
||||
[/\u{25B6}/gu, '>'], // ▶ play -> >
|
||||
[/\u{23F8}/gu, '||'], // ⏸ pause -> ||
|
||||
[/\u{23F9}/gu, '[]'], // ⏹ stop -> []
|
||||
|
||||
// File/folder icons
|
||||
[/\u{1F4C1}/gu, '[dir]'], // 📁 folder -> [dir]
|
||||
[/\u{1F4C2}/gu, '[dir]'], // 📂 open folder -> [dir]
|
||||
[/\u{1F4C4}/gu, '[file]'], // 📄 file -> [file]
|
||||
[/\u{1F4DD}/gu, '[edit]'], // 📝 memo -> [edit]
|
||||
|
||||
// Arrows and navigation
|
||||
[/\u{2190}/gu, '<-'], // ← left arrow
|
||||
[/\u{2192}/gu, '->'], // → right arrow
|
||||
[/\u{2191}/gu, '^'], // ↑ up arrow
|
||||
[/\u{2193}/gu, 'v'], // ↓ down arrow
|
||||
[/\u{21B5}/gu, '<CR>'], // ↵ return symbol -> <CR>
|
||||
[/\u{23CE}/gu, '<CR>'], // ⏎ return symbol -> <CR>
|
||||
|
||||
// Special Unicode box-drawing and symbols that may corrupt
|
||||
[/\u{25B8}/gu, '>'], // ▸ small right triangle
|
||||
[/\u{25B9}/gu, '>'], // ▹ white small right triangle
|
||||
[/\u{2022}/gu, '-'], // • bullet -> -
|
||||
[/\u{25CF}/gu, 'o'], // ● black circle -> o
|
||||
[/\u{25CB}/gu, 'o'], // ○ white circle -> o
|
||||
[/\u{25A0}/gu, '#'], // ■ black square -> #
|
||||
[/\u{25A1}/gu, '[]'], // □ white square -> []
|
||||
[/\u{2261}/gu, '='], // ≡ hamburger menu -> =
|
||||
|
||||
// Misc
|
||||
[/\u{1F512}/gu, '[lock]'], // 🔒 lock
|
||||
[/\u{1F513}/gu, '[unlock]'], // 🔓 unlock
|
||||
[/\u{1F527}/gu, '[tool]'], // 🔧 wrench
|
||||
[/\u{2699}/gu, '[gear]'], // ⚙ gear
|
||||
[/\u{1F50D}/gu, '[search]'], // 🔍 magnifying glass
|
||||
[/\u{1F4E6}/gu, '[pkg]'], // 📦 package
|
||||
[/\u{1F680}/gu, '[>]'], // 🚀 rocket
|
||||
[/\u{1F3AF}/gu, '[*]'], // 🎯 target
|
||||
|
||||
// Catch-all for any remaining emoji in common ranges
|
||||
// These will appear as replacement characters otherwise
|
||||
[/[\u{1F300}-\u{1F9FF}]/gu, ''], // Misc symbols and pictographs
|
||||
[/[\u{2600}-\u{26FF}]/gu, ''], // Misc symbols
|
||||
[/[\u{FE00}-\u{FE0F}]/gu, ''], // Variation selectors
|
||||
];
|
||||
|
||||
/**
|
||||
* Sanitizes screen hardcopy output by replacing emoji with ASCII equivalents.
|
||||
*
|
||||
* @param content - Raw hardcopy output that may contain corrupted UTF-8
|
||||
* @returns Sanitized string with emoji replaced by ASCII
|
||||
*/
|
||||
function sanitizeHardcopyOutput(content: string): string {
|
||||
let result = content;
|
||||
|
||||
// Apply all emoji replacements
|
||||
for (const [pattern, replacement] of EMOJI_REPLACEMENTS) {
|
||||
result = result.replace(pattern, replacement);
|
||||
}
|
||||
|
||||
// Remove any remaining replacement characters (U+FFFD) that indicate
|
||||
// encoding issues with multi-byte sequences
|
||||
result = result.replace(/\uFFFD+/g, '');
|
||||
|
||||
// Remove any other non-printable characters except common whitespace
|
||||
// This catches any remaining problematic bytes
|
||||
result = result.replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '');
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
interface RespawnStatus {
|
||||
enabled: boolean;
|
||||
state: string;
|
||||
cycleCount: number;
|
||||
}
|
||||
|
||||
interface CaseInfo {
|
||||
name: string;
|
||||
path: string;
|
||||
hasClaudeMd: boolean;
|
||||
}
|
||||
|
||||
interface SessionManagerState {
|
||||
sessions: ScreenSession[];
|
||||
activeSessionId: string | null;
|
||||
activeSession: ScreenSession | null;
|
||||
terminalOutput: string;
|
||||
ralphLoopState: RalphTrackerState | null;
|
||||
ralphTodos: RalphTodoItem[];
|
||||
respawnStatus: RespawnStatus | null;
|
||||
cases: CaseInfo[];
|
||||
lastUsedCase: string | null;
|
||||
refreshSessions: () => void;
|
||||
refreshCases: () => Promise<void>;
|
||||
selectSession: (sessionId: string) => void;
|
||||
createSession: (caseName?: string, mode?: 'claude' | 'shell') => Promise<string | null>;
|
||||
createCase: (name: string) => Promise<boolean>;
|
||||
killSession: (sessionId: string) => void;
|
||||
killAllSessions: () => void;
|
||||
killAllScreensAndClaude: () => void;
|
||||
nextSession: () => void;
|
||||
prevSession: () => void;
|
||||
sendInput: (sessionId: string, input: string) => void;
|
||||
toggleRespawn: () => Promise<boolean>;
|
||||
renameSession: (sessionId: string, name: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
// Cache for screen -ls output to avoid repeated execSync calls
|
||||
let screenListCache = '';
|
||||
let screenListCacheTime = 0;
|
||||
const SCREEN_CACHE_TTL = 100; // ms
|
||||
|
||||
/**
|
||||
* Checks if a GNU screen session is currently running.
|
||||
* Uses a 100ms cache to avoid repeated execSync calls when checking multiple sessions.
|
||||
*
|
||||
* @param screenName - The name of the screen session to check
|
||||
* @returns true if the session exists and is alive, false otherwise
|
||||
*/
|
||||
function isScreenAlive(screenName: string): boolean {
|
||||
const now = Date.now();
|
||||
|
||||
// Use cached result if fresh enough
|
||||
if (now - screenListCacheTime > SCREEN_CACHE_TTL) {
|
||||
try {
|
||||
screenListCache = execSync('screen -ls', {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000, // 5 second timeout to prevent hang
|
||||
});
|
||||
screenListCacheTime = now;
|
||||
} catch {
|
||||
screenListCache = '';
|
||||
screenListCacheTime = now;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return screenListCache.includes(screenName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads all sessions from the Claudeman screens registry.
|
||||
*
|
||||
* @description
|
||||
* Reads ~/.claudeman/screens.json and enriches each session with
|
||||
* its current alive/dead status by checking GNU screen.
|
||||
* Dead sessions are filtered out - only alive sessions are returned.
|
||||
*
|
||||
* @returns Array of alive screen sessions
|
||||
*/
|
||||
function loadSessions(): ScreenSession[] {
|
||||
try {
|
||||
if (!existsSync(SCREENS_FILE)) {
|
||||
return [];
|
||||
}
|
||||
const data = readFileSync(SCREENS_FILE, 'utf-8');
|
||||
const sessions: ScreenSession[] = JSON.parse(data);
|
||||
|
||||
// Check which sessions are alive and filter out dead ones
|
||||
return sessions
|
||||
.map((session) => ({
|
||||
...session,
|
||||
attached: isScreenAlive(session.screenName),
|
||||
}))
|
||||
.filter((session) => session.attached);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads Ralph Wiggum loop state for a specific session.
|
||||
*
|
||||
* @description
|
||||
* Reads ~/.claudeman/state-inner.json which contains per-session
|
||||
* tracking of Ralph loops, todos, and completion phrases.
|
||||
*
|
||||
* @param sessionId - The UUID of the session to load state for
|
||||
* @returns The Ralph session state or null if not found
|
||||
*/
|
||||
function loadRalphState(sessionId: string): RalphSessionState | null {
|
||||
try {
|
||||
if (!existsSync(RALPH_STATE_FILE)) {
|
||||
return null;
|
||||
}
|
||||
const data = readFileSync(RALPH_STATE_FILE, 'utf-8');
|
||||
const allStates = JSON.parse(data) as Record<string, RalphSessionState>;
|
||||
return allStates[sessionId] || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the last used case name from settings.
|
||||
*
|
||||
* @description
|
||||
* Reads ~/.claudeman/settings.json and returns the lastUsedCase field.
|
||||
* This is used to default the case selection in the TUI to match the web GUI.
|
||||
*
|
||||
* @returns The last used case name or null if not found
|
||||
*/
|
||||
function loadLastUsedCase(): string | null {
|
||||
try {
|
||||
if (!existsSync(SETTINGS_FILE)) {
|
||||
return null;
|
||||
}
|
||||
const data = readFileSync(SETTINGS_FILE, 'utf-8');
|
||||
const settings = JSON.parse(data) as { lastUsedCase?: string };
|
||||
return settings.lastUsedCase || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook for managing Claude sessions in the TUI.
|
||||
*
|
||||
* @description
|
||||
* Provides complete session lifecycle management:
|
||||
* - Automatic session discovery and status monitoring
|
||||
* - Terminal output polling (500ms interval)
|
||||
* - Ralph state tracking (500ms interval)
|
||||
* - Respawn status polling via API (2000ms interval)
|
||||
* - Session CRUD operations via Claudeman API
|
||||
*
|
||||
* @returns SessionManagerState object with session data and control methods
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function MyComponent() {
|
||||
* const { sessions, selectSession, sendInput } = useSessionManager();
|
||||
*
|
||||
* return (
|
||||
* <Box>
|
||||
* {sessions.map(s => (
|
||||
* <Text key={s.sessionId} onClick={() => selectSession(s.sessionId)}>
|
||||
* {s.name}
|
||||
* </Text>
|
||||
* ))}
|
||||
* </Box>
|
||||
* );
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useSessionManager(): SessionManagerState {
|
||||
const [sessions, setSessions] = useState<ScreenSession[]>([]);
|
||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
||||
const [terminalOutput, setTerminalOutput] = useState<string>('');
|
||||
const [ralphLoopState, setRalphTrackerState] = useState<RalphTrackerState | null>(null);
|
||||
const [ralphTodos, setInnerTodos] = useState<RalphTodoItem[]>([]);
|
||||
const [respawnStatus, setRespawnStatus] = useState<RespawnStatus | null>(null);
|
||||
const [cases, setCases] = useState<CaseInfo[]>([]);
|
||||
const [lastUsedCase, setLastUsedCase] = useState<string | null>(loadLastUsedCase);
|
||||
const outputBufferRef = useRef<string>('');
|
||||
|
||||
// Load sessions on mount and watch for changes
|
||||
useEffect(() => {
|
||||
// Initial load
|
||||
setSessions(loadSessions());
|
||||
|
||||
// Watch for changes
|
||||
const handleChange = () => {
|
||||
setSessions(loadSessions());
|
||||
};
|
||||
|
||||
try {
|
||||
watchFile(SCREENS_FILE, { interval: 1000 }, handleChange);
|
||||
} catch {
|
||||
// File may not exist yet
|
||||
}
|
||||
|
||||
return () => {
|
||||
try {
|
||||
unwatchFile(SCREENS_FILE, handleChange);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Get active session
|
||||
const activeSession = sessions.find((s) => s.sessionId === activeSessionId) || null;
|
||||
|
||||
// Poll Ralph state for active session
|
||||
useEffect(() => {
|
||||
if (!activeSessionId) {
|
||||
setRalphTrackerState(null);
|
||||
setInnerTodos([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const pollRalphState = () => {
|
||||
const state = loadRalphState(activeSessionId);
|
||||
if (state) {
|
||||
setRalphTrackerState(state.loop);
|
||||
setInnerTodos(state.todos);
|
||||
} else {
|
||||
setRalphTrackerState(null);
|
||||
setInnerTodos([]);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial poll
|
||||
pollRalphState();
|
||||
|
||||
// Set up polling interval (same as output polling)
|
||||
const intervalId = setInterval(pollRalphState, OUTPUT_POLL_INTERVAL);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [activeSessionId]);
|
||||
|
||||
// Poll respawn status via API (when server is running)
|
||||
useEffect(() => {
|
||||
if (!activeSessionId) {
|
||||
setRespawnStatus(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const pollRespawnStatus = async () => {
|
||||
try {
|
||||
const response = await fetch(`http://localhost:3000/api/sessions/${activeSessionId}`);
|
||||
if (response.ok) {
|
||||
const data = await response.json() as {
|
||||
success?: boolean;
|
||||
session?: {
|
||||
respawnEnabled?: boolean;
|
||||
respawnState?: string;
|
||||
respawnCycleCount?: number;
|
||||
};
|
||||
};
|
||||
if (data.success && data.session) {
|
||||
setRespawnStatus({
|
||||
enabled: data.session.respawnEnabled || false,
|
||||
state: data.session.respawnState || 'stopped',
|
||||
cycleCount: data.session.respawnCycleCount || 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Server not running, that's ok
|
||||
setRespawnStatus(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Poll less frequently than output (every 2 seconds)
|
||||
pollRespawnStatus();
|
||||
const intervalId = setInterval(pollRespawnStatus, 2000);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [activeSessionId]);
|
||||
|
||||
// Poll terminal output for active session (async for non-blocking)
|
||||
useEffect(() => {
|
||||
if (!activeSessionId || !activeSession) return;
|
||||
|
||||
let isMounted = true;
|
||||
let pollTimeoutId: NodeJS.Timeout | null = null;
|
||||
|
||||
const pollOutput = async () => {
|
||||
if (!isMounted || !isScreenAlive(activeSession.screenName)) return;
|
||||
|
||||
try {
|
||||
const hardcopyFile = `/tmp/claudeman-${activeSessionId}-hardcopy`;
|
||||
// Use screen with UTF-8 mode (-U) for proper character handling (async)
|
||||
await execAsync(`screen -U -S ${activeSession.screenName} -X hardcopy ${hardcopyFile}`, {
|
||||
timeout: 1000,
|
||||
env: {
|
||||
...process.env,
|
||||
LANG: process.env.LANG || 'en_US.UTF-8',
|
||||
LC_ALL: process.env.LC_ALL || 'en_US.UTF-8',
|
||||
}
|
||||
});
|
||||
if (isMounted && existsSync(hardcopyFile)) {
|
||||
const rawContent = readFileSync(hardcopyFile, 'utf-8');
|
||||
// Sanitize emoji/unicode that screen hardcopy corrupts
|
||||
const content = sanitizeHardcopyOutput(rawContent);
|
||||
// Only update if content changed
|
||||
if (content !== outputBufferRef.current) {
|
||||
outputBufferRef.current = content;
|
||||
setTerminalOutput(content);
|
||||
}
|
||||
// Clean up temp file
|
||||
try {
|
||||
unlinkSync(hardcopyFile);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Hardcopy may fail, that's ok
|
||||
}
|
||||
|
||||
// Schedule next poll (using timeout instead of interval for async)
|
||||
if (isMounted) {
|
||||
pollTimeoutId = setTimeout(pollOutput, OUTPUT_POLL_INTERVAL);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial poll
|
||||
pollOutput();
|
||||
|
||||
return () => {
|
||||
isMounted = false;
|
||||
if (pollTimeoutId) {
|
||||
clearTimeout(pollTimeoutId);
|
||||
}
|
||||
};
|
||||
}, [activeSessionId, activeSession]);
|
||||
|
||||
// Refresh sessions manually
|
||||
const refreshSessions = useCallback(() => {
|
||||
setSessions(loadSessions());
|
||||
}, []);
|
||||
|
||||
// Refresh cases from API and reload lastUsedCase from settings
|
||||
const refreshCases = useCallback(async () => {
|
||||
// Always reload lastUsedCase from settings
|
||||
setLastUsedCase(loadLastUsedCase());
|
||||
|
||||
try {
|
||||
const response = await fetch('http://localhost:3000/api/cases');
|
||||
if (response.ok) {
|
||||
const data = await response.json() as CaseInfo[];
|
||||
setCases(data);
|
||||
}
|
||||
} catch {
|
||||
// Server not running, clear cases
|
||||
setCases([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load cases on mount
|
||||
useEffect(() => {
|
||||
refreshCases();
|
||||
}, [refreshCases]);
|
||||
|
||||
// Select a session
|
||||
const selectSession = useCallback((sessionId: string) => {
|
||||
setActiveSessionId(sessionId);
|
||||
setTerminalOutput('');
|
||||
outputBufferRef.current = '';
|
||||
}, []);
|
||||
|
||||
// Create a new case
|
||||
const createCase = useCallback(async (name: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch('http://localhost:3000/api/cases', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json() as { success?: boolean };
|
||||
if (data.success) {
|
||||
await refreshCases();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, [refreshCases]);
|
||||
|
||||
// Create new session with proper naming (w1-casename, w2-casename, etc.)
|
||||
const createSession = useCallback(async (caseName?: string, mode: 'claude' | 'shell' = 'claude'): Promise<string | null> => {
|
||||
const actualCaseName = caseName || 'testcase';
|
||||
|
||||
try {
|
||||
// Get or create the case
|
||||
let caseRes = await fetch(`http://localhost:3000/api/cases/${actualCaseName}`);
|
||||
let caseData = await caseRes.json() as { path?: string };
|
||||
|
||||
// Create case if it doesn't exist
|
||||
if (!caseData.path) {
|
||||
const createCaseRes = await fetch('http://localhost:3000/api/cases', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: actualCaseName, description: '' }),
|
||||
});
|
||||
const createResult = await createCaseRes.json() as { success?: boolean; case?: { path: string } };
|
||||
if (!createResult.success || !createResult.case) {
|
||||
throw new Error('Failed to create case');
|
||||
}
|
||||
caseData = createResult.case;
|
||||
}
|
||||
|
||||
const workingDir = caseData.path;
|
||||
if (!workingDir) throw new Error('Case path not found');
|
||||
|
||||
// Find highest existing w-number across all sessions
|
||||
let startNumber = 1;
|
||||
for (const session of sessions) {
|
||||
const match = session.name?.match(/^w(\d+)-/);
|
||||
if (match) {
|
||||
const num = parseInt(match[1], 10);
|
||||
if (num >= startNumber) {
|
||||
startNumber = num + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Generate session name matching web UI convention
|
||||
const sessionName = `w${startNumber}-${actualCaseName}`;
|
||||
|
||||
// Create session with custom name
|
||||
const createRes = await fetch('http://localhost:3000/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir, name: sessionName, mode }),
|
||||
});
|
||||
const createData = await createRes.json() as { success?: boolean; session?: { id: string } };
|
||||
|
||||
if (!createData.success || !createData.session) {
|
||||
throw new Error('Failed to create session');
|
||||
}
|
||||
|
||||
const sessionId = createData.session.id;
|
||||
|
||||
// Start in the appropriate mode
|
||||
if (mode === 'shell') {
|
||||
await fetch(`http://localhost:3000/api/sessions/${sessionId}/shell`, { method: 'POST' });
|
||||
} else {
|
||||
await fetch(`http://localhost:3000/api/sessions/${sessionId}/interactive`, { method: 'POST' });
|
||||
}
|
||||
|
||||
// Refresh sessions
|
||||
setTimeout(() => {
|
||||
refreshSessions();
|
||||
refreshCases();
|
||||
}, 500);
|
||||
|
||||
return sessionId;
|
||||
} catch (err) {
|
||||
setTerminalOutput(
|
||||
`Failed to create session: ${err instanceof Error ? err.message : 'Unknown error'}\n` +
|
||||
'Make sure the web server is running: claudeman web'
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}, [sessions, refreshSessions, refreshCases]);
|
||||
|
||||
// Kill a session
|
||||
const killSession = useCallback((sessionId: string) => {
|
||||
const session = sessions.find((s) => s.sessionId === sessionId);
|
||||
if (!session) return;
|
||||
|
||||
try {
|
||||
// Kill via screen with timeout to prevent hang
|
||||
execSync(`screen -S ${session.screenName} -X quit`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
});
|
||||
} catch {
|
||||
// May already be dead or timeout
|
||||
}
|
||||
|
||||
// If this was the active session, select another
|
||||
if (activeSessionId === sessionId) {
|
||||
const remaining = sessions.filter((s) => s.sessionId !== sessionId);
|
||||
if (remaining.length > 0) {
|
||||
setActiveSessionId(remaining[0].sessionId);
|
||||
} else {
|
||||
setActiveSessionId(null);
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh to update the list
|
||||
setTimeout(refreshSessions, 500);
|
||||
}, [sessions, activeSessionId, refreshSessions]);
|
||||
|
||||
// Kill all sessions
|
||||
const killAllSessions = useCallback(() => {
|
||||
for (const session of sessions) {
|
||||
try {
|
||||
execSync(`screen -S ${session.screenName} -X quit`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
});
|
||||
} catch {
|
||||
// Ignore - may already be dead or timeout
|
||||
}
|
||||
}
|
||||
setActiveSessionId(null);
|
||||
setTimeout(refreshSessions, 500);
|
||||
}, [sessions, refreshSessions]);
|
||||
|
||||
// Kill ALL claudeman screens and Claude processes (nuclear option)
|
||||
const killAllScreensAndClaude = useCallback(() => {
|
||||
// 1. Kill all tracked sessions first
|
||||
for (const session of sessions) {
|
||||
try {
|
||||
execSync(`screen -S ${session.screenName} -X quit`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
});
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Kill any remaining claudeman screen sessions
|
||||
try {
|
||||
execSync('pkill -f "SCREEN.*claudeman"', {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
});
|
||||
} catch {
|
||||
// May not find any, that's ok
|
||||
}
|
||||
|
||||
// 3. Kill all Claude CLI processes
|
||||
try {
|
||||
execSync('pkill -f "claude"', {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
});
|
||||
} catch {
|
||||
// May not find any, that's ok
|
||||
}
|
||||
|
||||
setActiveSessionId(null);
|
||||
setTimeout(refreshSessions, 500);
|
||||
}, [sessions, refreshSessions]);
|
||||
|
||||
// Navigate to next session
|
||||
const nextSession = useCallback(() => {
|
||||
if (sessions.length === 0) return;
|
||||
const currentIndex = sessions.findIndex((s) => s.sessionId === activeSessionId);
|
||||
const nextIndex = (currentIndex + 1) % sessions.length;
|
||||
selectSession(sessions[nextIndex].sessionId);
|
||||
}, [sessions, activeSessionId, selectSession]);
|
||||
|
||||
// Navigate to previous session
|
||||
const prevSession = useCallback(() => {
|
||||
if (sessions.length === 0) return;
|
||||
const currentIndex = sessions.findIndex((s) => s.sessionId === activeSessionId);
|
||||
const prevIndex = currentIndex <= 0 ? sessions.length - 1 : currentIndex - 1;
|
||||
selectSession(sessions[prevIndex].sessionId);
|
||||
}, [sessions, activeSessionId, selectSession]);
|
||||
|
||||
// Send input to a session directly via screen command (simple, reliable)
|
||||
const sendInput = useCallback((sessionId: string, input: string) => {
|
||||
const session = sessions.find((s) => s.sessionId === sessionId);
|
||||
if (!session || !isScreenAlive(session.screenName)) return;
|
||||
|
||||
const screenName = session.screenName;
|
||||
|
||||
try {
|
||||
if (input === '\r') {
|
||||
// Send Enter key
|
||||
execSync(`screen -S ${screenName} -p 0 -X stuff $'\\015'`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: 1000,
|
||||
});
|
||||
} else if (input === '\t') {
|
||||
// Send Tab key
|
||||
execSync(`screen -S ${screenName} -p 0 -X stuff $'\\011'`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: 1000,
|
||||
});
|
||||
} else if (input === '\x7f') {
|
||||
// Send Backspace
|
||||
execSync(`screen -S ${screenName} -p 0 -X stuff $'\\177'`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: 1000,
|
||||
});
|
||||
} else if (input.startsWith('\x1b')) {
|
||||
// Send escape sequences (arrows, etc) - use $'...' syntax
|
||||
const escaped = input.replace(/'/g, "'\\''");
|
||||
execSync(`screen -S ${screenName} -p 0 -X stuff $'${escaped.replace(/\x1b/g, '\\033')}'`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: 1000,
|
||||
});
|
||||
} else {
|
||||
// Regular character - escape single quotes
|
||||
const escaped = input.replace(/'/g, "'\\''");
|
||||
execSync(`screen -S ${screenName} -p 0 -X stuff '${escaped}'`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: 1000,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Input may fail if screen is not ready - ignore
|
||||
}
|
||||
}, [sessions]);
|
||||
|
||||
// Toggle respawn for active session
|
||||
const toggleRespawn = useCallback(async (): Promise<boolean> => {
|
||||
if (!activeSessionId) return false;
|
||||
|
||||
try {
|
||||
const isEnabled = respawnStatus?.enabled || false;
|
||||
const endpoint = isEnabled
|
||||
? `http://localhost:3000/api/sessions/${activeSessionId}/respawn/stop`
|
||||
: `http://localhost:3000/api/sessions/${activeSessionId}/respawn/enable`;
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(isEnabled ? {} : { config: {} }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json() as { success?: boolean };
|
||||
return data.success || false;
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, [activeSessionId, respawnStatus]);
|
||||
|
||||
// Rename a session
|
||||
const renameSession = useCallback(async (sessionId: string, name: string): Promise<boolean> => {
|
||||
try {
|
||||
const response = await fetch(`http://localhost:3000/api/sessions/${sessionId}/name`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json() as { success?: boolean };
|
||||
if (data.success) {
|
||||
// Refresh sessions to get updated name
|
||||
setTimeout(refreshSessions, 100);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}, [refreshSessions]);
|
||||
|
||||
return {
|
||||
sessions,
|
||||
activeSessionId,
|
||||
activeSession,
|
||||
terminalOutput,
|
||||
ralphLoopState,
|
||||
ralphTodos,
|
||||
respawnStatus,
|
||||
cases,
|
||||
lastUsedCase,
|
||||
refreshSessions,
|
||||
refreshCases,
|
||||
selectSession,
|
||||
createSession,
|
||||
createCase,
|
||||
killSession,
|
||||
killAllSessions,
|
||||
killAllScreensAndClaude,
|
||||
nextSession,
|
||||
prevSession,
|
||||
sendInput,
|
||||
toggleRespawn,
|
||||
renameSession,
|
||||
};
|
||||
}
|
||||
@@ -1,299 +0,0 @@
|
||||
/**
|
||||
* @fileoverview TUI entry point for Claudeman
|
||||
*
|
||||
* Entry point for the terminal user interface, providing a full-screen
|
||||
* session manager similar to the web interface but entirely in the terminal.
|
||||
*
|
||||
* @description
|
||||
* Built with Ink (React for CLI), the TUI offers:
|
||||
* - Session discovery from ~/.claudeman/screens.json
|
||||
* - Tab-based session navigation (like browser tabs)
|
||||
* - Real-time terminal output via screen hardcopy polling
|
||||
* - Ralph Wiggum loop tracking
|
||||
* - Respawn status monitoring
|
||||
* - Auto-detection and startup of web server if not running
|
||||
*
|
||||
* @example
|
||||
* ```bash
|
||||
* # Start the TUI
|
||||
* claudeman tui
|
||||
* # Or via npm
|
||||
* npm run tui
|
||||
* ```
|
||||
*
|
||||
* @see {@link ./App.tsx} for main application component
|
||||
* @see {@link ./hooks/useSessionManager.ts} for state management
|
||||
*/
|
||||
|
||||
import { render } from 'ink';
|
||||
import { spawn, spawnSync } from 'child_process';
|
||||
import { createInterface } from 'readline';
|
||||
import { App, getPendingAttach, clearPendingAttach } from './App.js';
|
||||
import { attachWithTabs, getFullScreenId } from './DirectAttach.js';
|
||||
|
||||
/**
|
||||
* Checks if the terminal supports raw mode input.
|
||||
*
|
||||
* @description
|
||||
* Raw mode is required for Ink to capture keyboard input directly.
|
||||
* This check fails when stdin is piped or redirected (e.g., `echo | claudeman tui`).
|
||||
*
|
||||
* @returns true if raw mode is available, false otherwise
|
||||
*/
|
||||
function isRawModeSupported(): boolean {
|
||||
return Boolean(
|
||||
process.stdin.isTTY &&
|
||||
typeof process.stdin.setRawMode === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the Claudeman web server is running.
|
||||
*
|
||||
* @param port - Port to check (default 3000)
|
||||
* @returns Promise that resolves to true if server is running
|
||||
*/
|
||||
async function isWebServerRunning(port: number = 3000): Promise<boolean> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 2000);
|
||||
|
||||
const response = await fetch(`http://localhost:${port}/api/status`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeout);
|
||||
return response.ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompts the user with a yes/no question.
|
||||
*
|
||||
* @param question - The question to ask
|
||||
* @returns Promise that resolves to true for yes, false for no
|
||||
*/
|
||||
async function promptYesNo(question: string): Promise<boolean> {
|
||||
const rl = createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
return new Promise((resolve) => {
|
||||
rl.question(`${question} [Y/n] `, (answer) => {
|
||||
rl.close();
|
||||
const normalized = answer.trim().toLowerCase();
|
||||
resolve(normalized === '' || normalized === 'y' || normalized === 'yes');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the Claudeman web server in the background.
|
||||
*
|
||||
* @param port - Port to run on (default 3000)
|
||||
* @returns true if server started successfully
|
||||
*/
|
||||
function startWebServerInBackground(port: number = 3000): boolean {
|
||||
try {
|
||||
// Find the current script's directory to locate the web server entry
|
||||
const child = spawn('node', [
|
||||
'--import', 'tsx/esm',
|
||||
`${process.cwd()}/src/index.ts`,
|
||||
'web',
|
||||
'-p', String(port),
|
||||
], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
child.unref();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* TUI startup options.
|
||||
*/
|
||||
interface TUIOptions {
|
||||
/** Auto-start web server without prompting if not running */
|
||||
autoStartWeb?: boolean;
|
||||
/** Skip web server check entirely */
|
||||
skipWebCheck?: boolean;
|
||||
/** Web server port */
|
||||
port?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the TUI application in the current terminal.
|
||||
*
|
||||
* @description
|
||||
* Initializes the Ink renderer and displays the TUI.
|
||||
* The terminal is cleared for a full-screen experience.
|
||||
* This function blocks until the user exits the TUI.
|
||||
*
|
||||
* Before starting, checks if the web server is running and offers to start it
|
||||
* in the background if not (unless skipWebCheck is true).
|
||||
*
|
||||
* @param options - TUI startup options
|
||||
* @throws Exits with code 1 if TTY/raw mode is not supported
|
||||
* @returns Promise that resolves when the TUI exits
|
||||
*/
|
||||
export async function startTUI(options: TUIOptions = {}): Promise<void> {
|
||||
const { autoStartWeb = false, skipWebCheck = false, port = 3000 } = options;
|
||||
|
||||
// Check if we're in an interactive terminal
|
||||
if (!isRawModeSupported()) {
|
||||
console.error('Error: TUI requires an interactive terminal with TTY support.');
|
||||
console.error('Make sure you are running this command in a real terminal, not piped.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Check if web server is running (unless skipped)
|
||||
if (!skipWebCheck) {
|
||||
const serverRunning = await isWebServerRunning(port);
|
||||
|
||||
if (!serverRunning) {
|
||||
console.log('\x1b[33mClaudeman web server is not running.\x1b[0m');
|
||||
console.log('The TUI requires the web server to create and manage sessions.\n');
|
||||
|
||||
let startServer = autoStartWeb;
|
||||
|
||||
if (!autoStartWeb) {
|
||||
startServer = await promptYesNo('Would you like to start the web server in the background?');
|
||||
}
|
||||
|
||||
if (startServer) {
|
||||
console.log(`\x1b[32mStarting web server on port ${port}...\x1b[0m`);
|
||||
const success = startWebServerInBackground(port);
|
||||
|
||||
if (success) {
|
||||
// Wait a moment for the server to start
|
||||
await new Promise(resolve => setTimeout(resolve, 2000));
|
||||
|
||||
// Verify it started
|
||||
const nowRunning = await isWebServerRunning(port);
|
||||
if (nowRunning) {
|
||||
console.log('\x1b[32mWeb server started successfully!\x1b[0m\n');
|
||||
} else {
|
||||
console.log('\x1b[33mServer may still be starting... continuing with TUI.\x1b[0m');
|
||||
console.log('If you have issues, try running "claudeman web" in a separate terminal.\n');
|
||||
}
|
||||
} else {
|
||||
console.log('\x1b[31mFailed to start web server.\x1b[0m');
|
||||
console.log('Please run "claudeman web" in a separate terminal first.\n');
|
||||
}
|
||||
} else {
|
||||
console.log('\nYou can start the web server anytime with: claudeman web');
|
||||
console.log('Some TUI features may not work without the web server.\n');
|
||||
}
|
||||
|
||||
// Brief pause before clearing screen
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
// Clear the terminal for full-screen experience
|
||||
process.stdout.write('\x1b[2J\x1b[H');
|
||||
|
||||
// Main TUI loop - allows unmounting for screen attachment and re-rendering
|
||||
let shouldContinue = true;
|
||||
|
||||
while (shouldContinue) {
|
||||
// Clear any pending attach from previous iteration
|
||||
clearPendingAttach();
|
||||
|
||||
const { waitUntilExit, unmount } = render(<App />);
|
||||
|
||||
// Handle graceful exit on SIGINT/SIGTERM
|
||||
const cleanup = () => {
|
||||
unmount();
|
||||
// Restore terminal state
|
||||
process.stdout.write('\x1b[?25h'); // Show cursor
|
||||
process.stdout.write('\x1b[2J\x1b[H'); // Clear screen
|
||||
};
|
||||
|
||||
process.on('SIGINT', cleanup);
|
||||
process.on('SIGTERM', cleanup);
|
||||
|
||||
try {
|
||||
await waitUntilExit();
|
||||
} finally {
|
||||
process.off('SIGINT', cleanup);
|
||||
process.off('SIGTERM', cleanup);
|
||||
}
|
||||
|
||||
// Check if we exited to attach to a screen session
|
||||
const pendingAttach = getPendingAttach();
|
||||
|
||||
if (pendingAttach) {
|
||||
// Unmount Ink properly
|
||||
unmount();
|
||||
|
||||
// Restore terminal state after Ink - this is critical for screen to work
|
||||
// Ink leaves stdin in raw mode with event listeners, we need to clean up
|
||||
if (process.stdin.isTTY) {
|
||||
process.stdin.setRawMode(false);
|
||||
}
|
||||
process.stdin.removeAllListeners();
|
||||
// Pause stdin for clean state - readKeySync uses fs.readSync which works in paused mode
|
||||
process.stdin.pause();
|
||||
|
||||
// Clear terminal and show cursor
|
||||
process.stdout.write('\x1b[?25h'); // Show cursor
|
||||
process.stdout.write('\x1b[2J\x1b[H'); // Clear screen
|
||||
|
||||
// Handle the attachment
|
||||
if (pendingAttach.mode === 'tabs') {
|
||||
// Attach with tab switching between sessions
|
||||
await new Promise<void>((resolve) => {
|
||||
attachWithTabs(pendingAttach.sessions, pendingAttach.index, () => {
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// Direct attach to single screen
|
||||
const session = pendingAttach.session!;
|
||||
// Get full screen ID (PID.screenName) for unambiguous attachment
|
||||
const fullScreenId = getFullScreenId(session.screenName);
|
||||
|
||||
if (fullScreenId) {
|
||||
console.log(`Attaching to: ${session.screenName}`);
|
||||
console.log('Detach with Ctrl+A D to return to TUI\n');
|
||||
|
||||
spawnSync('screen', ['-x', '-A', fullScreenId], {
|
||||
stdio: 'inherit',
|
||||
env: {
|
||||
...process.env,
|
||||
TERM: process.env.TERM || 'xterm-256color',
|
||||
},
|
||||
});
|
||||
} else {
|
||||
console.log(`Screen session not found: ${session.screenName}`);
|
||||
console.log('Press any key to continue...');
|
||||
await new Promise<void>((resolve) => {
|
||||
process.stdin.once('data', () => resolve());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Clear terminal before returning to TUI
|
||||
process.stdout.write('\x1b[2J\x1b[H');
|
||||
|
||||
// Continue the loop to re-render Ink
|
||||
shouldContinue = true;
|
||||
} else {
|
||||
// Normal exit - user quit the TUI
|
||||
shouldContinue = false;
|
||||
// Final cleanup
|
||||
unmount();
|
||||
process.stdout.write('\x1b[?25h'); // Show cursor
|
||||
process.stdout.write('\x1b[2J\x1b[H'); // Clear screen
|
||||
}
|
||||
}
|
||||
}
|
||||
+225
-115
@@ -431,6 +431,8 @@ class NotificationManager {
|
||||
class ClaudemanApp {
|
||||
constructor() {
|
||||
this.sessions = new Map();
|
||||
this.sessionOrder = []; // Track tab order for drag-and-drop reordering
|
||||
this.draggedTabId = null; // Currently dragged tab session ID
|
||||
this.cases = [];
|
||||
this.currentRun = null;
|
||||
this.totalTokens = 0;
|
||||
@@ -842,14 +844,17 @@ class ClaudemanApp {
|
||||
}
|
||||
|
||||
showWelcome() {
|
||||
this.terminal.writeln('\x1b[1;36m Claudeman Terminal\x1b[0m');
|
||||
this.terminal.writeln('');
|
||||
this.terminal.writeln('\x1b[90m Each instance opens a persistent GNU Screen session running Claude or Shell.\x1b[0m');
|
||||
this.terminal.writeln('\x1b[90m Sessions stay alive for autonomous work, even if you close this browser.\x1b[0m');
|
||||
this.terminal.writeln('\x1b[90m Use the +/- controls to set how many instances to launch at once.\x1b[0m');
|
||||
this.terminal.writeln('');
|
||||
this.terminal.writeln('\x1b[90m Press \x1b[1;37mCtrl+Enter\x1b[0m\x1b[90m to start Claude\x1b[0m');
|
||||
this.terminal.writeln('');
|
||||
const overlay = document.getElementById('welcomeOverlay');
|
||||
if (overlay) {
|
||||
overlay.classList.add('visible');
|
||||
}
|
||||
}
|
||||
|
||||
hideWelcome() {
|
||||
const overlay = document.getElementById('welcomeOverlay');
|
||||
if (overlay) {
|
||||
overlay.classList.remove('visible');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1113,6 +1118,11 @@ class ClaudemanApp {
|
||||
addListener('session:created', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
this.sessions.set(data.id, data);
|
||||
// Add new session to end of tab order
|
||||
if (!this.sessionOrder.includes(data.id)) {
|
||||
this.sessionOrder.push(data.id);
|
||||
this.saveSessionOrder();
|
||||
}
|
||||
this.renderSessionTabs();
|
||||
this.updateCost();
|
||||
});
|
||||
@@ -1120,6 +1130,8 @@ class ClaudemanApp {
|
||||
addListener('session:updated', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
const session = data.session || data;
|
||||
const oldSession = this.sessions.get(session.id);
|
||||
const claudeSessionIdJustSet = session.claudeSessionId && (!oldSession || !oldSession.claudeSessionId);
|
||||
this.sessions.set(session.id, session);
|
||||
this.renderSessionTabs();
|
||||
this.updateCost();
|
||||
@@ -1130,11 +1142,22 @@ class ClaudemanApp {
|
||||
// Update parentSessionName for any subagents belonging to this session
|
||||
// (fixes stale name display after session rename)
|
||||
this.updateSubagentParentNames(session.id);
|
||||
// If claudeSessionId was just set, re-check orphan subagents
|
||||
// This connects subagents that were waiting for the session to identify itself
|
||||
if (claudeSessionIdJustSet) {
|
||||
this.recheckOrphanSubagents();
|
||||
}
|
||||
});
|
||||
|
||||
addListener('session:deleted', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
this.sessions.delete(data.id);
|
||||
// Remove from tab order
|
||||
const orderIndex = this.sessionOrder.indexOf(data.id);
|
||||
if (orderIndex !== -1) {
|
||||
this.sessionOrder.splice(orderIndex, 1);
|
||||
this.saveSessionOrder();
|
||||
}
|
||||
this.terminalBuffers.delete(data.id);
|
||||
this.ralphStates.delete(data.id); // Clean up ralph state for this session
|
||||
this.projectInsights.delete(data.id); // Clean up project insights for this session
|
||||
@@ -1790,7 +1813,7 @@ class ClaudemanApp {
|
||||
|
||||
// ========== Subagent Events (Claude Code Background Agents) ==========
|
||||
|
||||
addListener('subagent:discovered', async (e) => {
|
||||
addListener('subagent:discovered', (e) => {
|
||||
const data = JSON.parse(e.data);
|
||||
// Clear all old data for this agentId (in case of ID reuse)
|
||||
this.subagents.set(data.agentId, data);
|
||||
@@ -1802,8 +1825,8 @@ class ClaudemanApp {
|
||||
}
|
||||
this.renderSubagentPanel();
|
||||
|
||||
// Find which Claudeman session owns this subagent (by working directory)
|
||||
await this.findParentSessionForSubagent(data.agentId);
|
||||
// Find which Claudeman session owns this subagent (direct claudeSessionId match only)
|
||||
this.findParentSessionForSubagent(data.agentId);
|
||||
|
||||
// Auto-open window for new active agents
|
||||
if (data.status === 'active') {
|
||||
@@ -2065,6 +2088,9 @@ class ClaudemanApp {
|
||||
}
|
||||
});
|
||||
|
||||
// Sync sessionOrder with current sessions (preserve order, add new, remove stale)
|
||||
this.syncSessionOrder();
|
||||
|
||||
if (data.respawnStatus) {
|
||||
this.respawnStatus = data.respawnStatus;
|
||||
} else {
|
||||
@@ -2111,10 +2137,9 @@ class ClaudemanApp {
|
||||
this.restoreSubagentWindowStates();
|
||||
}
|
||||
|
||||
// Auto-select first session if any
|
||||
if (this.sessions.size > 0 && !this.activeSessionId) {
|
||||
const firstSession = this.sessions.values().next().value;
|
||||
this.selectSession(firstSession.id);
|
||||
// Auto-select first session if any (use sessionOrder for user's preferred order)
|
||||
if (this.sessionOrder.length > 0 && !this.activeSessionId) {
|
||||
this.selectSession(this.sessionOrder[0]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2225,11 +2250,6 @@ class ClaudemanApp {
|
||||
this._fullRenderSessionTabs();
|
||||
}
|
||||
|
||||
// Auto-focus: if there's exactly one session and none is active, select it
|
||||
if (this.sessions.size === 1 && !this.activeSessionId) {
|
||||
const [sessionId] = this.sessions.keys();
|
||||
this.selectSession(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
_fullRenderSessionTabs() {
|
||||
@@ -2240,8 +2260,12 @@ class ClaudemanApp {
|
||||
this.cancelHideSubagentDropdown();
|
||||
|
||||
// Build tabs HTML using array for better string concatenation performance
|
||||
// Iterate in sessionOrder to respect user's custom tab arrangement
|
||||
const parts = [];
|
||||
for (const [id, session] of this.sessions) {
|
||||
for (const id of this.sessionOrder) {
|
||||
const session = this.sessions.get(id);
|
||||
if (!session) continue; // Skip if session was removed
|
||||
|
||||
const isActive = id === this.activeSessionId;
|
||||
const status = session.status || 'idle';
|
||||
const name = this.getSessionName(session);
|
||||
@@ -2268,6 +2292,10 @@ class ClaudemanApp {
|
||||
}
|
||||
|
||||
container.innerHTML = parts.join('');
|
||||
|
||||
// Set up drag-and-drop handlers for tab reordering
|
||||
this.setupTabDragHandlers();
|
||||
|
||||
// Update connection lines after tabs change (positions may have shifted)
|
||||
this.updateConnectionLines();
|
||||
}
|
||||
@@ -2307,6 +2335,129 @@ class ClaudemanApp {
|
||||
`;
|
||||
}
|
||||
|
||||
// ========== Tab Order and Drag-and-Drop ==========
|
||||
|
||||
// Sync sessionOrder with current sessions (preserve order for existing, add new at end)
|
||||
syncSessionOrder() {
|
||||
const currentIds = new Set(this.sessions.keys());
|
||||
|
||||
// Load saved order from localStorage
|
||||
const savedOrder = this.loadSessionOrder();
|
||||
|
||||
// Start with saved order, keeping only sessions that still exist
|
||||
const preserved = savedOrder.filter(id => currentIds.has(id));
|
||||
const preservedSet = new Set(preserved);
|
||||
|
||||
// Add any new sessions at the end
|
||||
const newSessions = [...currentIds].filter(id => !preservedSet.has(id));
|
||||
|
||||
this.sessionOrder = [...preserved, ...newSessions];
|
||||
}
|
||||
|
||||
// Load session order from localStorage
|
||||
loadSessionOrder() {
|
||||
try {
|
||||
const saved = localStorage.getItem('claudeman-session-order');
|
||||
return saved ? JSON.parse(saved) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Save session order to localStorage
|
||||
saveSessionOrder() {
|
||||
try {
|
||||
localStorage.setItem('claudeman-session-order', JSON.stringify(this.sessionOrder));
|
||||
} catch {
|
||||
// Ignore storage errors
|
||||
}
|
||||
}
|
||||
|
||||
// Set up drag-and-drop handlers on tab elements
|
||||
setupTabDragHandlers() {
|
||||
const container = this.$('sessionTabs');
|
||||
const tabs = container.querySelectorAll('.session-tab[data-id]');
|
||||
|
||||
tabs.forEach(tab => {
|
||||
tab.setAttribute('draggable', 'true');
|
||||
|
||||
tab.addEventListener('dragstart', (e) => {
|
||||
this.draggedTabId = tab.dataset.id;
|
||||
tab.classList.add('dragging');
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
e.dataTransfer.setData('text/plain', tab.dataset.id);
|
||||
});
|
||||
|
||||
tab.addEventListener('dragend', () => {
|
||||
tab.classList.remove('dragging');
|
||||
this.draggedTabId = null;
|
||||
// Remove all drag-over indicators
|
||||
container.querySelectorAll('.session-tab').forEach(t => {
|
||||
t.classList.remove('drag-over-left', 'drag-over-right');
|
||||
});
|
||||
});
|
||||
|
||||
tab.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
if (!this.draggedTabId || this.draggedTabId === tab.dataset.id) return;
|
||||
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
|
||||
// Determine drop position based on mouse position
|
||||
const rect = tab.getBoundingClientRect();
|
||||
const midpoint = rect.left + rect.width / 2;
|
||||
const isLeftHalf = e.clientX < midpoint;
|
||||
|
||||
// Update visual indicator
|
||||
tab.classList.toggle('drag-over-left', isLeftHalf);
|
||||
tab.classList.toggle('drag-over-right', !isLeftHalf);
|
||||
});
|
||||
|
||||
tab.addEventListener('dragleave', () => {
|
||||
tab.classList.remove('drag-over-left', 'drag-over-right');
|
||||
});
|
||||
|
||||
tab.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
tab.classList.remove('drag-over-left', 'drag-over-right');
|
||||
|
||||
if (!this.draggedTabId || this.draggedTabId === tab.dataset.id) return;
|
||||
|
||||
const targetId = tab.dataset.id;
|
||||
const draggedId = this.draggedTabId;
|
||||
|
||||
// Determine insertion position
|
||||
const rect = tab.getBoundingClientRect();
|
||||
const midpoint = rect.left + rect.width / 2;
|
||||
const insertBefore = e.clientX < midpoint;
|
||||
|
||||
// Reorder sessionOrder array
|
||||
const fromIndex = this.sessionOrder.indexOf(draggedId);
|
||||
let toIndex = this.sessionOrder.indexOf(targetId);
|
||||
|
||||
if (fromIndex === -1 || toIndex === -1) return;
|
||||
|
||||
// Remove dragged item
|
||||
this.sessionOrder.splice(fromIndex, 1);
|
||||
|
||||
// Recalculate target index after removal
|
||||
toIndex = this.sessionOrder.indexOf(targetId);
|
||||
if (toIndex === -1) return;
|
||||
|
||||
// Insert at correct position
|
||||
if (insertBefore) {
|
||||
this.sessionOrder.splice(toIndex, 0, draggedId);
|
||||
} else {
|
||||
this.sessionOrder.splice(toIndex + 1, 0, draggedId);
|
||||
}
|
||||
|
||||
// Save and re-render
|
||||
this.saveSessionOrder();
|
||||
this._fullRenderSessionTabs();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Restore a minimized subagent window
|
||||
restoreMinimizedSubagent(agentId, sessionId) {
|
||||
// Remove from minimized set
|
||||
@@ -2443,6 +2594,7 @@ class ClaudemanApp {
|
||||
if (this.activeSessionId === sessionId) return;
|
||||
|
||||
this.activeSessionId = sessionId;
|
||||
this.hideWelcome();
|
||||
// Clear idle hooks on view, but keep action hooks until user interacts
|
||||
this.clearPendingHooks(sessionId, 'idle_prompt');
|
||||
this.renderSessionTabs();
|
||||
@@ -2552,16 +2704,22 @@ class ClaudemanApp {
|
||||
try {
|
||||
await fetch(`/api/sessions/${sessionId}?killScreen=${killScreen}`, { method: 'DELETE' });
|
||||
this.sessions.delete(sessionId);
|
||||
// Remove from tab order
|
||||
const orderIndex = this.sessionOrder.indexOf(sessionId);
|
||||
if (orderIndex !== -1) {
|
||||
this.sessionOrder.splice(orderIndex, 1);
|
||||
this.saveSessionOrder();
|
||||
}
|
||||
this.terminalBuffers.delete(sessionId);
|
||||
this.ralphStates.delete(sessionId);
|
||||
this.clearCountdownTimers(sessionId);
|
||||
|
||||
if (this.activeSessionId === sessionId) {
|
||||
this.activeSessionId = null;
|
||||
// Select another session or show welcome
|
||||
if (this.sessions.size > 0) {
|
||||
const nextSession = this.sessions.values().next().value;
|
||||
this.selectSession(nextSession.id);
|
||||
// Select another session or show welcome (use sessionOrder for consistent ordering)
|
||||
if (this.sessionOrder.length > 0 && this.sessions.size > 0) {
|
||||
const nextSessionId = this.sessionOrder[0];
|
||||
this.selectSession(nextSessionId);
|
||||
} else {
|
||||
this.terminal.clear();
|
||||
this.showWelcome();
|
||||
@@ -2611,12 +2769,11 @@ class ClaudemanApp {
|
||||
}
|
||||
|
||||
nextSession() {
|
||||
const ids = Array.from(this.sessions.keys());
|
||||
if (ids.length <= 1) return;
|
||||
if (this.sessionOrder.length <= 1) return;
|
||||
|
||||
const currentIndex = ids.indexOf(this.activeSessionId);
|
||||
const nextIndex = (currentIndex + 1) % ids.length;
|
||||
this.selectSession(ids[nextIndex]);
|
||||
const currentIndex = this.sessionOrder.indexOf(this.activeSessionId);
|
||||
const nextIndex = (currentIndex + 1) % this.sessionOrder.length;
|
||||
this.selectSession(this.sessionOrder[nextIndex]);
|
||||
}
|
||||
|
||||
// ========== Navigation ==========
|
||||
@@ -7442,15 +7599,11 @@ class ClaudemanApp {
|
||||
|
||||
// First, discover parent sessions for all subagents to establish correct mapping
|
||||
// This is important after server restart when parentSessionId isn't set
|
||||
const parentDiscoveryPromises = [];
|
||||
for (const [agentId, agent] of this.subagents) {
|
||||
if (!agent.parentSessionId) {
|
||||
parentDiscoveryPromises.push(this.findParentSessionForSubagent(agentId));
|
||||
this.findParentSessionForSubagent(agentId);
|
||||
}
|
||||
}
|
||||
if (parentDiscoveryPromises.length > 0) {
|
||||
await Promise.all(parentDiscoveryPromises);
|
||||
}
|
||||
|
||||
// Restore minimized state, but verify session mapping is correct
|
||||
// Skip old agents from previous runs to avoid confusion
|
||||
@@ -9239,92 +9392,50 @@ class ClaudemanApp {
|
||||
|
||||
// ========== Subagent Parent Session Tracking ==========
|
||||
|
||||
async findParentSessionForSubagent(agentId) {
|
||||
/**
|
||||
* Find and assign the parent Claudeman session for a subagent.
|
||||
*
|
||||
* ROCK-SOLID MATCHING: Only uses claudeSessionId.
|
||||
* - agent.sessionId = Claude session ID that spawned this subagent (from file path)
|
||||
* - session.claudeSessionId = Claude session ID running in this Claudeman session
|
||||
*
|
||||
* If they match, the subagent belongs to that tab. No fallbacks, no heuristics.
|
||||
* If no match is found, the subagent waits - when a session's claudeSessionId gets
|
||||
* set (via session:updated), we re-check all orphan subagents.
|
||||
*/
|
||||
findParentSessionForSubagent(agentId) {
|
||||
const agent = this.subagents.get(agentId);
|
||||
if (!agent) return;
|
||||
|
||||
// Strategy 1: Check if another subagent with the same Claude sessionId already has a parent
|
||||
// This ensures subagents from the same Claude session go to the same Claudeman session
|
||||
if (agent.sessionId) {
|
||||
for (const [otherAgentId, otherAgent] of this.subagents) {
|
||||
if (otherAgentId !== agentId &&
|
||||
otherAgent.sessionId === agent.sessionId &&
|
||||
otherAgent.parentSessionId &&
|
||||
this.sessions.has(otherAgent.parentSessionId)) {
|
||||
// Found a sibling subagent with an assigned parent
|
||||
agent.parentSessionId = otherAgent.parentSessionId;
|
||||
agent.parentSessionName = otherAgent.parentSessionName;
|
||||
this.subagents.set(agentId, agent);
|
||||
this.updateSubagentWindowParent(agentId);
|
||||
this.updateSubagentWindowVisibility();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Can't match without a sessionId
|
||||
if (!agent.sessionId) return;
|
||||
|
||||
// Strategy 2: Match by claudeSessionId (most reliable)
|
||||
// The subagent's sessionId is the Claude session that spawned it
|
||||
// Match this to the Claudeman session's claudeSessionId
|
||||
if (agent.sessionId) {
|
||||
for (const [sessionId, session] of this.sessions) {
|
||||
if (session.claudeSessionId === agent.sessionId) {
|
||||
agent.parentSessionId = sessionId;
|
||||
agent.parentSessionName = this.getSessionName(session);
|
||||
this.subagents.set(agentId, agent);
|
||||
this.updateSubagentWindowParent(agentId);
|
||||
this.updateSubagentWindowVisibility();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: Fall back to workingDir matching (for sessions without claudeSessionId yet)
|
||||
const matchingSessions = [];
|
||||
// Direct match: subagent.sessionId === session.claudeSessionId
|
||||
for (const [sessionId, session] of this.sessions) {
|
||||
try {
|
||||
const resp = await fetch(`/api/sessions/${sessionId}/subagents`);
|
||||
if (!resp.ok) continue;
|
||||
const result = await resp.json();
|
||||
const subagents = result.data || result.subagents || [];
|
||||
if (subagents.some(s => s.agentId === agentId)) {
|
||||
matchingSessions.push({ sessionId, session });
|
||||
}
|
||||
} catch (err) {
|
||||
// Ignore errors
|
||||
if (session.claudeSessionId === agent.sessionId) {
|
||||
agent.parentSessionId = sessionId;
|
||||
agent.parentSessionName = this.getSessionName(session);
|
||||
this.subagents.set(agentId, agent);
|
||||
this.updateSubagentWindowParent(agentId);
|
||||
this.updateSubagentWindowVisibility();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (matchingSessions.length === 0) {
|
||||
// No matching session found
|
||||
return;
|
||||
}
|
||||
// No match found - the session's claudeSessionId might not be set yet.
|
||||
// When session:updated fires with claudeSessionId, we'll re-check orphans.
|
||||
}
|
||||
|
||||
// If only one session matches, use it
|
||||
let chosen = matchingSessions[0];
|
||||
|
||||
if (matchingSessions.length > 1) {
|
||||
// Multiple sessions with same workingDir - prefer session with matching claudeSessionId
|
||||
// If no match, fall back to oldest session (first created is more likely the original)
|
||||
const byClaudeSession = matchingSessions.find(m => m.session.claudeSessionId === agent.sessionId);
|
||||
if (byClaudeSession) {
|
||||
chosen = byClaudeSession;
|
||||
} else {
|
||||
// Fall back to oldest session (not newest - oldest is more likely the original workspace)
|
||||
matchingSessions.sort((a, b) => {
|
||||
const aTime = new Date(a.session.createdAt || 0).getTime();
|
||||
const bTime = new Date(b.session.createdAt || 0).getTime();
|
||||
return aTime - bTime; // Oldest first
|
||||
});
|
||||
chosen = matchingSessions[0];
|
||||
/**
|
||||
* Re-check all orphan subagents (those without a parentSessionId) when a session updates.
|
||||
* Called when session:updated fires, in case claudeSessionId was just set.
|
||||
*/
|
||||
recheckOrphanSubagents() {
|
||||
for (const [agentId, agent] of this.subagents) {
|
||||
if (!agent.parentSessionId && agent.sessionId) {
|
||||
this.findParentSessionForSubagent(agentId);
|
||||
}
|
||||
}
|
||||
|
||||
// Assign the parent
|
||||
agent.parentSessionId = chosen.sessionId;
|
||||
agent.parentSessionName = this.getSessionName(chosen.session);
|
||||
this.subagents.set(agentId, agent);
|
||||
this.updateSubagentWindowParent(agentId);
|
||||
this.updateSubagentWindowVisibility();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -9821,7 +9932,7 @@ class ClaudemanApp {
|
||||
this.saveSubagentWindowStates();
|
||||
}
|
||||
|
||||
async closeSubagentWindow(agentId) {
|
||||
closeSubagentWindow(agentId) {
|
||||
const windowData = this.subagentWindows.get(agentId);
|
||||
if (!windowData) return;
|
||||
|
||||
@@ -9830,12 +9941,11 @@ class ClaudemanApp {
|
||||
// Try to discover parent session if not already known
|
||||
// This ensures we minimize to the correct tab
|
||||
if (agent && !agent.parentSessionId) {
|
||||
await this.findParentSessionForSubagent(agentId);
|
||||
this.findParentSessionForSubagent(agentId);
|
||||
}
|
||||
|
||||
// Now get the correct parent session (re-read agent after discovery)
|
||||
const updatedAgent = this.subagents.get(agentId);
|
||||
const parentSessionId = updatedAgent?.parentSessionId || this.activeSessionId;
|
||||
// Get the parent session (use active session if no parent found)
|
||||
const parentSessionId = agent?.parentSessionId || this.activeSessionId;
|
||||
|
||||
// Always minimize to tab (use active session if no parent found)
|
||||
windowData.element.style.display = 'none';
|
||||
|
||||
+27
-12
@@ -190,6 +190,26 @@
|
||||
<!-- Main Terminal Area -->
|
||||
<main class="main">
|
||||
<div class="terminal-container" id="terminalContainer"></div>
|
||||
|
||||
<!-- Welcome Overlay (shown when no session active) -->
|
||||
<div class="welcome-overlay" id="welcomeOverlay">
|
||||
<div class="welcome-content">
|
||||
<h1 class="welcome-title">Claudeman Terminal</h1>
|
||||
<p class="welcome-desc">Each instance opens a persistent GNU Screen session running Claude or Shell.</p>
|
||||
<p class="welcome-desc">Sessions stay alive for autonomous work, even if you close this browser.</p>
|
||||
<div class="welcome-actions">
|
||||
<button class="welcome-btn welcome-btn-primary" onclick="app.runClaude()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
Run Claude
|
||||
</button>
|
||||
<button class="welcome-btn welcome-btn-ralph" onclick="app.showRalphWizard()">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg>
|
||||
Start Ralph Loop Wizard
|
||||
</button>
|
||||
</div>
|
||||
<p class="welcome-hint">Or press <kbd>Ctrl</kbd>+<kbd>Enter</kbd> to start Claude</p>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Project Insights Panel (shows file-viewing Bash commands) -->
|
||||
@@ -242,24 +262,22 @@
|
||||
<!-- Run Claude -->
|
||||
<div class="toolbar-group">
|
||||
<button class="btn-toolbar btn-claude" onclick="app.runClaude()" title="Run Claude (Ctrl+Enter)">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polygon points="5 3 19 12 5 21 5 3"/></svg>
|
||||
Run Claude
|
||||
</button>
|
||||
<div class="tab-count-group" title="Instance count">
|
||||
<button class="tab-count-btn" onclick="app.decrementTabCount()" title="Instance count">−</button>
|
||||
<input type="number" id="tabCount" class="tab-count-input" value="1" min="1" max="20" readonly title="Instance count">
|
||||
<button class="tab-count-btn" onclick="app.incrementTabCount()" title="Instance count">+</button>
|
||||
<button class="tab-count-btn" onclick="app.decrementTabCount()">−</button>
|
||||
<input type="number" id="tabCount" class="tab-count-input" value="1" min="1" max="20" readonly>
|
||||
<button class="tab-count-btn" onclick="app.incrementTabCount()">+</button>
|
||||
</div>
|
||||
<button class="btn-toolbar btn-shell" onclick="app.runShell()" title="Run Shell">
|
||||
Run Shell
|
||||
</button>
|
||||
<div class="tab-count-group" title="Instance count">
|
||||
<button class="tab-count-btn" onclick="app.decrementShellCount()" title="Instance count">−</button>
|
||||
<input type="number" id="shellCount" class="tab-count-input" value="1" min="1" max="20" readonly title="Instance count">
|
||||
<button class="tab-count-btn" onclick="app.incrementShellCount()" title="Instance count">+</button>
|
||||
<button class="tab-count-btn" onclick="app.decrementShellCount()">−</button>
|
||||
<input type="number" id="shellCount" class="tab-count-input" value="1" min="1" max="20" readonly>
|
||||
<button class="tab-count-btn" onclick="app.incrementShellCount()">+</button>
|
||||
</div>
|
||||
<button class="btn-toolbar btn-ralph" onclick="app.showRalphWizard()" title="Start Ralph Loop">
|
||||
Ralph Loop
|
||||
</button>
|
||||
<div class="case-select-group">
|
||||
<select id="quickStartCase" class="toolbar-select" title="Select case">
|
||||
<option value="testcase">testcase</option>
|
||||
@@ -1295,9 +1313,6 @@
|
||||
<div class="notif-drawer-empty" id="notifEmpty">No notifications</div>
|
||||
</div>
|
||||
|
||||
<!-- Help Button -->
|
||||
<button class="help-btn" onclick="app.showHelp()" title="Keyboard shortcuts (Ctrl+?)">?</button>
|
||||
|
||||
<!-- Token Stats Modal -->
|
||||
<div class="modal" id="tokenStatsModal">
|
||||
<div class="modal-backdrop" onclick="app.closeTokenStats()"></div>
|
||||
|
||||
+144
-57
@@ -215,6 +215,24 @@ body {
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
|
||||
/* Tab drag-and-drop reordering */
|
||||
.session-tab[draggable="true"] {
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.session-tab.dragging {
|
||||
opacity: 0.5;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.session-tab.drag-over-left {
|
||||
box-shadow: -2px 0 0 0 var(--accent);
|
||||
}
|
||||
|
||||
.session-tab.drag-over-right {
|
||||
box-shadow: 2px 0 0 0 var(--accent);
|
||||
}
|
||||
|
||||
/* Header Right */
|
||||
.header-right {
|
||||
display: flex;
|
||||
@@ -1502,6 +1520,7 @@ body {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
background: var(--bg-dark);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.terminal-container {
|
||||
@@ -1522,6 +1541,111 @@ body {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
/* Welcome Overlay */
|
||||
.welcome-overlay {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-dark);
|
||||
z-index: 10;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.welcome-overlay.visible {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.welcome-content {
|
||||
text-align: center;
|
||||
max-width: 500px;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.welcome-title {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--accent-hover);
|
||||
margin-bottom: 1rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.welcome-desc {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.welcome-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: center;
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.welcome-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.welcome-btn-primary {
|
||||
background: linear-gradient(135deg, #1e3a5f 0%, #2563eb 100%);
|
||||
border-color: #3b82f6;
|
||||
color: #e0e7ff;
|
||||
}
|
||||
|
||||
.welcome-btn-primary:hover {
|
||||
background: linear-gradient(135deg, #2563eb 0%, #3b82f6 100%);
|
||||
box-shadow: 0 0 12px rgba(59, 130, 246, 0.4);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.welcome-btn-ralph {
|
||||
background: linear-gradient(135deg, #3d3415 0%, #4a3f1a 100%);
|
||||
border-color: #6b5a1e;
|
||||
color: #eab308;
|
||||
}
|
||||
|
||||
.welcome-btn-ralph:hover {
|
||||
background: linear-gradient(135deg, #4a3f1a 0%, #5a4d20 100%);
|
||||
border-color: #8b7a2e;
|
||||
box-shadow: 0 0 12px rgba(234, 179, 8, 0.4);
|
||||
color: #fcd34d;
|
||||
}
|
||||
|
||||
.welcome-hint {
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.welcome-hint kbd {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.4rem;
|
||||
background: var(--bg-input);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 3px;
|
||||
font-family: inherit;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* Bottom Toolbar */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
@@ -1539,7 +1663,7 @@ body {
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.toolbar-center {
|
||||
@@ -1608,23 +1732,25 @@ body {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
|
||||
/* Claude Button - Special styling */
|
||||
/* Claude Button - Special styling (matches welcome-btn-primary) */
|
||||
.btn-toolbar.btn-claude {
|
||||
background: linear-gradient(135deg, #1e3a5f 0%, #3d2a5c 100%);
|
||||
border-color: #2d4a6f;
|
||||
color: var(--accent-hover);
|
||||
font-weight: 700;
|
||||
background: linear-gradient(135deg, #1e3a5f 0%, #2563eb 100%);
|
||||
border-color: #3b82f6;
|
||||
color: #e0e7ff;
|
||||
font-weight: 500;
|
||||
padding: 0.4rem 0.85rem;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.btn-toolbar.btn-claude:hover {
|
||||
background: linear-gradient(135deg, #2a4d7a 0%, #4d3a6c 100%);
|
||||
border-color: #3d5a7f;
|
||||
box-shadow: 0 0 12px rgba(61, 90, 127, 0.3);
|
||||
background: linear-gradient(135deg, #2563eb 0%, #3b82f6 100%);
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 12px rgba(59, 130, 246, 0.4);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-toolbar.btn-claude .btn-icon {
|
||||
font-size: 0.9rem;
|
||||
.btn-toolbar.btn-claude svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Shell Button - Special styling */
|
||||
@@ -1642,21 +1768,6 @@ body {
|
||||
color: #a8d8a8;
|
||||
}
|
||||
|
||||
/* Ralph Loop button - golden/yellow theme */
|
||||
.btn-toolbar.btn-ralph {
|
||||
background: linear-gradient(135deg, #3d3415 0%, #4a3f1a 100%);
|
||||
border-color: #6b5a1e;
|
||||
color: #eab308;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-toolbar.btn-ralph:hover {
|
||||
background: linear-gradient(135deg, #4a3f1a 0%, #5a4d20 100%);
|
||||
border-color: #8b7a2e;
|
||||
box-shadow: 0 0 8px rgba(234, 179, 8, 0.4);
|
||||
color: #fcd34d;
|
||||
}
|
||||
|
||||
/* Button icon styling */
|
||||
.btn-toolbar .btn-icon {
|
||||
display: inline-flex;
|
||||
@@ -1779,18 +1890,19 @@ body {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
|
||||
.tab-count-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 28px;
|
||||
width: 22px;
|
||||
height: 26px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.9rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
@@ -1807,14 +1919,14 @@ body {
|
||||
}
|
||||
|
||||
.tab-count-input {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
width: 24px;
|
||||
height: 26px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-left: 1px solid var(--border);
|
||||
border-right: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
font-size: 0.8rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-align: center;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
@@ -3188,31 +3300,6 @@ kbd {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Help Button */
|
||||
.help-btn {
|
||||
position: fixed;
|
||||
bottom: calc(var(--toolbar-height) + 0.5rem);
|
||||
right: 0.5rem;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.help-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Toast Notifications */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
|
||||
+12
-3
@@ -4942,19 +4942,28 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
|
||||
const screens = this.screenManager.getScreens();
|
||||
for (const screen of screens) {
|
||||
if (!this.sessions.has(screen.sessionId)) {
|
||||
// Restore session settings from state.json (single source of truth)
|
||||
const savedState = this.store.getSession(screen.sessionId);
|
||||
|
||||
// Determine the correct session name (priority: savedState > screen > screenName)
|
||||
// This ensures renamed sessions keep their name after server restart
|
||||
const sessionName = savedState?.name || screen.name || screen.screenName;
|
||||
|
||||
// Create a session object for this screen with the existing screenSession
|
||||
const session = new Session({
|
||||
id: screen.sessionId, // Preserve the original session ID
|
||||
workingDir: screen.workingDir,
|
||||
mode: screen.mode,
|
||||
name: screen.name || screen.screenName,
|
||||
name: sessionName,
|
||||
screenManager: this.screenManager,
|
||||
useScreen: true,
|
||||
screenSession: screen // Pass the existing screen so startInteractive() can attach to it
|
||||
});
|
||||
|
||||
// Restore ALL session settings from state.json (single source of truth)
|
||||
const savedState = this.store.getSession(screen.sessionId);
|
||||
// Update screen name if it was a "Restored:" placeholder or doesn't match saved name
|
||||
if (savedState?.name && screen.name !== savedState.name) {
|
||||
this.screenManager.updateScreenName(screen.sessionId, savedState.name);
|
||||
}
|
||||
if (savedState) {
|
||||
// Auto-compact
|
||||
if (savedState.autoCompactEnabled !== undefined || savedState.autoCompactThreshold !== undefined) {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
+1
-3
@@ -14,8 +14,6 @@
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"jsx": "react-jsx",
|
||||
"jsxImportSource": "react",
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noImplicitReturns": true,
|
||||
@@ -25,5 +23,5 @@
|
||||
"allowUnusedLabels": false
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
"exclude": ["node_modules", "dist", "src/tui"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user