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:
arkon
2026-01-21 12:52:38 +01:00
co-authored by Claude Opus 4.5
parent e6ba373d12
commit e8c30b9880
11 changed files with 737 additions and 219 deletions
+16 -34
View File
@@ -417,7 +417,7 @@ npx agent-browser close
| GET | `/api/sessions/:id/inner-state` | Get Ralph loop state + todos |
| POST | `/api/sessions/:id/auto-compact` | Configure auto-compact threshold |
| POST | `/api/sessions/:id/auto-clear` | Configure auto-clear threshold |
| POST | `/api/quick-start` | Create case + start session (claude or shell mode) |
| POST | `/api/quick-start` | Create case + start session. Body: `{mode?: 'claude'\|'shell'}` |
| GET | `/api/cases` | List available cases |
| POST | `/api/cases` | Create new case |
| GET | `/api/screens` | List screen sessions with stats |
@@ -441,9 +441,10 @@ npx agent-browser close
| Key | Action |
|-----|--------|
| `↑`/`↓` | Navigate list |
| `Enter` | View session in TUI |
| `a` | Attach directly to screen (full terminal) |
| `Enter` | Open tab switcher menu, then full-screen attach |
| `a` | Direct attach (skip tab menu, Ctrl+A D returns to TUI) |
| `d` | Delete/kill selected session |
| `D` (Shift+d) | Delete ALL screens & Claude processes |
| `c` | Switch to cases view |
| `n` | Quick-start new session |
| `r` | Refresh list |
@@ -460,18 +461,17 @@ npx agent-browser close
| `s` | Switch to sessions view |
| `r` | Refresh list |
**Main View (Session Active):**
**Tab Switcher Menu (between attaches):**
| Key | Action |
|-----|--------|
| `Tab`/`Shift+Tab` | Next/previous session tab |
| `Ctrl+1-9` | Go to session N |
| `[`/`]` | Previous/next session (vim-style) |
| `Ctrl+N` | New session |
| `Ctrl+W` | Close current session |
| `Ctrl+K` | Kill all sessions |
| `Ctrl+R` | Toggle respawn (Claude sessions only) |
| `Escape` | Back to start screen |
| `?` | Toggle help overlay |
| `1-9` | Select and attach to session N |
| `Enter` | Attach to current session |
| `q` / `Esc` | Return to TUI start screen |
**While attached to screen:**
| Key | Action |
|-----|--------|
| `Ctrl+A D` | Detach and return to tab switcher |
## State Files
@@ -539,26 +539,8 @@ See [`docs/ralph-wiggum-guide.md`](docs/ralph-wiggum-guide.md) for full document
## Optimization Status
Most critical optimizations have been implemented. See `.claude/optimization-todos.md` for details.
Most critical optimizations implemented. Full details with file:line references in `.claude/optimization-todos.md`.
**Completed:**
| Area | Implementation |
|------|----------------|
| Buffer management | `BufferAccumulator` with auto-trimming |
| Pre-compiled regex | Module-level patterns with lastIndex resets |
| Event listener cleanup | `cleanupTrackerListeners()` in session.ts |
| Event debouncing | 50ms debounce in `inner-loop-tracker.ts` |
| State update debouncing | 500ms batching in `server.ts` |
| Regex pre-checks | String.includes() before pattern.test() |
| Frontend render debouncing | 50-100ms for panels, tabs |
| CSS containment | `contain` property on terminal, panels, modals |
| Input batching | 60fps keystroke coalescing |
| Incremental DOM updates | Session tabs and Ralph todos |
**Key optimizations**: Buffer auto-trimming, pre-compiled regex with lastIndex resets, event debouncing (50-500ms), CSS containment, 60fps input batching, incremental DOM updates.
**Remaining:**
| Priority | Area | Files |
|----------|------|-------|
| Low | Task lookup optimization | `task-tracker.ts` |
| Low | Buffer pagination API | `server.ts` |
Run `cat .claude/optimization-todos.md` for the full list with file:line references.
**Remaining (low priority)**: Task lookup optimization in `task-tracker.ts`, buffer pagination API.
-38
View File
@@ -265,43 +265,6 @@ claudeman web [-p 8080] # Start web interface
claudeman status # Show overall status
```
### Terminal User Interface (TUI)
Full-featured TUI built with Ink (React for CLI):
```bash
claudeman tui # Launch TUI
npx tsx src/index.ts tui # Dev mode
```
**Start Screen:**
| Key | Action |
|-----|--------|
| `↑`/`↓` | Navigate sessions |
| `Enter` | View session in TUI |
| `a` | Attach directly to screen |
| `d` | Delete session |
| `n` | New session |
| `r` | Refresh |
| `q` | Quit |
**Main View:**
| Key | Action |
|-----|--------|
| `Ctrl+Tab` | Next session |
| `Ctrl+1-9` | Go to session N |
| `Ctrl+W` | Close session |
| `Escape` | Back to start screen |
| `?` | Show help |
Features:
- Real-time terminal output (polls screen every 500ms)
- Tab-based session navigation
- Direct screen attachment for full interactivity
- Arrow key navigation with visual selection
- **Ralph Loop tracking** with live progress panel
- Respawn status indicator in status bar
### Screen Manager Script
Interactive bash script for direct screen management:
@@ -447,7 +410,6 @@ npm install
npx tsx src/index.ts web # Dev mode (no build needed)
npm run build # Production build
npm test # Run test suite
npm run tui # Launch TUI
npx tsc --noEmit # Type check
```
+119 -42
View File
@@ -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}
+263
View File
@@ -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();
}
+9 -18
View File
@@ -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>
);
+38 -6
View File
@@ -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 (
+160 -61
View File
@@ -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
View File
@@ -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
}
}
}
+3
View File
@@ -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
View File
@@ -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);
+17
View File
@@ -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,