feat(cli) - enhance input UX with double ESC clear (#4453)

Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
fuyou 2025-08-10 06:26:43 +08:00 committed by GitHub
parent 34434cd4aa
commit 0dea7233b6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 174 additions and 2 deletions

View File

@ -190,6 +190,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
const [ideContextState, setIdeContextState] = useState< const [ideContextState, setIdeContextState] = useState<
IdeContext | undefined IdeContext | undefined
>(); >();
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
const [isProcessing, setIsProcessing] = useState<boolean>(false); const [isProcessing, setIsProcessing] = useState<boolean>(false);
useEffect(() => { useEffect(() => {
@ -224,6 +225,11 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
const openPrivacyNotice = useCallback(() => { const openPrivacyNotice = useCallback(() => {
setShowPrivacyNotice(true); setShowPrivacyNotice(true);
}, []); }, []);
const handleEscapePromptChange = useCallback((showPrompt: boolean) => {
setShowEscapePrompt(showPrompt);
}, []);
const initialPromptSubmitted = useRef(false); const initialPromptSubmitted = useRef(false);
const errorCount = useMemo( const errorCount = useMemo(
@ -1055,6 +1061,8 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
<Text color={Colors.AccentYellow}> <Text color={Colors.AccentYellow}>
Press Ctrl+D again to exit. Press Ctrl+D again to exit.
</Text> </Text>
) : showEscapePrompt ? (
<Text color={Colors.Gray}>Press Esc again to clear.</Text>
) : ( ) : (
<ContextSummaryDisplay <ContextSummaryDisplay
ideContext={ideContextState} ideContext={ideContextState}
@ -1105,6 +1113,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
commandContext={commandContext} commandContext={commandContext}
shellModeActive={shellModeActive} shellModeActive={shellModeActive}
setShellModeActive={setShellModeActive} setShellModeActive={setShellModeActive}
onEscapePromptChange={handleEscapePromptChange}
focus={isFocused} focus={isFocused}
vimHandleInput={vimHandleInput} vimHandleInput={vimHandleInput}
placeholder={placeholder} placeholder={placeholder}

View File

@ -1191,6 +1191,106 @@ describe('InputPrompt', () => {
}); });
}); });
describe('enhanced input UX - double ESC clear functionality', () => {
it('should clear buffer on second ESC press', async () => {
const onEscapePromptChange = vi.fn();
props.onEscapePromptChange = onEscapePromptChange;
props.buffer.setText('text to clear');
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\x1B');
await wait();
stdin.write('\x1B');
await wait();
expect(props.buffer.setText).toHaveBeenCalledWith('');
expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled();
unmount();
});
it('should reset escape state on any non-ESC key', async () => {
const onEscapePromptChange = vi.fn();
props.onEscapePromptChange = onEscapePromptChange;
props.buffer.setText('some text');
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\x1B');
await wait();
expect(onEscapePromptChange).toHaveBeenCalledWith(true);
stdin.write('a');
await wait();
expect(onEscapePromptChange).toHaveBeenCalledWith(false);
unmount();
});
it('should handle ESC in shell mode by disabling shell mode', async () => {
props.shellModeActive = true;
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\x1B');
await wait();
expect(props.setShellModeActive).toHaveBeenCalledWith(false);
unmount();
});
it('should handle ESC when completion suggestions are showing', async () => {
mockedUseCommandCompletion.mockReturnValue({
...mockCommandCompletion,
showSuggestions: true,
suggestions: [{ label: 'suggestion', value: 'suggestion' }],
});
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\x1B');
await wait();
expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled();
unmount();
});
it('should not call onEscapePromptChange when not provided', async () => {
props.onEscapePromptChange = undefined;
props.buffer.setText('some text');
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\x1B');
await wait();
unmount();
});
it('should not interfere with existing keyboard shortcuts', async () => {
const { stdin, unmount } = render(<InputPrompt {...props} />);
await wait();
stdin.write('\x0C');
await wait();
expect(props.onClearScreen).toHaveBeenCalled();
stdin.write('\x01');
await wait();
expect(props.buffer.move).toHaveBeenCalledWith('home');
unmount();
});
});
describe('reverse search', () => { describe('reverse search', () => {
beforeEach(async () => { beforeEach(async () => {
props.shellModeActive = true; props.shellModeActive = true;

View File

@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import React, { useCallback, useEffect, useState } from 'react'; import React, { useCallback, useEffect, useState, useRef } from 'react';
import { Box, Text } from 'ink'; import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js'; import { theme } from '../semantic-colors.js';
import { SuggestionsDisplay } from './SuggestionsDisplay.js'; import { SuggestionsDisplay } from './SuggestionsDisplay.js';
@ -41,6 +41,7 @@ export interface InputPromptProps {
suggestionsWidth: number; suggestionsWidth: number;
shellModeActive: boolean; shellModeActive: boolean;
setShellModeActive: (value: boolean) => void; setShellModeActive: (value: boolean) => void;
onEscapePromptChange?: (showPrompt: boolean) => void;
vimHandleInput?: (key: Key) => boolean; vimHandleInput?: (key: Key) => boolean;
} }
@ -58,9 +59,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
suggestionsWidth, suggestionsWidth,
shellModeActive, shellModeActive,
setShellModeActive, setShellModeActive,
onEscapePromptChange,
vimHandleInput, vimHandleInput,
}) => { }) => {
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false); const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
const [escPressCount, setEscPressCount] = useState(0);
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
const escapeTimerRef = useRef<NodeJS.Timeout | null>(null);
const [dirs, setDirs] = useState<readonly string[]>( const [dirs, setDirs] = useState<readonly string[]>(
config.getWorkspaceContext().getDirectories(), config.getWorkspaceContext().getDirectories(),
@ -98,6 +103,32 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
const resetReverseSearchCompletionState = const resetReverseSearchCompletionState =
reverseSearchCompletion.resetCompletionState; reverseSearchCompletion.resetCompletionState;
const resetEscapeState = useCallback(() => {
if (escapeTimerRef.current) {
clearTimeout(escapeTimerRef.current);
escapeTimerRef.current = null;
}
setEscPressCount(0);
setShowEscapePrompt(false);
}, []);
// Notify parent component about escape prompt state changes
useEffect(() => {
if (onEscapePromptChange) {
onEscapePromptChange(showEscapePrompt);
}
}, [showEscapePrompt, onEscapePromptChange]);
// Clear escape prompt timer on unmount
useEffect(
() => () => {
if (escapeTimerRef.current) {
clearTimeout(escapeTimerRef.current);
}
},
[],
);
const handleSubmitAndClear = useCallback( const handleSubmitAndClear = useCallback(
(submittedValue: string) => { (submittedValue: string) => {
if (shellModeActive) { if (shellModeActive) {
@ -212,6 +243,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return; return;
} }
// Reset ESC count and hide prompt on any non-ESC key
if (key.name !== 'escape') {
if (escPressCount > 0 || showEscapePrompt) {
resetEscapeState();
}
}
if ( if (
key.sequence === '!' && key.sequence === '!' &&
buffer.text === '' && buffer.text === '' &&
@ -237,13 +275,36 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
} }
if (shellModeActive) { if (shellModeActive) {
setShellModeActive(false); setShellModeActive(false);
resetEscapeState();
return; return;
} }
if (completion.showSuggestions) { if (completion.showSuggestions) {
completion.resetCompletionState(); completion.resetCompletionState();
resetEscapeState();
return; return;
} }
// Handle double ESC for clearing input
if (escPressCount === 0) {
if (buffer.text === '') {
return;
}
setEscPressCount(1);
setShowEscapePrompt(true);
if (escapeTimerRef.current) {
clearTimeout(escapeTimerRef.current);
}
escapeTimerRef.current = setTimeout(() => {
resetEscapeState();
}, 500);
} else {
// clear input and immediately reset state
buffer.setText('');
resetCompletionState();
resetEscapeState();
}
return;
} }
if (shellModeActive && keyMatchers[Command.REVERSE_SEARCH](key)) { if (shellModeActive && keyMatchers[Command.REVERSE_SEARCH](key)) {
@ -418,7 +479,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
if (buffer.text.length > 0) { if (buffer.text.length > 0) {
buffer.setText(''); buffer.setText('');
resetCompletionState(); resetCompletionState();
return;
} }
return; return;
} }
@ -461,6 +521,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
reverseSearchCompletion, reverseSearchCompletion,
handleClipboardImage, handleClipboardImage,
resetCompletionState, resetCompletionState,
escPressCount,
showEscapePrompt,
resetEscapeState,
vimHandleInput, vimHandleInput,
reverseSearchActive, reverseSearchActive,
textBeforeReverseSearch, textBeforeReverseSearch,