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<
|
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}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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,
|
||||||
|
|
Loading…
Reference in New Issue