mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-04 06:29:42 +02:00
feat(tui): add terminal user interface with Ink
Add a full TUI for Claudeman using Ink (React for CLI): - StartScreen: Shows existing sessions from screens.json - TabBar: Session tabs with status indicators - TerminalView: PTY output display with scrolling - StatusBar: Session info and keyboard shortcuts - HelpOverlay: Full keyboard shortcut reference Features: - Session discovery from ~/.claudeman/screens.json - Tab navigation (Ctrl+Tab, Ctrl+1-9) - Create new sessions (connects to web API) - Kill sessions via screen commands - Keyboard-driven navigation Usage: claudeman tui Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
+14
@@ -470,4 +470,18 @@ program
|
||||
}
|
||||
});
|
||||
|
||||
// TUI command
|
||||
program
|
||||
.command('tui')
|
||||
.description('Start the terminal user interface')
|
||||
.action(async () => {
|
||||
try {
|
||||
const { startTUI } = await import('./tui/index.js');
|
||||
await startTUI();
|
||||
} catch (err) {
|
||||
console.error(chalk.red(`✗ Failed to start TUI: ${(err as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
export { program };
|
||||
|
||||
+215
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* @fileoverview Main TUI App component
|
||||
*
|
||||
* The root component that manages the overall TUI layout:
|
||||
* - StartScreen: Initial session discovery view
|
||||
* - Main view: TabBar + Terminal + StatusBar
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Box, Text, useApp, useInput, useStdout } from 'ink';
|
||||
import { StartScreen } from './components/StartScreen.js';
|
||||
import { TabBar } from './components/TabBar.js';
|
||||
import { TerminalView } from './components/TerminalView.js';
|
||||
import { StatusBar } from './components/StatusBar.js';
|
||||
import { HelpOverlay } from './components/HelpOverlay.js';
|
||||
import { useSessionManager } from './hooks/useSessionManager.js';
|
||||
import type { ScreenSession } from '../types.js';
|
||||
|
||||
type ViewMode = 'start' | 'main';
|
||||
|
||||
/**
|
||||
* Main application component
|
||||
*/
|
||||
export function App(): React.ReactElement {
|
||||
const { exit } = useApp();
|
||||
const { stdout } = useStdout();
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('start');
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const [terminalHeight, setTerminalHeight] = useState(20);
|
||||
|
||||
const {
|
||||
sessions,
|
||||
activeSessionId,
|
||||
activeSession,
|
||||
refreshSessions,
|
||||
selectSession,
|
||||
createSession,
|
||||
killSession,
|
||||
killAllSessions,
|
||||
nextSession,
|
||||
prevSession,
|
||||
sendInput,
|
||||
terminalOutput,
|
||||
} = useSessionManager();
|
||||
|
||||
// Calculate terminal height based on stdout dimensions
|
||||
useEffect(() => {
|
||||
const updateHeight = () => {
|
||||
// Reserve: 3 for TabBar, 3 for StatusBar, 2 for borders
|
||||
const rows = stdout?.rows || 24;
|
||||
setTerminalHeight(Math.max(10, rows - 8));
|
||||
};
|
||||
updateHeight();
|
||||
|
||||
stdout?.on('resize', updateHeight);
|
||||
return () => {
|
||||
stdout?.off('resize', updateHeight);
|
||||
};
|
||||
}, [stdout]);
|
||||
|
||||
// Handle keyboard input
|
||||
useInput((input, key) => {
|
||||
// Help overlay takes priority
|
||||
if (showHelp) {
|
||||
if (key.escape || input === 'q' || input === '?') {
|
||||
setShowHelp(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Global shortcuts
|
||||
if (input === '?' || (key.ctrl && input === 'h')) {
|
||||
setShowHelp(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// Exit on Ctrl+C (with confirmation in main view)
|
||||
if (key.ctrl && input === 'c') {
|
||||
exit();
|
||||
return;
|
||||
}
|
||||
|
||||
// Start screen specific inputs
|
||||
if (viewMode === 'start') {
|
||||
if (input === 'q') {
|
||||
exit();
|
||||
return;
|
||||
}
|
||||
if (input === 'n') {
|
||||
handleCreateSession();
|
||||
return;
|
||||
}
|
||||
if (input === 'r') {
|
||||
refreshSessions();
|
||||
return;
|
||||
}
|
||||
// Number keys to select session
|
||||
const num = parseInt(input, 10);
|
||||
if (!isNaN(num) && num >= 1 && num <= sessions.length) {
|
||||
handleSelectSession(sessions[num - 1]);
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Main view shortcuts
|
||||
if (key.ctrl && key.tab) {
|
||||
if (key.shift) {
|
||||
prevSession();
|
||||
} else {
|
||||
nextSession();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+1-9 for direct tab access
|
||||
if (key.ctrl) {
|
||||
const num = parseInt(input, 10);
|
||||
if (!isNaN(num) && num >= 1 && num <= 9 && num <= sessions.length) {
|
||||
selectSession(sessions[num - 1].sessionId);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+W to close current session
|
||||
if (key.ctrl && input === 'w') {
|
||||
if (activeSessionId) {
|
||||
killSession(activeSessionId);
|
||||
if (sessions.length <= 1) {
|
||||
setViewMode('start');
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+K to kill all sessions
|
||||
if (key.ctrl && input === 'k') {
|
||||
killAllSessions();
|
||||
setViewMode('start');
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+N for new session
|
||||
if (key.ctrl && input === 'n') {
|
||||
handleCreateSession();
|
||||
return;
|
||||
}
|
||||
|
||||
// Escape to go back to start screen
|
||||
if (key.escape) {
|
||||
setViewMode('start');
|
||||
return;
|
||||
}
|
||||
|
||||
// Forward other input to the active session
|
||||
if (activeSessionId && !key.ctrl && !key.meta) {
|
||||
if (key.return) {
|
||||
sendInput(activeSessionId, '\r');
|
||||
} else if (key.backspace || key.delete) {
|
||||
sendInput(activeSessionId, '\x7f');
|
||||
} else if (input) {
|
||||
sendInput(activeSessionId, input);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const handleSelectSession = useCallback((session: ScreenSession) => {
|
||||
selectSession(session.sessionId);
|
||||
setViewMode('main');
|
||||
}, [selectSession]);
|
||||
|
||||
const handleCreateSession = useCallback(async () => {
|
||||
const sessionId = await createSession();
|
||||
if (sessionId) {
|
||||
setViewMode('main');
|
||||
}
|
||||
}, [createSession]);
|
||||
|
||||
// Render help overlay if shown
|
||||
if (showHelp) {
|
||||
return <HelpOverlay onClose={() => setShowHelp(false)} />;
|
||||
}
|
||||
|
||||
// Render start screen
|
||||
if (viewMode === 'start') {
|
||||
return (
|
||||
<StartScreen
|
||||
sessions={sessions}
|
||||
onSelectSession={handleSelectSession}
|
||||
onCreateSession={handleCreateSession}
|
||||
onRefresh={refreshSessions}
|
||||
onExit={exit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Render main view with tabs, terminal, and status bar
|
||||
return (
|
||||
<Box flexDirection="column" height="100%">
|
||||
<TabBar
|
||||
sessions={sessions}
|
||||
activeSessionId={activeSessionId}
|
||||
onSelectSession={(id) => selectSession(id)}
|
||||
/>
|
||||
|
||||
<TerminalView
|
||||
output={terminalOutput}
|
||||
height={terminalHeight}
|
||||
session={activeSession}
|
||||
/>
|
||||
|
||||
<StatusBar session={activeSession} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* @fileoverview HelpOverlay component
|
||||
*
|
||||
* Full-screen overlay showing all keyboard shortcuts.
|
||||
* Dismissible with Escape, q, or ?.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
|
||||
interface HelpOverlayProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface ShortcutGroup {
|
||||
title: string;
|
||||
shortcuts: Array<{ key: string; description: string }>;
|
||||
}
|
||||
|
||||
const SHORTCUT_GROUPS: ShortcutGroup[] = [
|
||||
{
|
||||
title: 'Navigation',
|
||||
shortcuts: [
|
||||
{ key: 'Ctrl+Tab', description: 'Next session' },
|
||||
{ key: 'Ctrl+Shift+Tab', description: 'Previous session' },
|
||||
{ key: 'Ctrl+1-9', description: 'Go to session N' },
|
||||
{ key: 'Escape', description: 'Back to start screen' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Session Management',
|
||||
shortcuts: [
|
||||
{ key: 'Ctrl+N', description: 'New session' },
|
||||
{ key: 'Ctrl+W', description: 'Close current session' },
|
||||
{ key: 'Ctrl+K', description: 'Kill all sessions' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Start Screen',
|
||||
shortcuts: [
|
||||
{ key: '1-9', description: 'Select session by number' },
|
||||
{ key: 'n', description: 'Create new session' },
|
||||
{ key: 'r', description: 'Refresh session list' },
|
||||
{ key: 'q', description: 'Quit' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'General',
|
||||
shortcuts: [
|
||||
{ key: '?', description: 'Toggle this help' },
|
||||
{ key: 'Ctrl+C', description: 'Exit TUI' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* HelpOverlay component showing keyboard shortcuts
|
||||
*/
|
||||
export function HelpOverlay({ onClose }: HelpOverlayProps): React.ReactElement {
|
||||
return (
|
||||
<Box flexDirection="column" padding={2}>
|
||||
{/* Header */}
|
||||
<Box
|
||||
borderStyle="double"
|
||||
borderColor="cyan"
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
marginBottom={1}
|
||||
justifyContent="center"
|
||||
>
|
||||
<Text bold color="cyan">
|
||||
Keyboard Shortcuts
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Shortcut groups */}
|
||||
{SHORTCUT_GROUPS.map((group, groupIndex) => (
|
||||
<Box key={groupIndex} flexDirection="column" marginBottom={1}>
|
||||
<Text bold color="yellow">
|
||||
{group.title}
|
||||
</Text>
|
||||
<Box flexDirection="column" marginLeft={2}>
|
||||
{group.shortcuts.map((shortcut, index) => (
|
||||
<Box key={index}>
|
||||
<Text color="green">{shortcut.key.padEnd(20)}</Text>
|
||||
<Text>{shortcut.description}</Text>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
{/* Footer */}
|
||||
<Box marginTop={1} borderStyle="single" borderColor="gray" paddingX={1}>
|
||||
<Text dimColor>Press Escape, q, or ? to close</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* @fileoverview StartScreen component
|
||||
*
|
||||
* Initial screen shown when TUI launches. Displays:
|
||||
* - List of existing screen sessions from ~/.claudeman/screens.json
|
||||
* - Session status (alive/dead), runtime, mode
|
||||
* - Options to select, create, or refresh sessions
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { ScreenSession } from '../../types.js';
|
||||
|
||||
interface StartScreenProps {
|
||||
sessions: ScreenSession[];
|
||||
onSelectSession: (session: ScreenSession) => void;
|
||||
onCreateSession: () => void;
|
||||
onRefresh: () => void;
|
||||
onExit: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats duration from milliseconds to human-readable string
|
||||
*/
|
||||
function formatDuration(ms: number): string {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const days = Math.floor(hours / 24);
|
||||
|
||||
if (days > 0) {
|
||||
return `${days}d ${hours % 24}h`;
|
||||
}
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes % 60}m`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m`;
|
||||
}
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start screen component showing session list
|
||||
*/
|
||||
export function StartScreen({
|
||||
sessions,
|
||||
onSelectSession,
|
||||
onCreateSession,
|
||||
onRefresh,
|
||||
onExit,
|
||||
}: StartScreenProps): React.ReactElement {
|
||||
const now = Date.now();
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" padding={1}>
|
||||
{/* Header */}
|
||||
<Box
|
||||
borderStyle="double"
|
||||
borderColor="cyan"
|
||||
paddingX={2}
|
||||
paddingY={1}
|
||||
justifyContent="center"
|
||||
>
|
||||
<Text bold color="cyan">
|
||||
Claudeman TUI
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
<Box marginY={1}>
|
||||
<Text dimColor>Session Manager - Press ? for help</Text>
|
||||
</Box>
|
||||
|
||||
{/* Session list */}
|
||||
{sessions.length === 0 ? (
|
||||
<Box flexDirection="column" marginY={1}>
|
||||
<Text color="yellow">No sessions found</Text>
|
||||
<Text dimColor>Press [n] to create a new session</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Box flexDirection="column">
|
||||
{/* Table header */}
|
||||
<Box marginBottom={1}>
|
||||
<Text bold>
|
||||
<Text color="gray">{' # '}</Text>
|
||||
<Text>{'NAME'.padEnd(22)}</Text>
|
||||
<Text>{'RUNTIME'.padEnd(12)}</Text>
|
||||
<Text>{'STATUS'.padEnd(10)}</Text>
|
||||
<Text>{'MODE'.padEnd(10)}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Session rows */}
|
||||
{sessions.map((session, index) => {
|
||||
const runtime = formatDuration(now - session.createdAt);
|
||||
const statusColor = session.attached ? 'green' : 'red';
|
||||
const statusIcon = session.attached ? '\u25CF' : '\u25CB';
|
||||
const statusText = session.attached ? 'alive' : 'dead';
|
||||
const name = (session.name || 'unnamed').slice(0, 20);
|
||||
|
||||
return (
|
||||
<Box key={session.sessionId}>
|
||||
<Text>
|
||||
<Text color="cyan">[{index + 1}]</Text>
|
||||
<Text> </Text>
|
||||
<Text>{name.padEnd(22)}</Text>
|
||||
<Text dimColor>{runtime.padEnd(12)}</Text>
|
||||
<Text color={statusColor}>
|
||||
{statusIcon} {statusText.padEnd(8)}
|
||||
</Text>
|
||||
<Text>{session.mode.padEnd(10)}</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Footer with controls */}
|
||||
<Box marginTop={2} flexDirection="column">
|
||||
<Box borderStyle="single" borderColor="gray" paddingX={1}>
|
||||
<Text>
|
||||
<Text color="green">[n]</Text>
|
||||
<Text> New session </Text>
|
||||
<Text color="green">[1-9]</Text>
|
||||
<Text> Select </Text>
|
||||
<Text color="green">[r]</Text>
|
||||
<Text> Refresh </Text>
|
||||
<Text color="yellow">[q]</Text>
|
||||
<Text> Quit </Text>
|
||||
<Text color="blue">[?]</Text>
|
||||
<Text> Help</Text>
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* @fileoverview StatusBar component
|
||||
*
|
||||
* Bottom status bar showing:
|
||||
* - Session status (idle/working)
|
||||
* - Token count
|
||||
* - Cost
|
||||
* - Runtime
|
||||
* - Quick keyboard shortcuts
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { ScreenSession } from '../../types.js';
|
||||
|
||||
interface StatusBarProps {
|
||||
session: ScreenSession | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats duration from milliseconds to human-readable string
|
||||
*/
|
||||
function formatDuration(ms: number): string {
|
||||
const seconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const hours = Math.floor(minutes / 60);
|
||||
|
||||
if (hours > 0) {
|
||||
return `${hours}h ${minutes % 60}m`;
|
||||
}
|
||||
if (minutes > 0) {
|
||||
return `${minutes}m ${seconds % 60}s`;
|
||||
}
|
||||
return `${seconds}s`;
|
||||
}
|
||||
|
||||
/**
|
||||
* StatusBar component showing session information
|
||||
*/
|
||||
export function StatusBar({ session }: StatusBarProps): React.ReactElement {
|
||||
if (!session) {
|
||||
return (
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderColor="gray"
|
||||
paddingX={1}
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<Text dimColor>No session selected</Text>
|
||||
<Text dimColor>Press ? for help | Esc to go back</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const runtime = formatDuration(Date.now() - session.createdAt);
|
||||
const statusColor = session.attached ? 'green' : 'red';
|
||||
const statusText = session.attached ? 'alive' : 'dead';
|
||||
|
||||
return (
|
||||
<Box
|
||||
borderStyle="single"
|
||||
borderColor="cyan"
|
||||
paddingX={1}
|
||||
justifyContent="space-between"
|
||||
>
|
||||
{/* Left side: Session info */}
|
||||
<Box>
|
||||
<Text color={statusColor} bold>
|
||||
{'\u25CF'} {statusText}
|
||||
</Text>
|
||||
<Text> | </Text>
|
||||
<Text>
|
||||
<Text dimColor>runtime:</Text> {runtime}
|
||||
</Text>
|
||||
<Text> | </Text>
|
||||
<Text>
|
||||
<Text dimColor>mode:</Text> {session.mode}
|
||||
</Text>
|
||||
<Text> | </Text>
|
||||
<Text>
|
||||
<Text dimColor>screen:</Text> {session.screenName}
|
||||
</Text>
|
||||
</Box>
|
||||
|
||||
{/* Right side: Keyboard hints */}
|
||||
<Box>
|
||||
<Text dimColor>? help | Esc back | Ctrl+C exit</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* @fileoverview TabBar component
|
||||
*
|
||||
* Horizontal tab bar showing all active sessions.
|
||||
* Features:
|
||||
* - Visual indication of active tab
|
||||
* - Status indicator (idle/working)
|
||||
* - Tab truncation for long names
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { ScreenSession } from '../../types.js';
|
||||
|
||||
interface TabBarProps {
|
||||
sessions: ScreenSession[];
|
||||
activeSessionId: string | null;
|
||||
onSelectSession: (sessionId: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* TabBar component for session navigation
|
||||
*/
|
||||
export function TabBar({
|
||||
sessions,
|
||||
activeSessionId,
|
||||
}: TabBarProps): React.ReactElement {
|
||||
if (sessions.length === 0) {
|
||||
return (
|
||||
<Box borderStyle="single" borderColor="gray" paddingX={1}>
|
||||
<Text dimColor>No sessions</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box borderStyle="single" borderColor="cyan" paddingX={1}>
|
||||
{sessions.map((session, index) => {
|
||||
const isActive = session.sessionId === activeSessionId;
|
||||
const name = (session.name || 'unnamed').slice(0, 15);
|
||||
// Status indicator: filled circle for alive, hollow for dead
|
||||
const statusIcon = session.attached ? '\u25CF' : '\u25CB';
|
||||
const statusColor = session.attached ? 'green' : 'red';
|
||||
|
||||
return (
|
||||
<Box key={session.sessionId} marginRight={1}>
|
||||
{isActive ? (
|
||||
<Text backgroundColor="blue" color="white" bold>
|
||||
{' '}
|
||||
<Text color={statusColor}>{statusIcon}</Text> {name}{' '}
|
||||
</Text>
|
||||
) : (
|
||||
<Text>
|
||||
{' '}
|
||||
<Text color={statusColor}>{statusIcon}</Text>{' '}
|
||||
<Text dimColor>{name}</Text>{' '}
|
||||
</Text>
|
||||
)}
|
||||
{index < sessions.length - 1 && <Text dimColor>|</Text>}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Shortcut hint */}
|
||||
<Box flexGrow={1} justifyContent="flex-end">
|
||||
<Text dimColor>Ctrl+Tab: switch | Ctrl+W: close</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* @fileoverview TerminalView component
|
||||
*
|
||||
* Displays PTY output from the active session.
|
||||
* Features:
|
||||
* - Scrollable viewport showing last N lines
|
||||
* - ANSI color support (handled by Ink)
|
||||
* - Visual border indicating focus
|
||||
*/
|
||||
|
||||
import React, { useMemo } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import type { ScreenSession } from '../../types.js';
|
||||
|
||||
interface TerminalViewProps {
|
||||
output: string;
|
||||
height: number;
|
||||
session: ScreenSession | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* TerminalView component for PTY output display
|
||||
*/
|
||||
export function TerminalView({
|
||||
output,
|
||||
height,
|
||||
session,
|
||||
}: TerminalViewProps): React.ReactElement {
|
||||
// Split output into lines and get the last N lines that fit
|
||||
const displayLines = useMemo(() => {
|
||||
if (!output) return [];
|
||||
|
||||
const lines = output.split('\n');
|
||||
// Reserve 2 lines for border
|
||||
const visibleLines = height - 2;
|
||||
|
||||
if (lines.length <= visibleLines) {
|
||||
return lines;
|
||||
}
|
||||
|
||||
// Return last N lines
|
||||
return lines.slice(-visibleLines);
|
||||
}, [output, height]);
|
||||
|
||||
if (!session) {
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="single"
|
||||
borderColor="gray"
|
||||
height={height}
|
||||
paddingX={1}
|
||||
>
|
||||
<Box flexGrow={1} alignItems="center" justifyContent="center">
|
||||
<Text dimColor>No session selected</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box
|
||||
flexDirection="column"
|
||||
borderStyle="single"
|
||||
borderColor="green"
|
||||
height={height}
|
||||
paddingX={1}
|
||||
overflow="hidden"
|
||||
>
|
||||
{/* Terminal content */}
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
{displayLines.length === 0 ? (
|
||||
<Text dimColor>Waiting for output...</Text>
|
||||
) : (
|
||||
displayLines.map((line, index) => (
|
||||
<Text key={index} wrap="truncate">
|
||||
{line}
|
||||
</Text>
|
||||
))
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* @fileoverview Component exports for TUI
|
||||
*/
|
||||
|
||||
export { StartScreen } from './StartScreen.js';
|
||||
export { TabBar } from './TabBar.js';
|
||||
export { TerminalView } from './TerminalView.js';
|
||||
export { StatusBar } from './StatusBar.js';
|
||||
export { HelpOverlay } from './HelpOverlay.js';
|
||||
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* @fileoverview Hook exports for TUI
|
||||
*/
|
||||
|
||||
export { useSessionManager } from './useSessionManager.js';
|
||||
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* @fileoverview useSessionManager hook
|
||||
*
|
||||
* Manages session state for the TUI. Connects to:
|
||||
* - ~/.claudeman/screens.json for session discovery
|
||||
* - ScreenManager for session operations
|
||||
* - Session class for terminal output
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { existsSync, readFileSync, watchFile, unwatchFile } from 'fs';
|
||||
import { homedir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { execSync, spawn } from 'child_process';
|
||||
import type { ScreenSession } from '../../types.js';
|
||||
|
||||
const SCREENS_FILE = join(homedir(), '.claudeman', 'screens.json');
|
||||
|
||||
interface SessionManagerState {
|
||||
sessions: ScreenSession[];
|
||||
activeSessionId: string | null;
|
||||
activeSession: ScreenSession | null;
|
||||
terminalOutput: string;
|
||||
refreshSessions: () => void;
|
||||
selectSession: (sessionId: string) => void;
|
||||
createSession: () => Promise<string | null>;
|
||||
killSession: (sessionId: string) => void;
|
||||
killAllSessions: () => void;
|
||||
nextSession: () => void;
|
||||
prevSession: () => void;
|
||||
sendInput: (sessionId: string, input: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a screen session is alive
|
||||
*/
|
||||
function isScreenAlive(screenName: string): boolean {
|
||||
try {
|
||||
const output = execSync('screen -ls', { encoding: 'utf-8' });
|
||||
return output.includes(screenName);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load sessions from screens.json
|
||||
*/
|
||||
function loadSessions(): ScreenSession[] {
|
||||
try {
|
||||
if (!existsSync(SCREENS_FILE)) {
|
||||
return [];
|
||||
}
|
||||
const data = readFileSync(SCREENS_FILE, 'utf-8');
|
||||
const sessions: ScreenSession[] = JSON.parse(data);
|
||||
|
||||
// Check which sessions are alive
|
||||
return sessions.map((session) => ({
|
||||
...session,
|
||||
attached: isScreenAlive(session.screenName),
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing sessions in the TUI
|
||||
*/
|
||||
export function useSessionManager(): SessionManagerState {
|
||||
const [sessions, setSessions] = useState<ScreenSession[]>([]);
|
||||
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
||||
const [terminalOutput, setTerminalOutput] = useState<string>('');
|
||||
const outputBufferRef = useRef<string>('');
|
||||
|
||||
// Load sessions on mount and watch for changes
|
||||
useEffect(() => {
|
||||
// Initial load
|
||||
setSessions(loadSessions());
|
||||
|
||||
// Watch for changes
|
||||
const handleChange = () => {
|
||||
setSessions(loadSessions());
|
||||
};
|
||||
|
||||
try {
|
||||
watchFile(SCREENS_FILE, { interval: 1000 }, handleChange);
|
||||
} catch {
|
||||
// File may not exist yet
|
||||
}
|
||||
|
||||
return () => {
|
||||
try {
|
||||
unwatchFile(SCREENS_FILE, handleChange);
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Get active session
|
||||
const activeSession = sessions.find((s) => s.sessionId === activeSessionId) || null;
|
||||
|
||||
// Refresh sessions manually
|
||||
const refreshSessions = useCallback(() => {
|
||||
setSessions(loadSessions());
|
||||
}, []);
|
||||
|
||||
// Select a session
|
||||
const selectSession = useCallback((sessionId: string) => {
|
||||
setActiveSessionId(sessionId);
|
||||
setTerminalOutput('');
|
||||
outputBufferRef.current = '';
|
||||
|
||||
// Start capturing output from the screen session
|
||||
const session = sessions.find((s) => s.sessionId === sessionId);
|
||||
if (session && isScreenAlive(session.screenName)) {
|
||||
// Use screen -X hardcopy to capture current screen content
|
||||
try {
|
||||
const hardcopyFile = `/tmp/claudeman-${sessionId}-hardcopy`;
|
||||
execSync(`screen -S ${session.screenName} -X hardcopy ${hardcopyFile}`, {
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
if (existsSync(hardcopyFile)) {
|
||||
const content = readFileSync(hardcopyFile, 'utf-8');
|
||||
setTerminalOutput(content);
|
||||
outputBufferRef.current = content;
|
||||
}
|
||||
} catch {
|
||||
// Hardcopy may fail, that's ok
|
||||
}
|
||||
}
|
||||
}, [sessions]);
|
||||
|
||||
// Create new session
|
||||
const createSession = useCallback(async (): 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: `tui-${Date.now()}` }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json() as { success?: boolean; sessionId?: string };
|
||||
if (data.success && data.sessionId) {
|
||||
// Refresh and select new session
|
||||
const sessionId = data.sessionId;
|
||||
setTimeout(() => {
|
||||
refreshSessions();
|
||||
setActiveSessionId(sessionId);
|
||||
}, 500);
|
||||
return sessionId;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Just report that web server isn't running
|
||||
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.'
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}, [refreshSessions]);
|
||||
|
||||
// Kill a session
|
||||
const killSession = useCallback((sessionId: string) => {
|
||||
const session = sessions.find((s) => s.sessionId === sessionId);
|
||||
if (!session) return;
|
||||
|
||||
try {
|
||||
// Kill via screen
|
||||
execSync(`screen -S ${session.screenName} -X quit`, { encoding: 'utf-8' });
|
||||
} catch {
|
||||
// May already be dead
|
||||
}
|
||||
|
||||
// If this was the active session, select another
|
||||
if (activeSessionId === sessionId) {
|
||||
const remaining = sessions.filter((s) => s.sessionId !== sessionId);
|
||||
if (remaining.length > 0) {
|
||||
setActiveSessionId(remaining[0].sessionId);
|
||||
} else {
|
||||
setActiveSessionId(null);
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh to update the list
|
||||
setTimeout(refreshSessions, 500);
|
||||
}, [sessions, activeSessionId, refreshSessions]);
|
||||
|
||||
// Kill all sessions
|
||||
const killAllSessions = useCallback(() => {
|
||||
for (const session of sessions) {
|
||||
try {
|
||||
execSync(`screen -S ${session.screenName} -X quit`, { encoding: 'utf-8' });
|
||||
} catch {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
setActiveSessionId(null);
|
||||
setTimeout(refreshSessions, 500);
|
||||
}, [sessions, refreshSessions]);
|
||||
|
||||
// Navigate to next session
|
||||
const nextSession = useCallback(() => {
|
||||
if (sessions.length === 0) return;
|
||||
const currentIndex = sessions.findIndex((s) => s.sessionId === activeSessionId);
|
||||
const nextIndex = (currentIndex + 1) % sessions.length;
|
||||
selectSession(sessions[nextIndex].sessionId);
|
||||
}, [sessions, activeSessionId, selectSession]);
|
||||
|
||||
// Navigate to previous session
|
||||
const prevSession = useCallback(() => {
|
||||
if (sessions.length === 0) return;
|
||||
const currentIndex = sessions.findIndex((s) => s.sessionId === activeSessionId);
|
||||
const prevIndex = currentIndex <= 0 ? sessions.length - 1 : currentIndex - 1;
|
||||
selectSession(sessions[prevIndex].sessionId);
|
||||
}, [sessions, activeSessionId, selectSession]);
|
||||
|
||||
// Send input to a session
|
||||
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, "'\\''");
|
||||
|
||||
if (input === '\r') {
|
||||
// Send Enter key
|
||||
execSync(`screen -S ${session.screenName} -p 0 -X stuff $'\\015'`, {
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
} else {
|
||||
execSync(`screen -S ${session.screenName} -p 0 -X stuff '${escaped}'`, {
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Input may fail if screen is not ready
|
||||
}
|
||||
}, [sessions]);
|
||||
|
||||
return {
|
||||
sessions,
|
||||
activeSessionId,
|
||||
activeSession,
|
||||
terminalOutput,
|
||||
refreshSessions,
|
||||
selectSession,
|
||||
createSession,
|
||||
killSession,
|
||||
killAllSessions,
|
||||
nextSession,
|
||||
prevSession,
|
||||
sendInput,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* @fileoverview TUI entry point for Claudeman
|
||||
*
|
||||
* Renders the terminal user interface using Ink (React for CLI).
|
||||
* This provides a full-screen TUI similar to the web interface,
|
||||
* with tabs for sessions and real-time terminal output.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { render } from 'ink';
|
||||
import { App } from './App.js';
|
||||
|
||||
/**
|
||||
* Checks if the terminal supports raw mode (required for TUI)
|
||||
*/
|
||||
function isRawModeSupported(): boolean {
|
||||
return Boolean(
|
||||
process.stdin.isTTY &&
|
||||
typeof process.stdin.setRawMode === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the TUI application
|
||||
* @returns Promise that resolves when the app exits
|
||||
*/
|
||||
export async function startTUI(): Promise<void> {
|
||||
// Check if we're in an interactive terminal
|
||||
if (!isRawModeSupported()) {
|
||||
console.error('Error: TUI requires an interactive terminal with TTY support.');
|
||||
console.error('Make sure you are running this command in a real terminal, not piped.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Clear the terminal for full-screen experience
|
||||
process.stdout.write('\x1b[2J\x1b[H');
|
||||
|
||||
const { waitUntilExit } = render(<App />);
|
||||
|
||||
await waitUntilExit();
|
||||
}
|
||||
Reference in New Issue
Block a user