diff --git a/src/components/shell/constants/constants.ts b/src/components/shell/constants/constants.ts index 9a523d4c..49dffd50 100644 --- a/src/components/shell/constants/constants.ts +++ b/src/components/shell/constants/constants.ts @@ -5,6 +5,13 @@ export const SHELL_RESTART_DELAY_MS = 200; export const TERMINAL_INIT_DELAY_MS = 100; export const TERMINAL_RESIZE_DELAY_MS = 50; +// CLI prompt overlay detection +export const PROMPT_DEBOUNCE_MS = 500; +export const PROMPT_BUFFER_SCAN_LINES = 20; +export const PROMPT_OPTION_SCAN_LINES = 15; +export const PROMPT_MAX_OPTIONS = 5; +export const PROMPT_MIN_OPTIONS = 2; + export const TERMINAL_OPTIONS: ITerminalOptions = { cursorBlink: true, fontSize: 14, diff --git a/src/components/shell/hooks/useShellConnection.ts b/src/components/shell/hooks/useShellConnection.ts index b60ef94c..7babed15 100644 --- a/src/components/shell/hooks/useShellConnection.ts +++ b/src/components/shell/hooks/useShellConnection.ts @@ -24,6 +24,7 @@ type UseShellConnectionOptions = { closeSocket: () => void; clearTerminalScreen: () => void; setAuthUrl: (nextAuthUrl: string) => void; + onOutputRef?: MutableRefObject<(() => void) | null>; }; type UseShellConnectionResult = { @@ -48,6 +49,7 @@ export function useShellConnection({ closeSocket, clearTerminalScreen, setAuthUrl, + onOutputRef, }: UseShellConnectionOptions): UseShellConnectionResult { const [isConnected, setIsConnected] = useState(false); const [isConnecting, setIsConnecting] = useState(false); @@ -91,6 +93,7 @@ export function useShellConnection({ const output = typeof message.data === 'string' ? message.data : ''; handleProcessCompletion(output); terminalRef.current?.write(output); + onOutputRef?.current?.(); return; } @@ -101,7 +104,7 @@ export function useShellConnection({ } } }, - [handleProcessCompletion, setAuthUrl, terminalRef], + [handleProcessCompletion, onOutputRef, setAuthUrl, terminalRef], ); const connectWebSocket = useCallback( diff --git a/src/components/shell/hooks/useShellRuntime.ts b/src/components/shell/hooks/useShellRuntime.ts index ecef3aa3..75aac40f 100644 --- a/src/components/shell/hooks/useShellRuntime.ts +++ b/src/components/shell/hooks/useShellRuntime.ts @@ -15,6 +15,7 @@ export function useShellRuntime({ autoConnect, isRestarting, onProcessComplete, + onOutputRef, }: UseShellRuntimeOptions): UseShellRuntimeResult { const terminalContainerRef = useRef(null); const terminalRef = useRef(null); @@ -118,6 +119,7 @@ export function useShellRuntime({ closeSocket, clearTerminalScreen, setAuthUrl: setCurrentAuthUrl, + onOutputRef, }); useEffect(() => { diff --git a/src/components/shell/types/types.ts b/src/components/shell/types/types.ts index e4bed994..14df2ea7 100644 --- a/src/components/shell/types/types.ts +++ b/src/components/shell/types/types.ts @@ -45,6 +45,7 @@ export type UseShellRuntimeOptions = { autoConnect: boolean; isRestarting: boolean; onProcessComplete?: ((exitCode: number) => void) | null; + onOutputRef?: MutableRefObject<(() => void) | null>; }; export type ShellSharedRefs = { diff --git a/src/components/shell/view/Shell.tsx b/src/components/shell/view/Shell.tsx index 822397c9..8fd18222 100644 --- a/src/components/shell/view/Shell.tsx +++ b/src/components/shell/view/Shell.tsx @@ -1,9 +1,17 @@ -import { useCallback, useMemo, useState } from 'react'; +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 { SHELL_RESTART_DELAY_MS } from '../constants/constants'; +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'; @@ -11,6 +19,8 @@ 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; @@ -34,6 +44,9 @@ export default function Shell({ }: 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); // Keep the public API stable for existing callers that still pass `isActive`. void isActive; @@ -60,8 +73,97 @@ export default function Shell({ 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]); + + 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), @@ -159,6 +261,40 @@ export default function Shell({ onConnect={connectToShell} /> )} + + {cliPromptOptions && isConnected && ( +
e.preventDefault()} + > +
+ {cliPromptOptions.map((opt) => ( + + ))} + +
+
+ )} + ); }