mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-06 23:49:41 +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:
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user