import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import '@xterm/xterm/css/xterm.css'; import type { Project, ProjectSession } from '../../../types/app'; import { PROMPT_BUFFER_SCAN_LINES, PROMPT_DEBOUNCE_MS, PROMPT_MAX_OPTIONS, PROMPT_MIN_OPTIONS, PROMPT_OPTION_SCAN_LINES, SHELL_RESTART_DELAY_MS, } from '../constants/constants'; import { useShellRuntime } from '../hooks/useShellRuntime'; import { sendSocketMessage } from '../utils/socket'; import { getSessionDisplayName } from '../utils/auth'; import ShellConnectionOverlay from './subcomponents/ShellConnectionOverlay'; import ShellEmptyState from './subcomponents/ShellEmptyState'; import ShellHeader from './subcomponents/ShellHeader'; import ShellMinimalView from './subcomponents/ShellMinimalView'; import TerminalShortcutsPanel from './subcomponents/TerminalShortcutsPanel'; type CliPromptOption = { number: string; label: string }; type ShellProps = { selectedProject?: Project | null; selectedSession?: ProjectSession | null; initialCommand?: string | null; isPlainShell?: boolean; onProcessComplete?: ((exitCode: number) => void) | null; minimal?: boolean; autoConnect?: boolean; isActive?: boolean; }; export default function Shell({ selectedProject = null, selectedSession = null, initialCommand = null, isPlainShell = false, onProcessComplete = null, minimal = false, autoConnect = false, isActive = true, }: ShellProps) { const { t } = useTranslation('chat'); const [isRestarting, setIsRestarting] = useState(false); const [cliPromptOptions, setCliPromptOptions] = useState(null); const promptCheckTimer = useRef | null>(null); const onOutputRef = useRef<(() => void) | null>(null); const { terminalContainerRef, terminalRef, wsRef, isConnected, isInitialized, isConnecting, authUrl, authUrlVersion, connectToShell, disconnectFromShell, openAuthUrlInBrowser, copyAuthUrlToClipboard, } = useShellRuntime({ selectedProject, selectedSession, initialCommand, isPlainShell, minimal, autoConnect, isRestarting, onProcessComplete, onOutputRef, }); // Check xterm.js buffer for CLI prompt patterns (❯ N. label) const checkBufferForPrompt = useCallback(() => { const term = terminalRef.current; if (!term) return; const buf = term.buffer.active; const lastContentRow = buf.baseY + buf.cursorY; const scanEnd = Math.min(buf.baseY + buf.length - 1, lastContentRow + 10); const scanStart = Math.max(0, lastContentRow - PROMPT_BUFFER_SCAN_LINES); const lines: string[] = []; for (let i = scanStart; i <= scanEnd; i++) { const line = buf.getLine(i); if (line) lines.push(line.translateToString().trimEnd()); } let footerIdx = -1; for (let i = lines.length - 1; i >= 0; i--) { if (/esc to cancel/i.test(lines[i]) || /enter to select/i.test(lines[i])) { footerIdx = i; break; } } if (footerIdx === -1) { setCliPromptOptions(null); return; } // Scan upward from footer collecting numbered options. // Non-matching lines are allowed (multi-line labels, blank separators) // because CLI prompts may wrap options across multiple terminal rows. const optMap = new Map(); const optScanStart = Math.max(0, footerIdx - PROMPT_OPTION_SCAN_LINES); for (let i = footerIdx - 1; i >= optScanStart; i--) { const match = lines[i].match(/^\s*[❯›>]?\s*(\d+)\.\s+(.+)/); if (match) { const num = match[1]; const label = match[2].trim(); if (parseInt(num, 10) <= PROMPT_MAX_OPTIONS && label.length > 0 && !optMap.has(num)) { optMap.set(num, label); } } } const valid: CliPromptOption[] = []; for (let i = 1; i <= optMap.size; i++) { if (optMap.has(String(i))) valid.push({ number: String(i), label: optMap.get(String(i))! }); else break; } setCliPromptOptions(valid.length >= PROMPT_MIN_OPTIONS ? valid : null); }, [terminalRef]); // Schedule prompt check after terminal output (debounced) const schedulePromptCheck = useCallback(() => { if (promptCheckTimer.current) clearTimeout(promptCheckTimer.current); promptCheckTimer.current = setTimeout(checkBufferForPrompt, PROMPT_DEBOUNCE_MS); }, [checkBufferForPrompt]); // Wire up the onOutput callback useEffect(() => { onOutputRef.current = schedulePromptCheck; }, [schedulePromptCheck]); // Cleanup prompt check timer on unmount useEffect(() => { return () => { if (promptCheckTimer.current) clearTimeout(promptCheckTimer.current); }; }, []); // Clear stale prompt options and cancel pending timer on disconnect useEffect(() => { if (!isConnected) { if (promptCheckTimer.current) { clearTimeout(promptCheckTimer.current); promptCheckTimer.current = null; } setCliPromptOptions(null); } }, [isConnected]); useEffect(() => { if (!isActive || !isInitialized || !isConnected) { return; } const focusTerminal = () => { terminalRef.current?.focus(); }; const animationFrameId = window.requestAnimationFrame(focusTerminal); const timeoutId = window.setTimeout(focusTerminal, 0); return () => { window.cancelAnimationFrame(animationFrameId); window.clearTimeout(timeoutId); }; }, [isActive, isConnected, isInitialized, terminalRef]); const sendInput = useCallback( (data: string) => { sendSocketMessage(wsRef.current, { type: 'input', data }); }, [wsRef], ); const sessionDisplayName = useMemo(() => getSessionDisplayName(selectedSession), [selectedSession]); const sessionDisplayNameShort = useMemo( () => (sessionDisplayName ? sessionDisplayName.slice(0, 30) : null), [sessionDisplayName], ); const sessionDisplayNameLong = useMemo( () => (sessionDisplayName ? sessionDisplayName.slice(0, 50) : null), [sessionDisplayName], ); const handleRestartShell = useCallback(() => { setIsRestarting(true); window.setTimeout(() => { setIsRestarting(false); }, SHELL_RESTART_DELAY_MS); }, []); if (!selectedProject) { return ( ); } if (minimal) { return ( <> ); } const readyDescription = isPlainShell ? t('shell.runCommand', { command: initialCommand || t('shell.defaultCommand'), projectName: selectedProject.displayName, }) : selectedSession ? t('shell.resumeSession', { displayName: sessionDisplayNameLong }) : t('shell.startSession'); const connectingDescription = isPlainShell ? t('shell.runCommand', { command: initialCommand || t('shell.defaultCommand'), projectName: selectedProject.displayName, }) : t('shell.startCli', { projectName: selectedProject.displayName }); const overlayMode = !isInitialized ? 'loading' : isConnecting ? 'connecting' : !isConnected ? 'connect' : null; const overlayDescription = overlayMode === 'connecting' ? connectingDescription : readyDescription; return (
{overlayMode && ( )} {cliPromptOptions && isConnected && (
e.preventDefault()} >
{cliPromptOptions.map((opt) => ( ))}
)}
); }