mirror of
https://github.com/siteboon/claudecodeui.git
synced 2026-03-07 15:07:38 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -45,6 +45,7 @@ export type UseShellRuntimeOptions = {
|
||||
autoConnect: boolean;
|
||||
isRestarting: boolean;
|
||||
onProcessComplete?: ((exitCode: number) => void) | null;
|
||||
onOutputRef?: MutableRefObject<(() => void) | null>;
|
||||
};
|
||||
|
||||
export type ShellSharedRefs = {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user