feat(cli) - enhance input UX with double ESC clear (#4453)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
This commit is contained in:
parent
34434cd4aa
commit
0dea7233b6
|
@ -190,6 +190,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
|||
const [ideContextState, setIdeContextState] = useState<
|
||||
IdeContext | undefined
|
||||
>();
|
||||
const [showEscapePrompt, setShowEscapePrompt] = useState(false);
|
||||
const [isProcessing, setIsProcessing] = useState<boolean>(false);
|
||||
|
||||
useEffect(() => {
|
||||
|
@ -224,6 +225,11 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
|||
const openPrivacyNotice = useCallback(() => {
|
||||
setShowPrivacyNotice(true);
|
||||
}, []);
|
||||
|
||||
const handleEscapePromptChange = useCallback((showPrompt: boolean) => {
|
||||
setShowEscapePrompt(showPrompt);
|
||||
}, []);
|
||||
|
||||
const initialPromptSubmitted = useRef(false);
|
||||
|
||||
const errorCount = useMemo(
|
||||
|
@ -1055,6 +1061,8 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
|||
<Text color={Colors.AccentYellow}>
|
||||
Press Ctrl+D again to exit.
|
||||
</Text>
|
||||
) : showEscapePrompt ? (
|
||||
<Text color={Colors.Gray}>Press Esc again to clear.</Text>
|
||||
) : (
|
||||
<ContextSummaryDisplay
|
||||
ideContext={ideContextState}
|
||||
|
@ -1105,6 +1113,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => {
|
|||
commandContext={commandContext}
|
||||
shellModeActive={shellModeActive}
|
||||
setShellModeActive={setShellModeActive}
|
||||
onEscapePromptChange={handleEscapePromptChange}
|
||||
focus={isFocused}
|
||||
vimHandleInput={vimHandleInput}
|
||||
placeholder={placeholder}
|
||||
|
|
|
@ -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', () => {
|
||||
beforeEach(async () => {
|
||||
props.shellModeActive = true;
|
||||
|
|
|
@ -4,7 +4,7 @@
|
|||
* 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 { theme } from '../semantic-colors.js';
|
||||
import { SuggestionsDisplay } from './SuggestionsDisplay.js';
|
||||
|
@ -41,6 +41,7 @@ export interface InputPromptProps {
|
|||
suggestionsWidth: number;
|
||||
shellModeActive: boolean;
|
||||
setShellModeActive: (value: boolean) => void;
|
||||
onEscapePromptChange?: (showPrompt: boolean) => void;
|
||||
vimHandleInput?: (key: Key) => boolean;
|
||||
}
|
||||
|
||||
|
@ -58,9 +59,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
suggestionsWidth,
|
||||
shellModeActive,
|
||||
setShellModeActive,
|
||||
onEscapePromptChange,
|
||||
vimHandleInput,
|
||||
}) => {
|
||||
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[]>(
|
||||
config.getWorkspaceContext().getDirectories(),
|
||||
|
@ -98,6 +103,32 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
const resetReverseSearchCompletionState =
|
||||
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(
|
||||
(submittedValue: string) => {
|
||||
if (shellModeActive) {
|
||||
|
@ -212,6 +243,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
return;
|
||||
}
|
||||
|
||||
// Reset ESC count and hide prompt on any non-ESC key
|
||||
if (key.name !== 'escape') {
|
||||
if (escPressCount > 0 || showEscapePrompt) {
|
||||
resetEscapeState();
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
key.sequence === '!' &&
|
||||
buffer.text === '' &&
|
||||
|
@ -237,13 +275,36 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
}
|
||||
if (shellModeActive) {
|
||||
setShellModeActive(false);
|
||||
resetEscapeState();
|
||||
return;
|
||||
}
|
||||
|
||||
if (completion.showSuggestions) {
|
||||
completion.resetCompletionState();
|
||||
resetEscapeState();
|
||||
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)) {
|
||||
|
@ -418,7 +479,6 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
if (buffer.text.length > 0) {
|
||||
buffer.setText('');
|
||||
resetCompletionState();
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
@ -461,6 +521,9 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||
reverseSearchCompletion,
|
||||
handleClipboardImage,
|
||||
resetCompletionState,
|
||||
escPressCount,
|
||||
showEscapePrompt,
|
||||
resetEscapeState,
|
||||
vimHandleInput,
|
||||
reverseSearchActive,
|
||||
textBeforeReverseSearch,
|
||||
|
|
Loading…
Reference in New Issue