summaryrefslogtreecommitdiff
path: root/packages/cli/src
diff options
context:
space:
mode:
authorfuyou <[email protected]>2025-08-10 06:26:43 +0800
committerGitHub <[email protected]>2025-08-09 22:26:43 +0000
commit0dea7233b65b480466e4da62db9127205b57a6d0 (patch)
treedba8b2d92d3b914873f7eea78cdb2cd39c91f038 /packages/cli/src
parent34434cd4aad088fe001708ff4685d7c97be099eb (diff)
feat(cli) - enhance input UX with double ESC clear (#4453)
Co-authored-by: Jacob Richman <[email protected]>
Diffstat (limited to 'packages/cli/src')
-rw-r--r--packages/cli/src/ui/App.tsx9
-rw-r--r--packages/cli/src/ui/components/InputPrompt.test.tsx100
-rw-r--r--packages/cli/src/ui/components/InputPrompt.tsx67
3 files changed, 174 insertions, 2 deletions
diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx
index 7ee9405f..a52236f8 100644
--- a/packages/cli/src/ui/App.tsx
+++ b/packages/cli/src/ui/App.tsx
@@ -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}
diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx
index f050ba07..a29a095a 100644
--- a/packages/cli/src/ui/components/InputPrompt.test.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.test.tsx
@@ -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;
diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx
index 78b3b96b..7eb1905d 100644
--- a/packages/cli/src/ui/components/InputPrompt.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.tsx
@@ -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,