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(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) => { Press Ctrl+D again to exit. + ) : showEscapePrompt ? ( + Press Esc again to clear. ) : ( { 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(); + 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(); + 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(); + 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(); + 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(); + await wait(); + + stdin.write('\x1B'); + await wait(); + + unmount(); + }); + + it('should not interfere with existing keyboard shortcuts', async () => { + const { stdin, unmount } = render(); + 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 = ({ suggestionsWidth, shellModeActive, setShellModeActive, + onEscapePromptChange, vimHandleInput, }) => { const [justNavigatedHistory, setJustNavigatedHistory] = useState(false); + const [escPressCount, setEscPressCount] = useState(0); + const [showEscapePrompt, setShowEscapePrompt] = useState(false); + const escapeTimerRef = useRef(null); const [dirs, setDirs] = useState( config.getWorkspaceContext().getDirectories(), @@ -98,6 +103,32 @@ export const InputPrompt: React.FC = ({ 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 = ({ 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 = ({ } 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 = ({ if (buffer.text.length > 0) { buffer.setText(''); resetCompletionState(); - return; } return; } @@ -461,6 +521,9 @@ export const InputPrompt: React.FC = ({ reverseSearchCompletion, handleClipboardImage, resetCompletionState, + escPressCount, + showEscapePrompt, + resetEscapeState, vimHandleInput, reverseSearchActive, textBeforeReverseSearch,