mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 14:39:42 +02:00
fix(tui): resolve screen attach ambiguity with multiple sessions
- Add getFullScreenId() to get PID.screenName format for unambiguous attach - Update DirectAttach.ts and index.tsx to use full screen IDs - Remove TUI documentation from README (still in development) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
+119
-42
@@ -25,7 +25,6 @@
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Box, useApp, useInput, useStdout } from 'ink';
|
||||
import { spawnSync } from 'child_process';
|
||||
import { StartScreen } from './components/StartScreen.js';
|
||||
import { TabBar } from './components/TabBar.js';
|
||||
import { TerminalView } from './components/TerminalView.js';
|
||||
@@ -35,6 +34,44 @@ import { RalphPanel } from './components/RalphPanel.js';
|
||||
import { useSessionManager } from './hooks/useSessionManager.js';
|
||||
import type { ScreenSession } from '../types.js';
|
||||
|
||||
/**
|
||||
* Pending screen attachment request.
|
||||
* Used to communicate between App component and the entry point.
|
||||
*/
|
||||
export interface PendingAttach {
|
||||
mode: 'tabs' | 'direct';
|
||||
sessions: ScreenSession[];
|
||||
index: number;
|
||||
session?: ScreenSession;
|
||||
}
|
||||
|
||||
// Module-level state for pending attachment (shared between App and entry point)
|
||||
let pendingAttach: PendingAttach | null = null;
|
||||
|
||||
/**
|
||||
* Set a pending screen attachment request.
|
||||
* Called by App when user wants to attach to a screen.
|
||||
*/
|
||||
export function setPendingAttach(attach: PendingAttach): void {
|
||||
pendingAttach = attach;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current pending attachment request.
|
||||
* Called by entry point after App exits.
|
||||
*/
|
||||
export function getPendingAttach(): PendingAttach | null {
|
||||
return pendingAttach;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the pending attachment request.
|
||||
* Called by entry point after handling the attachment.
|
||||
*/
|
||||
export function clearPendingAttach(): void {
|
||||
pendingAttach = null;
|
||||
}
|
||||
|
||||
type ViewMode = 'start' | 'main';
|
||||
|
||||
/**
|
||||
@@ -83,6 +120,7 @@ export function App(): React.ReactElement {
|
||||
innerTodos,
|
||||
respawnStatus,
|
||||
cases,
|
||||
lastUsedCase,
|
||||
toggleRespawn,
|
||||
renameSession,
|
||||
} = useSessionManager();
|
||||
@@ -106,14 +144,14 @@ export function App(): React.ReactElement {
|
||||
useInput((input, key) => {
|
||||
// Help overlay takes priority
|
||||
if (showHelp) {
|
||||
if (key.escape || input === 'q' || input === '?') {
|
||||
if (key.escape || input === 'q') {
|
||||
setShowHelp(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Global shortcuts
|
||||
if (input === '?' || (key.ctrl && input === 'h')) {
|
||||
// Global shortcuts - use Ctrl+H for help (? is a normal character)
|
||||
if (key.ctrl && input === 'h') {
|
||||
setShowHelp(true);
|
||||
return;
|
||||
}
|
||||
@@ -137,17 +175,8 @@ export function App(): React.ReactElement {
|
||||
|
||||
// === SESSION SWITCHING SHORTCUTS ===
|
||||
|
||||
// Tab / Shift+Tab to switch sessions (most intuitive)
|
||||
if (key.tab) {
|
||||
if (key.shift) {
|
||||
prevSession();
|
||||
} else {
|
||||
nextSession();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+Tab / Ctrl+Shift+Tab (if terminal supports it)
|
||||
// Ctrl+Tab / Ctrl+Shift+Tab for session switching (if terminal supports it)
|
||||
// Plain Tab is sent to the session for auto-complete
|
||||
if (key.ctrl && key.tab) {
|
||||
if (key.shift) {
|
||||
prevSession();
|
||||
@@ -167,12 +196,13 @@ export function App(): React.ReactElement {
|
||||
return;
|
||||
}
|
||||
|
||||
// [ and ] for previous/next session (vim-like)
|
||||
if (input === '[' && !key.ctrl && !key.meta) {
|
||||
// Ctrl+[ and Ctrl+] for previous/next session (vim-style, requires Ctrl)
|
||||
// Note: Ctrl+[ is often Escape, so this may not work on all terminals
|
||||
if (key.ctrl && input === '[') {
|
||||
prevSession();
|
||||
return;
|
||||
}
|
||||
if (input === ']' && !key.ctrl && !key.meta) {
|
||||
if (key.ctrl && input === ']') {
|
||||
nextSession();
|
||||
return;
|
||||
}
|
||||
@@ -238,6 +268,11 @@ export function App(): React.ReactElement {
|
||||
sendInput(activeSessionId, '\x7f');
|
||||
return;
|
||||
}
|
||||
// Tab key - send to session for auto-complete
|
||||
if (key.tab && !key.ctrl) {
|
||||
sendInput(activeSessionId, '\t');
|
||||
return;
|
||||
}
|
||||
// Arrow keys - send ANSI escape sequences
|
||||
if (key.upArrow) {
|
||||
sendInput(activeSessionId, '\x1b[A');
|
||||
@@ -272,54 +307,95 @@ export function App(): React.ReactElement {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Select a session and enter direct attach mode with tab bar.
|
||||
* Sets pending attachment and exits Ink so the entry point can handle it.
|
||||
*/
|
||||
const handleSelectSession = useCallback((session: ScreenSession) => {
|
||||
selectSession(session.sessionId);
|
||||
setViewMode('main');
|
||||
}, [selectSession]);
|
||||
const sessionIndex = sessions.findIndex(s => s.sessionId === session.sessionId);
|
||||
if (sessionIndex === -1) return;
|
||||
|
||||
// Set pending attachment and exit Ink
|
||||
// The entry point will handle the actual screen attachment
|
||||
setPendingAttach({
|
||||
mode: 'tabs',
|
||||
sessions: [...sessions], // Copy to avoid stale reference
|
||||
index: sessionIndex,
|
||||
});
|
||||
exit();
|
||||
}, [sessions, exit]);
|
||||
|
||||
const handleCreateSession = useCallback(async (caseName?: string, count?: number, mode: 'claude' | 'shell' = 'claude') => {
|
||||
// Default to 'default' case if no case name provided (like web UI)
|
||||
const sessionsToCreate = Math.min(Math.max(count || 1, 1), 20);
|
||||
let lastSessionId: string | null = null;
|
||||
|
||||
// Create sessions sequentially to avoid overwhelming the server
|
||||
// Create sessions sequentially
|
||||
for (let i = 0; i < sessionsToCreate; i++) {
|
||||
const sessionId = await createSession(caseName || 'default', mode);
|
||||
if (sessionId) {
|
||||
lastSessionId = sessionId;
|
||||
// Wait a moment for screen to be ready
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
refreshSessions();
|
||||
}
|
||||
// Small delay between session creations to allow server to process
|
||||
if (i < sessionsToCreate - 1) {
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh to get updated session list
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
refreshSessions();
|
||||
|
||||
// Wait for state to update, then attach to the new session
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
if (lastSessionId) {
|
||||
setViewMode('main');
|
||||
// Re-fetch sessions to get the fresh list with the new session
|
||||
// We need to read the screens file directly since state may not have updated yet
|
||||
const { existsSync, readFileSync } = await import('fs');
|
||||
const { homedir } = await import('os');
|
||||
const { join } = await import('path');
|
||||
const screensFile = join(homedir(), '.claudeman', 'screens.json');
|
||||
|
||||
let freshSessions: ScreenSession[] = [];
|
||||
try {
|
||||
if (existsSync(screensFile)) {
|
||||
freshSessions = JSON.parse(readFileSync(screensFile, 'utf-8'));
|
||||
}
|
||||
} catch {
|
||||
freshSessions = sessions;
|
||||
}
|
||||
|
||||
if (freshSessions.length > 0) {
|
||||
const sessionIndex = freshSessions.findIndex(s => s.sessionId === lastSessionId);
|
||||
const targetIndex = sessionIndex >= 0 ? sessionIndex : freshSessions.length - 1;
|
||||
|
||||
// Set pending attachment and exit Ink
|
||||
setPendingAttach({
|
||||
mode: 'tabs',
|
||||
sessions: freshSessions,
|
||||
index: targetIndex,
|
||||
});
|
||||
exit();
|
||||
}
|
||||
}
|
||||
}, [createSession]);
|
||||
}, [createSession, refreshSessions, sessions, exit]);
|
||||
|
||||
/**
|
||||
* Attach directly to a screen session
|
||||
* This exits the TUI and attaches to GNU screen
|
||||
* Attach directly to a screen session (skipping tab menu).
|
||||
* Sets pending attachment and exits Ink so the entry point can handle it.
|
||||
*/
|
||||
const handleAttachSession = useCallback((session: ScreenSession) => {
|
||||
// Clear screen and restore terminal
|
||||
process.stdout.write('\x1b[2J\x1b[H');
|
||||
console.log(`Attaching to screen: ${session.screenName}`);
|
||||
console.log('Press Ctrl+A D to detach and return to terminal\n');
|
||||
|
||||
// Use spawnSync to attach to screen (inherits stdio)
|
||||
const result = spawnSync('screen', ['-r', session.screenName], {
|
||||
stdio: 'inherit',
|
||||
// Set pending attachment for direct mode and exit Ink
|
||||
setPendingAttach({
|
||||
mode: 'direct',
|
||||
sessions: [...sessions],
|
||||
index: sessions.findIndex(s => s.sessionId === session.sessionId),
|
||||
session: session,
|
||||
});
|
||||
|
||||
// After detaching, exit the TUI
|
||||
if (result.status === 0) {
|
||||
console.log('\nDetached from screen. Run "claudeman tui" to return.');
|
||||
}
|
||||
exit();
|
||||
}, [exit]);
|
||||
}, [sessions, exit]);
|
||||
|
||||
// Render help overlay if shown
|
||||
if (showHelp) {
|
||||
@@ -337,6 +413,7 @@ export function App(): React.ReactElement {
|
||||
<StartScreen
|
||||
sessions={sessions}
|
||||
cases={cases}
|
||||
lastUsedCase={lastUsedCase}
|
||||
onSelectSession={handleSelectSession}
|
||||
onAttachSession={handleAttachSession}
|
||||
onDeleteSession={handleDeleteSession}
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* @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 BG_GRAY = `${CSI}48;5;238m`;
|
||||
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();
|
||||
}
|
||||
@@ -30,8 +30,8 @@ const SHORTCUT_GROUPS: ShortcutGroup[] = [
|
||||
title: 'Start Screen - Sessions',
|
||||
shortcuts: [
|
||||
{ key: '\u2191/\u2193', description: 'Navigate list' },
|
||||
{ key: 'Enter', description: 'View session in TUI' },
|
||||
{ key: 'a', description: 'Attach to screen (full terminal)' },
|
||||
{ key: 'Enter', description: 'Tab switcher \u2192 full attach' },
|
||||
{ key: 'a', description: 'Direct attach (skip tab menu)' },
|
||||
{ key: 'd', description: 'Delete/kill selected session' },
|
||||
{ key: 'D (Shift+d)', description: 'Delete ALL screens & Claude processes' },
|
||||
{ key: 'c', description: 'Switch to cases view' },
|
||||
@@ -53,27 +53,18 @@ const SHORTCUT_GROUPS: ShortcutGroup[] = [
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Main View - Navigation',
|
||||
title: 'Tab Switcher Menu',
|
||||
shortcuts: [
|
||||
{ key: 'Ctrl+Tab', description: 'Next session tab' },
|
||||
{ key: 'Ctrl+Shift+Tab', description: 'Previous session tab' },
|
||||
{ key: 'Ctrl+1-9', description: 'Go to session N' },
|
||||
{ key: 'Escape', description: 'Back to start screen' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Main View - Session Management',
|
||||
shortcuts: [
|
||||
{ key: 'Ctrl+N', description: 'New session' },
|
||||
{ key: 'Ctrl+W', description: 'Close current session' },
|
||||
{ key: 'Ctrl+K', description: 'Kill all sessions' },
|
||||
{ key: 'Ctrl+R', description: 'Toggle respawn (Claude sessions only)' },
|
||||
{ key: '1-9', description: 'Select and attach to session N' },
|
||||
{ key: 'Enter', description: 'Attach to current session' },
|
||||
{ key: 'q/Esc', description: 'Return to TUI start screen' },
|
||||
{ key: 'Ctrl+A D', description: '(While attached) Detach to menu' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'General',
|
||||
shortcuts: [
|
||||
{ key: '?', description: 'Toggle this help' },
|
||||
{ key: 'Ctrl+H', description: 'Toggle this help' },
|
||||
{ key: 'Ctrl+C', description: 'Exit TUI' },
|
||||
],
|
||||
},
|
||||
@@ -126,7 +117,7 @@ export function HelpOverlay({ onClose: _onClose }: HelpOverlayProps): React.Reac
|
||||
|
||||
{/* Footer */}
|
||||
<Box marginTop={1} borderStyle="single" borderColor="gray" paddingX={1}>
|
||||
<Text dimColor>Press Escape, q, or ? to close</Text>
|
||||
<Text dimColor>Press Escape or q to close</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -27,6 +27,7 @@ interface CaseInfo {
|
||||
interface StartScreenProps {
|
||||
sessions: ScreenSession[];
|
||||
cases: CaseInfo[];
|
||||
lastUsedCase: string | null;
|
||||
onSelectSession: (session: ScreenSession) => void;
|
||||
onAttachSession: (session: ScreenSession) => void;
|
||||
onDeleteSession: (session: ScreenSession) => void;
|
||||
@@ -64,6 +65,20 @@ function formatDuration(ms: number): string {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets display name for a session (matches web interface logic).
|
||||
* Priority: custom name > directory name (case) > session ID
|
||||
*/
|
||||
function getSessionName(session: ScreenSession): string {
|
||||
if (session.name) {
|
||||
return session.name;
|
||||
}
|
||||
if (session.workingDir) {
|
||||
return session.workingDir.split('/').pop() || session.workingDir;
|
||||
}
|
||||
return session.sessionId.slice(0, 8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start screen component for session discovery and selection.
|
||||
*
|
||||
@@ -78,6 +93,7 @@ function formatDuration(ms: number): string {
|
||||
export function StartScreen({
|
||||
sessions,
|
||||
cases,
|
||||
lastUsedCase,
|
||||
onSelectSession,
|
||||
onAttachSession,
|
||||
onDeleteSession,
|
||||
@@ -95,14 +111,19 @@ export function StartScreen({
|
||||
const [multiStartCount, setMultiStartCount] = useState('1');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Reset selection when switching modes
|
||||
// Reset selection when switching modes - default to lastUsedCase for cases view
|
||||
useEffect(() => {
|
||||
setSelectedIndex(0);
|
||||
if (mode === 'cases' && lastUsedCase && cases.length > 0) {
|
||||
const lastUsedIndex = cases.findIndex(c => c.name === lastUsedCase);
|
||||
setSelectedIndex(lastUsedIndex >= 0 ? lastUsedIndex : 0);
|
||||
} else {
|
||||
setSelectedIndex(0);
|
||||
}
|
||||
setError(null);
|
||||
if (mode !== 'multi-start') {
|
||||
setMultiStartCount('1');
|
||||
}
|
||||
}, [mode]);
|
||||
}, [mode, lastUsedCase, cases]);
|
||||
|
||||
// Ensure selectedIndex is valid when list changes
|
||||
useEffect(() => {
|
||||
@@ -396,19 +417,30 @@ export function StartScreen({
|
||||
);
|
||||
}
|
||||
|
||||
// Get the default case to display (lastUsedCase or first case or 'testcase')
|
||||
const defaultCase = lastUsedCase || (cases.length > 0 ? cases[0].name : 'testcase');
|
||||
const defaultCasePath = cases.find(c => c.name === defaultCase)?.path;
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
{/* Header */}
|
||||
{/* Header with current case */}
|
||||
<Box
|
||||
borderStyle="double"
|
||||
borderColor="cyan"
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
justifyContent="center"
|
||||
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 */}
|
||||
@@ -473,7 +505,7 @@ export function StartScreen({
|
||||
const statusColor = session.attached ? 'green' : 'red';
|
||||
const statusIcon = session.attached ? '\u25CF' : '\u25CB';
|
||||
const statusText = session.attached ? 'alive' : 'dead';
|
||||
const name = (session.name || 'unnamed').slice(0, 20);
|
||||
const name = getSessionName(session).slice(0, 20);
|
||||
const isSelected = index === selectedIndex;
|
||||
|
||||
return (
|
||||
|
||||
@@ -27,12 +27,17 @@ import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { existsSync, readFileSync, watchFile, unwatchFile, unlinkSync } from 'fs';
|
||||
import { homedir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { execSync } from 'child_process';
|
||||
import { execSync, exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import type { ScreenSession, InnerLoopState, InnerTodoItem, InnerSessionState } from '../../types.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const SCREENS_FILE = join(homedir(), '.claudeman', 'screens.json');
|
||||
const INNER_STATE_FILE = join(homedir(), '.claudeman', 'state-inner.json');
|
||||
const OUTPUT_POLL_INTERVAL = 500; // Poll terminal output every 500ms
|
||||
const SETTINGS_FILE = join(homedir(), '.claudeman', 'settings.json');
|
||||
const OUTPUT_POLL_INTERVAL = 300; // Poll terminal output every 300ms (faster refresh)
|
||||
const INPUT_BATCH_INTERVAL = 16; // Batch input every 16ms (60fps)
|
||||
|
||||
/**
|
||||
* Emoji to ASCII replacement map for screen hardcopy output.
|
||||
@@ -149,6 +154,7 @@ interface SessionManagerState {
|
||||
innerTodos: InnerTodoItem[];
|
||||
respawnStatus: RespawnStatus | null;
|
||||
cases: CaseInfo[];
|
||||
lastUsedCase: string | null;
|
||||
refreshSessions: () => void;
|
||||
refreshCases: () => Promise<void>;
|
||||
selectSession: (sessionId: string) => void;
|
||||
@@ -203,8 +209,9 @@ function isScreenAlive(screenName: string): boolean {
|
||||
* @description
|
||||
* Reads ~/.claudeman/screens.json and enriches each session with
|
||||
* its current alive/dead status by checking GNU screen.
|
||||
* Dead sessions are filtered out - only alive sessions are returned.
|
||||
*
|
||||
* @returns Array of screen sessions with updated attached status
|
||||
* @returns Array of alive screen sessions
|
||||
*/
|
||||
function loadSessions(): ScreenSession[] {
|
||||
try {
|
||||
@@ -214,11 +221,13 @@ function loadSessions(): ScreenSession[] {
|
||||
const data = readFileSync(SCREENS_FILE, 'utf-8');
|
||||
const sessions: ScreenSession[] = JSON.parse(data);
|
||||
|
||||
// Check which sessions are alive
|
||||
return sessions.map((session) => ({
|
||||
...session,
|
||||
attached: isScreenAlive(session.screenName),
|
||||
}));
|
||||
// Check which sessions are alive and filter out dead ones
|
||||
return sessions
|
||||
.map((session) => ({
|
||||
...session,
|
||||
attached: isScreenAlive(session.screenName),
|
||||
}))
|
||||
.filter((session) => session.attached);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
@@ -247,6 +256,28 @@ function loadInnerState(sessionId: string): InnerSessionState | null {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the last used case name from settings.
|
||||
*
|
||||
* @description
|
||||
* Reads ~/.claudeman/settings.json and returns the lastUsedCase field.
|
||||
* This is used to default the case selection in the TUI to match the web GUI.
|
||||
*
|
||||
* @returns The last used case name or null if not found
|
||||
*/
|
||||
function loadLastUsedCase(): string | null {
|
||||
try {
|
||||
if (!existsSync(SETTINGS_FILE)) {
|
||||
return null;
|
||||
}
|
||||
const data = readFileSync(SETTINGS_FILE, 'utf-8');
|
||||
const settings = JSON.parse(data) as { lastUsedCase?: string };
|
||||
return settings.lastUsedCase || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* React hook for managing Claude sessions in the TUI.
|
||||
*
|
||||
@@ -285,6 +316,7 @@ export function useSessionManager(): SessionManagerState {
|
||||
const [innerTodos, setInnerTodos] = useState<InnerTodoItem[]>([]);
|
||||
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
|
||||
@@ -387,18 +419,20 @@ export function useSessionManager(): SessionManagerState {
|
||||
};
|
||||
}, [activeSessionId]);
|
||||
|
||||
// Poll terminal output for active session
|
||||
// Poll terminal output for active session (async for non-blocking)
|
||||
useEffect(() => {
|
||||
if (!activeSessionId || !activeSession) return;
|
||||
|
||||
const pollOutput = () => {
|
||||
if (!isScreenAlive(activeSession.screenName)) return;
|
||||
let isMounted = true;
|
||||
let pollTimeoutId: NodeJS.Timeout | null = null;
|
||||
|
||||
const pollOutput = async () => {
|
||||
if (!isMounted || !isScreenAlive(activeSession.screenName)) return;
|
||||
|
||||
try {
|
||||
const hardcopyFile = `/tmp/claudeman-${activeSessionId}-hardcopy`;
|
||||
// Use screen with UTF-8 mode (-U) for proper character handling
|
||||
execSync(`screen -U -S ${activeSession.screenName} -X hardcopy ${hardcopyFile}`, {
|
||||
encoding: 'utf-8',
|
||||
// Use screen with UTF-8 mode (-U) for proper character handling (async)
|
||||
await execAsync(`screen -U -S ${activeSession.screenName} -X hardcopy ${hardcopyFile}`, {
|
||||
timeout: 1000,
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -406,7 +440,7 @@ export function useSessionManager(): SessionManagerState {
|
||||
LC_ALL: process.env.LC_ALL || 'en_US.UTF-8',
|
||||
}
|
||||
});
|
||||
if (existsSync(hardcopyFile)) {
|
||||
if (isMounted && existsSync(hardcopyFile)) {
|
||||
const rawContent = readFileSync(hardcopyFile, 'utf-8');
|
||||
// Sanitize emoji/unicode that screen hardcopy corrupts
|
||||
const content = sanitizeHardcopyOutput(rawContent);
|
||||
@@ -425,16 +459,21 @@ export function useSessionManager(): SessionManagerState {
|
||||
} catch {
|
||||
// Hardcopy may fail, that's ok
|
||||
}
|
||||
|
||||
// Schedule next poll (using timeout instead of interval for async)
|
||||
if (isMounted) {
|
||||
pollTimeoutId = setTimeout(pollOutput, OUTPUT_POLL_INTERVAL);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial poll
|
||||
pollOutput();
|
||||
|
||||
// Set up polling interval
|
||||
const intervalId = setInterval(pollOutput, OUTPUT_POLL_INTERVAL);
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
isMounted = false;
|
||||
if (pollTimeoutId) {
|
||||
clearTimeout(pollTimeoutId);
|
||||
}
|
||||
};
|
||||
}, [activeSessionId, activeSession]);
|
||||
|
||||
@@ -443,8 +482,11 @@ export function useSessionManager(): SessionManagerState {
|
||||
setSessions(loadSessions());
|
||||
}, []);
|
||||
|
||||
// Refresh cases from API
|
||||
// Refresh cases from API and reload lastUsedCase from settings
|
||||
const refreshCases = useCallback(async () => {
|
||||
// Always reload lastUsedCase from settings
|
||||
setLastUsedCase(loadLastUsedCase());
|
||||
|
||||
try {
|
||||
const response = await fetch('http://localhost:3000/api/cases');
|
||||
if (response.ok) {
|
||||
@@ -467,7 +509,6 @@ export function useSessionManager(): SessionManagerState {
|
||||
setActiveSessionId(sessionId);
|
||||
setTerminalOutput('');
|
||||
outputBufferRef.current = '';
|
||||
// Polling effect will handle fetching output
|
||||
}, []);
|
||||
|
||||
// Create a new case
|
||||
@@ -492,45 +533,83 @@ export function useSessionManager(): SessionManagerState {
|
||||
}
|
||||
}, [refreshCases]);
|
||||
|
||||
// Create new session (optionally with case name and mode)
|
||||
// Create new session with proper naming (w1-casename, w2-casename, etc.)
|
||||
const createSession = useCallback(async (caseName?: string, mode: 'claude' | 'shell' = 'claude'): Promise<string | null> => {
|
||||
try {
|
||||
// Use the web API to create a session if server is running
|
||||
const response = await fetch('http://localhost:3000/api/quick-start', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ caseName: caseName || `case-${Date.now()}`, mode }),
|
||||
});
|
||||
const actualCaseName = caseName || 'testcase';
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json() as { success?: boolean; sessionId?: string; caseName?: string };
|
||||
if (data.success && data.sessionId) {
|
||||
// Refresh and select new session
|
||||
const sessionId = data.sessionId;
|
||||
setTimeout(() => {
|
||||
refreshSessions();
|
||||
refreshCases();
|
||||
setActiveSessionId(sessionId);
|
||||
}, 500);
|
||||
return sessionId;
|
||||
try {
|
||||
// Get or create the case
|
||||
let caseRes = await fetch(`http://localhost:3000/api/cases/${actualCaseName}`);
|
||||
let caseData = await caseRes.json() as { path?: string };
|
||||
|
||||
// Create case if it doesn't exist
|
||||
if (!caseData.path) {
|
||||
const createCaseRes = await fetch('http://localhost:3000/api/cases', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name: actualCaseName, description: '' }),
|
||||
});
|
||||
const createResult = await createCaseRes.json() as { success?: boolean; case?: { path: string } };
|
||||
if (!createResult.success || !createResult.case) {
|
||||
throw new Error('Failed to create case');
|
||||
}
|
||||
caseData = createResult.case;
|
||||
}
|
||||
|
||||
const workingDir = caseData.path;
|
||||
if (!workingDir) throw new Error('Case path not found');
|
||||
|
||||
// Find highest existing w-number across all sessions
|
||||
let startNumber = 1;
|
||||
for (const session of sessions) {
|
||||
const match = session.name?.match(/^w(\d+)-/);
|
||||
if (match) {
|
||||
const num = parseInt(match[1], 10);
|
||||
if (num >= startNumber) {
|
||||
startNumber = num + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Just report that web server isn't running
|
||||
// Generate session name matching web UI convention
|
||||
const sessionName = `w${startNumber}-${actualCaseName}`;
|
||||
|
||||
// Create session with custom name
|
||||
const createRes = await fetch('http://localhost:3000/api/sessions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ workingDir, name: sessionName, mode }),
|
||||
});
|
||||
const createData = await createRes.json() as { success?: boolean; session?: { id: string } };
|
||||
|
||||
if (!createData.success || !createData.session) {
|
||||
throw new Error('Failed to create session');
|
||||
}
|
||||
|
||||
const sessionId = createData.session.id;
|
||||
|
||||
// Start in the appropriate mode
|
||||
if (mode === 'shell') {
|
||||
await fetch(`http://localhost:3000/api/sessions/${sessionId}/shell`, { method: 'POST' });
|
||||
} else {
|
||||
await fetch(`http://localhost:3000/api/sessions/${sessionId}/interactive`, { method: 'POST' });
|
||||
}
|
||||
|
||||
// Refresh sessions
|
||||
setTimeout(() => {
|
||||
refreshSessions();
|
||||
refreshCases();
|
||||
}, 500);
|
||||
|
||||
return sessionId;
|
||||
} catch (err) {
|
||||
setTerminalOutput(
|
||||
'Web server not running. Start it with: claudeman web\n' +
|
||||
'Then use this TUI to manage sessions.'
|
||||
);
|
||||
return null;
|
||||
} catch {
|
||||
setTerminalOutput(
|
||||
'Cannot connect to Claudeman server.\n' +
|
||||
'Start the server with: claudeman web\n' +
|
||||
'Then use this TUI to manage sessions.'
|
||||
`Failed to create session: ${err instanceof Error ? err.message : 'Unknown error'}\n` +
|
||||
'Make sure the web server is running: claudeman web'
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}, [refreshSessions, refreshCases]);
|
||||
}, [sessions, refreshSessions, refreshCases]);
|
||||
|
||||
// Kill a session
|
||||
const killSession = useCallback((sessionId: string) => {
|
||||
@@ -631,30 +710,49 @@ export function useSessionManager(): SessionManagerState {
|
||||
selectSession(sessions[prevIndex].sessionId);
|
||||
}, [sessions, activeSessionId, selectSession]);
|
||||
|
||||
// Send input to a session
|
||||
// Send input to a session directly via screen command (simple, reliable)
|
||||
const sendInput = useCallback((sessionId: string, input: string) => {
|
||||
const session = sessions.find((s) => s.sessionId === sessionId);
|
||||
if (!session || !isScreenAlive(session.screenName)) return;
|
||||
|
||||
try {
|
||||
// Send input via screen stuff command
|
||||
// Escape special characters for shell
|
||||
const escaped = input.replace(/'/g, "'\\''");
|
||||
const screenName = session.screenName;
|
||||
|
||||
try {
|
||||
if (input === '\r') {
|
||||
// Send Enter key
|
||||
execSync(`screen -S ${session.screenName} -p 0 -X stuff $'\\015'`, {
|
||||
execSync(`screen -S ${screenName} -p 0 -X stuff $'\\015'`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
timeout: 1000,
|
||||
});
|
||||
} else if (input === '\t') {
|
||||
// Send Tab key
|
||||
execSync(`screen -S ${screenName} -p 0 -X stuff $'\\011'`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: 1000,
|
||||
});
|
||||
} else if (input === '\x7f') {
|
||||
// Send Backspace
|
||||
execSync(`screen -S ${screenName} -p 0 -X stuff $'\\177'`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: 1000,
|
||||
});
|
||||
} else if (input.startsWith('\x1b')) {
|
||||
// Send escape sequences (arrows, etc) - use $'...' syntax
|
||||
const escaped = input.replace(/'/g, "'\\''");
|
||||
execSync(`screen -S ${screenName} -p 0 -X stuff $'${escaped.replace(/\x1b/g, '\\033')}'`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: 1000,
|
||||
});
|
||||
} else {
|
||||
execSync(`screen -S ${session.screenName} -p 0 -X stuff '${escaped}'`, {
|
||||
// Regular character - escape single quotes
|
||||
const escaped = input.replace(/'/g, "'\\''");
|
||||
execSync(`screen -S ${screenName} -p 0 -X stuff '${escaped}'`, {
|
||||
encoding: 'utf-8',
|
||||
timeout: 5000,
|
||||
timeout: 1000,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Input may fail if screen is not ready or timeout
|
||||
// Input may fail if screen is not ready - ignore
|
||||
}
|
||||
}, [sessions]);
|
||||
|
||||
@@ -716,6 +814,7 @@ export function useSessionManager(): SessionManagerState {
|
||||
innerTodos,
|
||||
respawnStatus,
|
||||
cases,
|
||||
lastUsedCase,
|
||||
refreshSessions,
|
||||
refreshCases,
|
||||
selectSession,
|
||||
|
||||
+94
-18
@@ -26,9 +26,10 @@
|
||||
*/
|
||||
|
||||
import { render } from 'ink';
|
||||
import { spawn } from 'child_process';
|
||||
import { spawn, spawnSync } from 'child_process';
|
||||
import { createInterface } from 'readline';
|
||||
import { App } from './App.js';
|
||||
import { App, getPendingAttach, clearPendingAttach } from './App.js';
|
||||
import { attachWithTabs, getFullScreenId } from './DirectAttach.js';
|
||||
|
||||
/**
|
||||
* Checks if the terminal supports raw mode input.
|
||||
@@ -200,24 +201,99 @@ export async function startTUI(options: TUIOptions = {}): Promise<void> {
|
||||
// Clear the terminal for full-screen experience
|
||||
process.stdout.write('\x1b[2J\x1b[H');
|
||||
|
||||
const { waitUntilExit, unmount } = render(<App />);
|
||||
// Main TUI loop - allows unmounting for screen attachment and re-rendering
|
||||
let shouldContinue = true;
|
||||
|
||||
// Handle graceful exit on SIGINT/SIGTERM
|
||||
const cleanup = () => {
|
||||
unmount();
|
||||
// Restore terminal state
|
||||
process.stdout.write('\x1b[?25h'); // Show cursor
|
||||
process.stdout.write('\x1b[2J\x1b[H'); // Clear screen
|
||||
};
|
||||
while (shouldContinue) {
|
||||
// Clear any pending attach from previous iteration
|
||||
clearPendingAttach();
|
||||
|
||||
process.on('SIGINT', cleanup);
|
||||
process.on('SIGTERM', cleanup);
|
||||
const { waitUntilExit, unmount } = render(<App />);
|
||||
|
||||
try {
|
||||
await waitUntilExit();
|
||||
} finally {
|
||||
process.off('SIGINT', cleanup);
|
||||
process.off('SIGTERM', cleanup);
|
||||
cleanup();
|
||||
// Handle graceful exit on SIGINT/SIGTERM
|
||||
const cleanup = () => {
|
||||
unmount();
|
||||
// Restore terminal state
|
||||
process.stdout.write('\x1b[?25h'); // Show cursor
|
||||
process.stdout.write('\x1b[2J\x1b[H'); // Clear screen
|
||||
};
|
||||
|
||||
process.on('SIGINT', cleanup);
|
||||
process.on('SIGTERM', cleanup);
|
||||
|
||||
try {
|
||||
await waitUntilExit();
|
||||
} finally {
|
||||
process.off('SIGINT', cleanup);
|
||||
process.off('SIGTERM', cleanup);
|
||||
}
|
||||
|
||||
// Check if we exited to attach to a screen session
|
||||
const pendingAttach = getPendingAttach();
|
||||
|
||||
if (pendingAttach) {
|
||||
// Unmount Ink properly
|
||||
unmount();
|
||||
|
||||
// Restore terminal state after Ink - this is critical for screen to work
|
||||
// Ink leaves stdin in raw mode with event listeners, we need to clean up
|
||||
if (process.stdin.isTTY) {
|
||||
process.stdin.setRawMode(false);
|
||||
}
|
||||
process.stdin.removeAllListeners();
|
||||
// Pause stdin for clean state - readKeySync uses fs.readSync which works in paused mode
|
||||
process.stdin.pause();
|
||||
|
||||
// Clear terminal and show cursor
|
||||
process.stdout.write('\x1b[?25h'); // Show cursor
|
||||
process.stdout.write('\x1b[2J\x1b[H'); // Clear screen
|
||||
|
||||
// Handle the attachment
|
||||
if (pendingAttach.mode === 'tabs') {
|
||||
// Attach with tab switching between sessions
|
||||
await new Promise<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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,6 +187,8 @@ export interface AppConfig {
|
||||
stateFilePath: string;
|
||||
/** Respawn controller configuration */
|
||||
respawn: RespawnConfig;
|
||||
/** Last used case name (for default selection) */
|
||||
lastUsedCase: string | null;
|
||||
}
|
||||
|
||||
// ========== Output Types ==========
|
||||
@@ -490,6 +492,7 @@ export const DEFAULT_CONFIG: AppConfig = {
|
||||
sendClear: true, // send /clear after update prompt
|
||||
sendInit: true, // send /init after /clear
|
||||
},
|
||||
lastUsedCase: null,
|
||||
};
|
||||
|
||||
// ========== Inner Loop Tracking Types ==========
|
||||
|
||||
+18
-2
@@ -892,11 +892,23 @@ class ClaudemanApp {
|
||||
|
||||
async loadQuickStartCases(selectCaseName = null) {
|
||||
try {
|
||||
// Load settings to get lastUsedCase
|
||||
let lastUsedCase = null;
|
||||
try {
|
||||
const settingsRes = await fetch('/api/settings');
|
||||
if (settingsRes.ok) {
|
||||
const settings = await settingsRes.json();
|
||||
lastUsedCase = settings.lastUsedCase || null;
|
||||
}
|
||||
} catch {
|
||||
// Ignore settings load errors
|
||||
}
|
||||
|
||||
// Add cache-busting to ensure fresh data
|
||||
const res = await fetch('/api/cases?_t=' + Date.now());
|
||||
const cases = await res.json();
|
||||
this.cases = cases;
|
||||
console.log('[loadQuickStartCases] Loaded cases:', cases.map(c => c.name));
|
||||
console.log('[loadQuickStartCases] Loaded cases:', cases.map(c => c.name), 'lastUsedCase:', lastUsedCase);
|
||||
|
||||
const select = document.getElementById('quickStartCase');
|
||||
|
||||
@@ -920,8 +932,12 @@ class ClaudemanApp {
|
||||
if (selectCaseName) {
|
||||
select.value = selectCaseName;
|
||||
this.updateDirDisplayForCase(selectCaseName);
|
||||
} else if (lastUsedCase && cases.some(c => c.name === lastUsedCase)) {
|
||||
// Use lastUsedCase if available and exists
|
||||
select.value = lastUsedCase;
|
||||
this.updateDirDisplayForCase(lastUsedCase);
|
||||
} else if (cases.length > 0) {
|
||||
// Auto-select first case
|
||||
// Fallback to testcase or first case
|
||||
const firstCase = cases.find(c => c.name === 'testcase') || cases[0];
|
||||
select.value = firstCase.name;
|
||||
this.updateDirDisplayForCase(firstCase.name);
|
||||
|
||||
@@ -854,6 +854,23 @@ export class WebServer extends EventEmitter {
|
||||
}
|
||||
this.broadcast('session:updated', { session: session.toDetailedState() });
|
||||
|
||||
// Save lastUsedCase to settings for TUI/web sync
|
||||
try {
|
||||
const settingsFilePath = join(homedir(), '.claudeman', 'settings.json');
|
||||
let settings: Record<string, unknown> = {};
|
||||
if (existsSync(settingsFilePath)) {
|
||||
settings = JSON.parse(readFileSync(settingsFilePath, 'utf-8'));
|
||||
}
|
||||
settings.lastUsedCase = caseName;
|
||||
const dir = dirname(settingsFilePath);
|
||||
if (!existsSync(dir)) {
|
||||
mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
writeFileSync(settingsFilePath, JSON.stringify(settings, null, 2));
|
||||
} catch {
|
||||
// Non-critical, ignore settings save errors
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
sessionId: session.id,
|
||||
|
||||
Reference in New Issue
Block a user