mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 07:29: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
|
# Temporary files
|
||||||
*.tmp
|
*.tmp
|
||||||
*.temp
|
*.temp
|
||||||
|
|
||||||
|
# Unfinished TUI (local development only)
|
||||||
|
src/tui/
|
||||||
.claude/
|
.claude/
|
||||||
media-assets/
|
media-assets/
|
||||||
commands
|
commands
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ When user says "COM":
|
|||||||
1. Increment version in BOTH `package.json` AND `CLAUDE.md`
|
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`
|
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
|
## Project Overview
|
||||||
|
|
||||||
|
|||||||
+1
-7
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "claudeman",
|
"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",
|
"description": "The missing control plane for Claude Code - run 20 autonomous agents with real-time monitoring and session persistence",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
@@ -14,7 +14,6 @@
|
|||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
"dev": "tsx src/index.ts",
|
"dev": "tsx src/index.ts",
|
||||||
"web": "node dist/index.js web",
|
"web": "node dist/index.js web",
|
||||||
"tui": "tsx src/index.ts tui",
|
|
||||||
"clean": "rm -rf dist",
|
"clean": "rm -rf dist",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
@@ -45,18 +44,13 @@
|
|||||||
"chokidar": "^3.6.0",
|
"chokidar": "^3.6.0",
|
||||||
"commander": "^12.1.0",
|
"commander": "^12.1.0",
|
||||||
"fastify": "^5.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",
|
"node-pty": "^1.1.0",
|
||||||
"react": "^19.2.3",
|
|
||||||
"uuid": "^10.0.0",
|
"uuid": "^10.0.0",
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20.14.0",
|
"@types/node": "^20.14.0",
|
||||||
"@types/pngjs": "^6.0.5",
|
"@types/pngjs": "^6.0.5",
|
||||||
"@types/react": "^19.2.9",
|
|
||||||
"@types/uuid": "^10.0.0",
|
"@types/uuid": "^10.0.0",
|
||||||
"@vitest/coverage-v8": "^4.0.18",
|
"@vitest/coverage-v8": "^4.0.18",
|
||||||
"agent-browser": "^0.6.0",
|
"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 };
|
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 {
|
class ClaudemanApp {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.sessions = new Map();
|
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.cases = [];
|
||||||
this.currentRun = null;
|
this.currentRun = null;
|
||||||
this.totalTokens = 0;
|
this.totalTokens = 0;
|
||||||
@@ -842,14 +844,17 @@ class ClaudemanApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
showWelcome() {
|
showWelcome() {
|
||||||
this.terminal.writeln('\x1b[1;36m Claudeman Terminal\x1b[0m');
|
const overlay = document.getElementById('welcomeOverlay');
|
||||||
this.terminal.writeln('');
|
if (overlay) {
|
||||||
this.terminal.writeln('\x1b[90m Each instance opens a persistent GNU Screen session running Claude or Shell.\x1b[0m');
|
overlay.classList.add('visible');
|
||||||
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');
|
hideWelcome() {
|
||||||
this.terminal.writeln('');
|
const overlay = document.getElementById('welcomeOverlay');
|
||||||
|
if (overlay) {
|
||||||
|
overlay.classList.remove('visible');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1113,6 +1118,11 @@ class ClaudemanApp {
|
|||||||
addListener('session:created', (e) => {
|
addListener('session:created', (e) => {
|
||||||
const data = JSON.parse(e.data);
|
const data = JSON.parse(e.data);
|
||||||
this.sessions.set(data.id, 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.renderSessionTabs();
|
||||||
this.updateCost();
|
this.updateCost();
|
||||||
});
|
});
|
||||||
@@ -1120,6 +1130,8 @@ class ClaudemanApp {
|
|||||||
addListener('session:updated', (e) => {
|
addListener('session:updated', (e) => {
|
||||||
const data = JSON.parse(e.data);
|
const data = JSON.parse(e.data);
|
||||||
const session = data.session || 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.sessions.set(session.id, session);
|
||||||
this.renderSessionTabs();
|
this.renderSessionTabs();
|
||||||
this.updateCost();
|
this.updateCost();
|
||||||
@@ -1130,11 +1142,22 @@ class ClaudemanApp {
|
|||||||
// Update parentSessionName for any subagents belonging to this session
|
// Update parentSessionName for any subagents belonging to this session
|
||||||
// (fixes stale name display after session rename)
|
// (fixes stale name display after session rename)
|
||||||
this.updateSubagentParentNames(session.id);
|
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) => {
|
addListener('session:deleted', (e) => {
|
||||||
const data = JSON.parse(e.data);
|
const data = JSON.parse(e.data);
|
||||||
this.sessions.delete(data.id);
|
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.terminalBuffers.delete(data.id);
|
||||||
this.ralphStates.delete(data.id); // Clean up ralph state for this session
|
this.ralphStates.delete(data.id); // Clean up ralph state for this session
|
||||||
this.projectInsights.delete(data.id); // Clean up project insights 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) ==========
|
// ========== Subagent Events (Claude Code Background Agents) ==========
|
||||||
|
|
||||||
addListener('subagent:discovered', async (e) => {
|
addListener('subagent:discovered', (e) => {
|
||||||
const data = JSON.parse(e.data);
|
const data = JSON.parse(e.data);
|
||||||
// Clear all old data for this agentId (in case of ID reuse)
|
// Clear all old data for this agentId (in case of ID reuse)
|
||||||
this.subagents.set(data.agentId, data);
|
this.subagents.set(data.agentId, data);
|
||||||
@@ -1802,8 +1825,8 @@ class ClaudemanApp {
|
|||||||
}
|
}
|
||||||
this.renderSubagentPanel();
|
this.renderSubagentPanel();
|
||||||
|
|
||||||
// Find which Claudeman session owns this subagent (by working directory)
|
// Find which Claudeman session owns this subagent (direct claudeSessionId match only)
|
||||||
await this.findParentSessionForSubagent(data.agentId);
|
this.findParentSessionForSubagent(data.agentId);
|
||||||
|
|
||||||
// Auto-open window for new active agents
|
// Auto-open window for new active agents
|
||||||
if (data.status === 'active') {
|
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) {
|
if (data.respawnStatus) {
|
||||||
this.respawnStatus = data.respawnStatus;
|
this.respawnStatus = data.respawnStatus;
|
||||||
} else {
|
} else {
|
||||||
@@ -2111,10 +2137,9 @@ class ClaudemanApp {
|
|||||||
this.restoreSubagentWindowStates();
|
this.restoreSubagentWindowStates();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto-select first session if any
|
// Auto-select first session if any (use sessionOrder for user's preferred order)
|
||||||
if (this.sessions.size > 0 && !this.activeSessionId) {
|
if (this.sessionOrder.length > 0 && !this.activeSessionId) {
|
||||||
const firstSession = this.sessions.values().next().value;
|
this.selectSession(this.sessionOrder[0]);
|
||||||
this.selectSession(firstSession.id);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2225,11 +2250,6 @@ class ClaudemanApp {
|
|||||||
this._fullRenderSessionTabs();
|
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() {
|
_fullRenderSessionTabs() {
|
||||||
@@ -2240,8 +2260,12 @@ class ClaudemanApp {
|
|||||||
this.cancelHideSubagentDropdown();
|
this.cancelHideSubagentDropdown();
|
||||||
|
|
||||||
// Build tabs HTML using array for better string concatenation performance
|
// Build tabs HTML using array for better string concatenation performance
|
||||||
|
// Iterate in sessionOrder to respect user's custom tab arrangement
|
||||||
const parts = [];
|
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 isActive = id === this.activeSessionId;
|
||||||
const status = session.status || 'idle';
|
const status = session.status || 'idle';
|
||||||
const name = this.getSessionName(session);
|
const name = this.getSessionName(session);
|
||||||
@@ -2268,6 +2292,10 @@ class ClaudemanApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = parts.join('');
|
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)
|
// Update connection lines after tabs change (positions may have shifted)
|
||||||
this.updateConnectionLines();
|
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
|
// Restore a minimized subagent window
|
||||||
restoreMinimizedSubagent(agentId, sessionId) {
|
restoreMinimizedSubagent(agentId, sessionId) {
|
||||||
// Remove from minimized set
|
// Remove from minimized set
|
||||||
@@ -2443,6 +2594,7 @@ class ClaudemanApp {
|
|||||||
if (this.activeSessionId === sessionId) return;
|
if (this.activeSessionId === sessionId) return;
|
||||||
|
|
||||||
this.activeSessionId = sessionId;
|
this.activeSessionId = sessionId;
|
||||||
|
this.hideWelcome();
|
||||||
// Clear idle hooks on view, but keep action hooks until user interacts
|
// Clear idle hooks on view, but keep action hooks until user interacts
|
||||||
this.clearPendingHooks(sessionId, 'idle_prompt');
|
this.clearPendingHooks(sessionId, 'idle_prompt');
|
||||||
this.renderSessionTabs();
|
this.renderSessionTabs();
|
||||||
@@ -2552,16 +2704,22 @@ class ClaudemanApp {
|
|||||||
try {
|
try {
|
||||||
await fetch(`/api/sessions/${sessionId}?killScreen=${killScreen}`, { method: 'DELETE' });
|
await fetch(`/api/sessions/${sessionId}?killScreen=${killScreen}`, { method: 'DELETE' });
|
||||||
this.sessions.delete(sessionId);
|
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.terminalBuffers.delete(sessionId);
|
||||||
this.ralphStates.delete(sessionId);
|
this.ralphStates.delete(sessionId);
|
||||||
this.clearCountdownTimers(sessionId);
|
this.clearCountdownTimers(sessionId);
|
||||||
|
|
||||||
if (this.activeSessionId === sessionId) {
|
if (this.activeSessionId === sessionId) {
|
||||||
this.activeSessionId = null;
|
this.activeSessionId = null;
|
||||||
// Select another session or show welcome
|
// Select another session or show welcome (use sessionOrder for consistent ordering)
|
||||||
if (this.sessions.size > 0) {
|
if (this.sessionOrder.length > 0 && this.sessions.size > 0) {
|
||||||
const nextSession = this.sessions.values().next().value;
|
const nextSessionId = this.sessionOrder[0];
|
||||||
this.selectSession(nextSession.id);
|
this.selectSession(nextSessionId);
|
||||||
} else {
|
} else {
|
||||||
this.terminal.clear();
|
this.terminal.clear();
|
||||||
this.showWelcome();
|
this.showWelcome();
|
||||||
@@ -2611,12 +2769,11 @@ class ClaudemanApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
nextSession() {
|
nextSession() {
|
||||||
const ids = Array.from(this.sessions.keys());
|
if (this.sessionOrder.length <= 1) return;
|
||||||
if (ids.length <= 1) return;
|
|
||||||
|
|
||||||
const currentIndex = ids.indexOf(this.activeSessionId);
|
const currentIndex = this.sessionOrder.indexOf(this.activeSessionId);
|
||||||
const nextIndex = (currentIndex + 1) % ids.length;
|
const nextIndex = (currentIndex + 1) % this.sessionOrder.length;
|
||||||
this.selectSession(ids[nextIndex]);
|
this.selectSession(this.sessionOrder[nextIndex]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== Navigation ==========
|
// ========== Navigation ==========
|
||||||
@@ -7442,15 +7599,11 @@ class ClaudemanApp {
|
|||||||
|
|
||||||
// First, discover parent sessions for all subagents to establish correct mapping
|
// First, discover parent sessions for all subagents to establish correct mapping
|
||||||
// This is important after server restart when parentSessionId isn't set
|
// This is important after server restart when parentSessionId isn't set
|
||||||
const parentDiscoveryPromises = [];
|
|
||||||
for (const [agentId, agent] of this.subagents) {
|
for (const [agentId, agent] of this.subagents) {
|
||||||
if (!agent.parentSessionId) {
|
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
|
// Restore minimized state, but verify session mapping is correct
|
||||||
// Skip old agents from previous runs to avoid confusion
|
// Skip old agents from previous runs to avoid confusion
|
||||||
@@ -9239,92 +9392,50 @@ class ClaudemanApp {
|
|||||||
|
|
||||||
// ========== Subagent Parent Session Tracking ==========
|
// ========== 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);
|
const agent = this.subagents.get(agentId);
|
||||||
if (!agent) return;
|
if (!agent) return;
|
||||||
|
|
||||||
// Strategy 1: Check if another subagent with the same Claude sessionId already has a parent
|
// Can't match without a sessionId
|
||||||
// This ensures subagents from the same Claude session go to the same Claudeman session
|
if (!agent.sessionId) return;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Strategy 2: Match by claudeSessionId (most reliable)
|
// Direct match: subagent.sessionId === session.claudeSessionId
|
||||||
// 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 = [];
|
|
||||||
for (const [sessionId, session] of this.sessions) {
|
for (const [sessionId, session] of this.sessions) {
|
||||||
try {
|
if (session.claudeSessionId === agent.sessionId) {
|
||||||
const resp = await fetch(`/api/sessions/${sessionId}/subagents`);
|
agent.parentSessionId = sessionId;
|
||||||
if (!resp.ok) continue;
|
agent.parentSessionName = this.getSessionName(session);
|
||||||
const result = await resp.json();
|
this.subagents.set(agentId, agent);
|
||||||
const subagents = result.data || result.subagents || [];
|
this.updateSubagentWindowParent(agentId);
|
||||||
if (subagents.some(s => s.agentId === agentId)) {
|
this.updateSubagentWindowVisibility();
|
||||||
matchingSessions.push({ sessionId, session });
|
return;
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
// Ignore errors
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (matchingSessions.length === 0) {
|
// No match found - the session's claudeSessionId might not be set yet.
|
||||||
// No matching session found
|
// When session:updated fires with claudeSessionId, we'll re-check orphans.
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// If only one session matches, use it
|
/**
|
||||||
let 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.
|
||||||
if (matchingSessions.length > 1) {
|
*/
|
||||||
// Multiple sessions with same workingDir - prefer session with matching claudeSessionId
|
recheckOrphanSubagents() {
|
||||||
// If no match, fall back to oldest session (first created is more likely the original)
|
for (const [agentId, agent] of this.subagents) {
|
||||||
const byClaudeSession = matchingSessions.find(m => m.session.claudeSessionId === agent.sessionId);
|
if (!agent.parentSessionId && agent.sessionId) {
|
||||||
if (byClaudeSession) {
|
this.findParentSessionForSubagent(agentId);
|
||||||
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];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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();
|
this.saveSubagentWindowStates();
|
||||||
}
|
}
|
||||||
|
|
||||||
async closeSubagentWindow(agentId) {
|
closeSubagentWindow(agentId) {
|
||||||
const windowData = this.subagentWindows.get(agentId);
|
const windowData = this.subagentWindows.get(agentId);
|
||||||
if (!windowData) return;
|
if (!windowData) return;
|
||||||
|
|
||||||
@@ -9830,12 +9941,11 @@ class ClaudemanApp {
|
|||||||
// Try to discover parent session if not already known
|
// Try to discover parent session if not already known
|
||||||
// This ensures we minimize to the correct tab
|
// This ensures we minimize to the correct tab
|
||||||
if (agent && !agent.parentSessionId) {
|
if (agent && !agent.parentSessionId) {
|
||||||
await this.findParentSessionForSubagent(agentId);
|
this.findParentSessionForSubagent(agentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now get the correct parent session (re-read agent after discovery)
|
// Get the parent session (use active session if no parent found)
|
||||||
const updatedAgent = this.subagents.get(agentId);
|
const parentSessionId = agent?.parentSessionId || this.activeSessionId;
|
||||||
const parentSessionId = updatedAgent?.parentSessionId || this.activeSessionId;
|
|
||||||
|
|
||||||
// Always minimize to tab (use active session if no parent found)
|
// Always minimize to tab (use active session if no parent found)
|
||||||
windowData.element.style.display = 'none';
|
windowData.element.style.display = 'none';
|
||||||
|
|||||||
+27
-12
@@ -190,6 +190,26 @@
|
|||||||
<!-- Main Terminal Area -->
|
<!-- Main Terminal Area -->
|
||||||
<main class="main">
|
<main class="main">
|
||||||
<div class="terminal-container" id="terminalContainer"></div>
|
<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>
|
</main>
|
||||||
|
|
||||||
<!-- Project Insights Panel (shows file-viewing Bash commands) -->
|
<!-- Project Insights Panel (shows file-viewing Bash commands) -->
|
||||||
@@ -242,24 +262,22 @@
|
|||||||
<!-- Run Claude -->
|
<!-- Run Claude -->
|
||||||
<div class="toolbar-group">
|
<div class="toolbar-group">
|
||||||
<button class="btn-toolbar btn-claude" onclick="app.runClaude()" title="Run Claude (Ctrl+Enter)">
|
<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
|
Run Claude
|
||||||
</button>
|
</button>
|
||||||
<div class="tab-count-group" title="Instance count">
|
<div class="tab-count-group" title="Instance count">
|
||||||
<button class="tab-count-btn" onclick="app.decrementTabCount()" 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 title="Instance count">
|
<input type="number" id="tabCount" class="tab-count-input" value="1" min="1" max="20" readonly>
|
||||||
<button class="tab-count-btn" onclick="app.incrementTabCount()" title="Instance count">+</button>
|
<button class="tab-count-btn" onclick="app.incrementTabCount()">+</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-toolbar btn-shell" onclick="app.runShell()" title="Run Shell">
|
<button class="btn-toolbar btn-shell" onclick="app.runShell()" title="Run Shell">
|
||||||
Run Shell
|
Run Shell
|
||||||
</button>
|
</button>
|
||||||
<div class="tab-count-group" title="Instance count">
|
<div class="tab-count-group" title="Instance count">
|
||||||
<button class="tab-count-btn" onclick="app.decrementShellCount()" 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 title="Instance count">
|
<input type="number" id="shellCount" class="tab-count-input" value="1" min="1" max="20" readonly>
|
||||||
<button class="tab-count-btn" onclick="app.incrementShellCount()" title="Instance count">+</button>
|
<button class="tab-count-btn" onclick="app.incrementShellCount()">+</button>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn-toolbar btn-ralph" onclick="app.showRalphWizard()" title="Start Ralph Loop">
|
|
||||||
Ralph Loop
|
|
||||||
</button>
|
|
||||||
<div class="case-select-group">
|
<div class="case-select-group">
|
||||||
<select id="quickStartCase" class="toolbar-select" title="Select case">
|
<select id="quickStartCase" class="toolbar-select" title="Select case">
|
||||||
<option value="testcase">testcase</option>
|
<option value="testcase">testcase</option>
|
||||||
@@ -1295,9 +1313,6 @@
|
|||||||
<div class="notif-drawer-empty" id="notifEmpty">No notifications</div>
|
<div class="notif-drawer-empty" id="notifEmpty">No notifications</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Help Button -->
|
|
||||||
<button class="help-btn" onclick="app.showHelp()" title="Keyboard shortcuts (Ctrl+?)">?</button>
|
|
||||||
|
|
||||||
<!-- Token Stats Modal -->
|
<!-- Token Stats Modal -->
|
||||||
<div class="modal" id="tokenStatsModal">
|
<div class="modal" id="tokenStatsModal">
|
||||||
<div class="modal-backdrop" onclick="app.closeTokenStats()"></div>
|
<div class="modal-backdrop" onclick="app.closeTokenStats()"></div>
|
||||||
|
|||||||
+144
-57
@@ -215,6 +215,24 @@ body {
|
|||||||
50% { opacity: 0.4; }
|
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 */
|
||||||
.header-right {
|
.header-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1502,6 +1520,7 @@ body {
|
|||||||
display: flex;
|
display: flex;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: var(--bg-dark);
|
background: var(--bg-dark);
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.terminal-container {
|
.terminal-container {
|
||||||
@@ -1522,6 +1541,111 @@ body {
|
|||||||
background: transparent !important;
|
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 */
|
/* Bottom Toolbar */
|
||||||
.toolbar {
|
.toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1539,7 +1663,7 @@ body {
|
|||||||
.toolbar-right {
|
.toolbar-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.25rem;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toolbar-center {
|
.toolbar-center {
|
||||||
@@ -1608,23 +1732,25 @@ body {
|
|||||||
background: var(--accent-hover);
|
background: var(--accent-hover);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Claude Button - Special styling */
|
/* Claude Button - Special styling (matches welcome-btn-primary) */
|
||||||
.btn-toolbar.btn-claude {
|
.btn-toolbar.btn-claude {
|
||||||
background: linear-gradient(135deg, #1e3a5f 0%, #3d2a5c 100%);
|
background: linear-gradient(135deg, #1e3a5f 0%, #2563eb 100%);
|
||||||
border-color: #2d4a6f;
|
border-color: #3b82f6;
|
||||||
color: var(--accent-hover);
|
color: #e0e7ff;
|
||||||
font-weight: 700;
|
font-weight: 500;
|
||||||
padding: 0.4rem 0.85rem;
|
padding: 0.4rem 0.85rem;
|
||||||
|
gap: 0.4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-toolbar.btn-claude:hover {
|
.btn-toolbar.btn-claude:hover {
|
||||||
background: linear-gradient(135deg, #2a4d7a 0%, #4d3a6c 100%);
|
background: linear-gradient(135deg, #2563eb 0%, #3b82f6 100%);
|
||||||
border-color: #3d5a7f;
|
border-color: #3b82f6;
|
||||||
box-shadow: 0 0 12px rgba(61, 90, 127, 0.3);
|
box-shadow: 0 0 12px rgba(59, 130, 246, 0.4);
|
||||||
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-toolbar.btn-claude .btn-icon {
|
.btn-toolbar.btn-claude svg {
|
||||||
font-size: 0.9rem;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Shell Button - Special styling */
|
/* Shell Button - Special styling */
|
||||||
@@ -1642,21 +1768,6 @@ body {
|
|||||||
color: #a8d8a8;
|
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 */
|
/* Button icon styling */
|
||||||
.btn-toolbar .btn-icon {
|
.btn-toolbar .btn-icon {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
@@ -1779,18 +1890,19 @@ body {
|
|||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
margin: 0 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab-count-btn {
|
.tab-count-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 24px;
|
width: 22px;
|
||||||
height: 28px;
|
height: 26px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
font-size: 0.9rem;
|
font-size: 0.85rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
@@ -1807,14 +1919,14 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tab-count-input {
|
.tab-count-input {
|
||||||
width: 28px;
|
width: 24px;
|
||||||
height: 28px;
|
height: 26px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
border-left: 1px solid var(--border);
|
border-left: 1px solid var(--border);
|
||||||
border-right: 1px solid var(--border);
|
border-right: 1px solid var(--border);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
font-size: 0.8rem;
|
font-size: 0.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-family: 'SF Mono', Monaco, monospace;
|
font-family: 'SF Mono', Monaco, monospace;
|
||||||
@@ -3188,31 +3300,6 @@ kbd {
|
|||||||
color: var(--text);
|
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 Notifications */
|
||||||
.toast-container {
|
.toast-container {
|
||||||
position: fixed;
|
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();
|
const screens = this.screenManager.getScreens();
|
||||||
for (const screen of screens) {
|
for (const screen of screens) {
|
||||||
if (!this.sessions.has(screen.sessionId)) {
|
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
|
// Create a session object for this screen with the existing screenSession
|
||||||
const session = new Session({
|
const session = new Session({
|
||||||
id: screen.sessionId, // Preserve the original session ID
|
id: screen.sessionId, // Preserve the original session ID
|
||||||
workingDir: screen.workingDir,
|
workingDir: screen.workingDir,
|
||||||
mode: screen.mode,
|
mode: screen.mode,
|
||||||
name: screen.name || screen.screenName,
|
name: sessionName,
|
||||||
screenManager: this.screenManager,
|
screenManager: this.screenManager,
|
||||||
useScreen: true,
|
useScreen: true,
|
||||||
screenSession: screen // Pass the existing screen so startInteractive() can attach to it
|
screenSession: screen // Pass the existing screen so startInteractive() can attach to it
|
||||||
});
|
});
|
||||||
|
|
||||||
// Restore ALL session settings from state.json (single source of truth)
|
// Update screen name if it was a "Restored:" placeholder or doesn't match saved name
|
||||||
const savedState = this.store.getSession(screen.sessionId);
|
if (savedState?.name && screen.name !== savedState.name) {
|
||||||
|
this.screenManager.updateScreenName(screen.sessionId, savedState.name);
|
||||||
|
}
|
||||||
if (savedState) {
|
if (savedState) {
|
||||||
// Auto-compact
|
// Auto-compact
|
||||||
if (savedState.autoCompactEnabled !== undefined || savedState.autoCompactThreshold !== undefined) {
|
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,
|
"declaration": true,
|
||||||
"declarationMap": true,
|
"declarationMap": true,
|
||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"jsx": "react-jsx",
|
|
||||||
"jsxImportSource": "react",
|
|
||||||
"noUnusedLocals": true,
|
"noUnusedLocals": true,
|
||||||
"noUnusedParameters": true,
|
"noUnusedParameters": true,
|
||||||
"noImplicitReturns": true,
|
"noImplicitReturns": true,
|
||||||
@@ -25,5 +23,5 @@
|
|||||||
"allowUnusedLabels": false
|
"allowUnusedLabels": false
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist", "src/tui"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user