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.
This commit is contained in:
PaloSP
2026-03-05 09:35:28 +01:00
committed by GitHub
parent 0590c5c178
commit 2444209723
5 changed files with 153 additions and 3 deletions

View File

@@ -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,

View File

@@ -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(

View File

@@ -15,6 +15,7 @@ export function useShellRuntime({
autoConnect,
isRestarting,
onProcessComplete,
onOutputRef,
}: UseShellRuntimeOptions): UseShellRuntimeResult {
const terminalContainerRef = useRef<HTMLDivElement>(null);
const terminalRef = useRef<Terminal | null>(null);
@@ -118,6 +119,7 @@ export function useShellRuntime({
closeSocket,
clearTerminalScreen,
setAuthUrl: setCurrentAuthUrl,
onOutputRef,
});
useEffect(() => {

View File

@@ -45,6 +45,7 @@ export type UseShellRuntimeOptions = {
autoConnect: boolean;
isRestarting: boolean;
onProcessComplete?: ((exitCode: number) => void) | null;
onOutputRef?: MutableRefObject<(() => void) | null>;
};
export type ShellSharedRefs = {

View File

@@ -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<CliPromptOption[] | null>(null);
const promptCheckTimer = useRef<ReturnType<typeof setTimeout> | 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<string, string>();
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 && (
<div
className="absolute inset-x-0 bottom-0 z-10 border-t border-gray-700/80 bg-gray-800/95 px-3 py-2 backdrop-blur-sm"
onMouseDown={(e) => e.preventDefault()}
>
<div className="flex flex-wrap items-center gap-2">
{cliPromptOptions.map((opt) => (
<button
type="button"
key={opt.number}
onClick={() => {
sendInput(opt.number);
setCliPromptOptions(null);
}}
className="px-3 py-1.5 text-xs font-medium rounded bg-blue-600 text-white hover:bg-blue-700 transition-colors max-w-36 truncate"
title={`${opt.number}. ${opt.label}`}
>
{opt.number}. {opt.label}
</button>
))}
<button
type="button"
onClick={() => {
sendInput('\x1b');
setCliPromptOptions(null);
}}
className="px-3 py-1.5 text-xs font-medium rounded bg-gray-700 text-gray-200 hover:bg-gray-600 transition-colors"
>
Esc
</button>
</div>
</div>
)}
</div>
<TerminalShortcutsPanel
@@ -166,6 +302,7 @@ export default function Shell({
terminalRef={terminalRef}
isConnected={isConnected}
/>
</div>
);
}