From 2444209723701dda2b881cea2501b239e64e51c1 Mon Sep 17 00:00:00 2001 From: PaloSP <32291845+PaloSP@users.noreply.github.com> Date: Thu, 5 Mar 2026 09:35:28 +0100 Subject: [PATCH] feat: add clickable overlay buttons for CLI prompts in Shell terminal (#480) * feat: add clickable overlay buttons for CLI prompt selection Detect numbered selection prompts in the xterm.js terminal buffer and display clickable overlay buttons, allowing users to respond by tapping instead of typing numbers. Useful on mobile/tablet devices. Closes #427 * fix: address CodeRabbit review feedback - Remove fallback option scanning without footer anchor to prevent false positives on regular numbered lists in conversation output - Cancel pending prompt check timer on disconnect to prevent stale options from reappearing after reconnection * fix: require contiguous option block above footer anchor Stop collecting numbered options as soon as a non-matching line is encountered, preventing false matches from non-contiguous numbered text above the prompt. Addresses CodeRabbit review feedback on PR #480. * revert: allow non-contiguous option lines for multi-line labels CLI prompts may wrap options across multiple terminal rows or include blank separators. Revert contiguous-block requirement and document why non-matching lines are tolerated during upward scan. --- src/components/shell/constants/constants.ts | 7 + .../shell/hooks/useShellConnection.ts | 5 +- src/components/shell/hooks/useShellRuntime.ts | 2 + src/components/shell/types/types.ts | 1 + src/components/shell/view/Shell.tsx | 141 +++++++++++++++++- 5 files changed, 153 insertions(+), 3 deletions(-) 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) => ( + + ))} + +
+
+ )} + ); }