diff --git a/docs/cli/keyboard-shortcuts.md b/docs/cli/keyboard-shortcuts.md index c496b416c5..bc57dab485 100644 --- a/docs/cli/keyboard-shortcuts.md +++ b/docs/cli/keyboard-shortcuts.md @@ -117,7 +117,8 @@ available combinations. - `!` on an empty prompt: Enter or exit shell mode. - `\` (at end of a line) + `Enter`: Insert a newline without leaving single-line mode. -- `Esc` pressed twice quickly: Browse and rewind previous interactions. +- `Esc` pressed twice quickly: Clear the input prompt if it is not empty, + otherwise browse and rewind previous interactions. - `Up Arrow` / `Down Arrow`: When the cursor is at the top or bottom of a single-line input, navigate backward or forward through prompt history. - `Number keys (1-9, multi-digit)` inside selection dialogs: Jump directly to diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 8368109a8e..1a3e07c2b9 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -100,7 +100,7 @@ const createMockUIState = (overrides: Partial = {}): UIState => showErrorDetails: false, constrainHeight: false, isInputActive: true, - buffer: '', + buffer: { text: '' }, inputWidth: 80, suggestionsWidth: 40, userMessages: [], @@ -389,6 +389,7 @@ describe('Composer', () => { it('shows escape prompt when showEscapePrompt is true', () => { const uiState = createMockUIState({ showEscapePrompt: true, + history: [{ id: 1, type: 'user', text: 'test' }], }); const { lastFrame } = renderComposer(uiState); diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index 0a801cba87..7fa6476a49 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -1893,10 +1893,35 @@ describe('InputPrompt', () => { unmount(); }); - it('should submit /rewind on double ESC', async () => { + it('should submit /rewind on double ESC when buffer is empty', async () => { + const onEscapePromptChange = vi.fn(); + props.onEscapePromptChange = onEscapePromptChange; + props.buffer.setText(''); + vi.mocked(props.buffer.setText).mockClear(); + + const { stdin, unmount } = renderWithProviders( + , + { + uiState: { + history: [{ id: 1, type: 'user', text: 'test' }], + }, + }, + ); + + await act(async () => { + stdin.write('\x1B\x1B'); + vi.advanceTimersByTime(100); + + expect(props.onSubmit).toHaveBeenCalledWith('/rewind'); + }); + unmount(); + }); + + it('should clear the buffer on esc esc if it has text', async () => { const onEscapePromptChange = vi.fn(); props.onEscapePromptChange = onEscapePromptChange; props.buffer.setText('some text'); + vi.mocked(props.buffer.setText).mockClear(); const { stdin, unmount } = renderWithProviders( , @@ -1906,7 +1931,8 @@ describe('InputPrompt', () => { stdin.write('\x1B\x1B'); vi.advanceTimersByTime(100); - expect(props.onSubmit).toHaveBeenCalledWith('/rewind'); + expect(props.buffer.setText).toHaveBeenCalledWith(''); + expect(props.onSubmit).not.toHaveBeenCalledWith('/rewind'); }); unmount(); }); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index f2445d4061..b81dc4a601 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -138,7 +138,7 @@ export const InputPrompt: React.FC = ({ const kittyProtocol = useKittyKeyboardProtocol(); const isShellFocused = useShellFocusState(); const { setEmbeddedShellFocused } = useUIActions(); - const { mainAreaWidth, activePtyId } = useUIState(); + const { mainAreaWidth, activePtyId, history } = useUIState(); const [justNavigatedHistory, setJustNavigatedHistory] = useState(false); const escPressCount = useRef(0); const [showEscapePrompt, setShowEscapePrompt] = useState(false); @@ -495,7 +495,7 @@ export const InputPrompt: React.FC = ({ return; } - // Handle double ESC for rewind + // Handle double ESC if (escPressCount.current === 0) { escPressCount.current = 1; setShowEscapePrompt(true); @@ -506,9 +506,16 @@ export const InputPrompt: React.FC = ({ resetEscapeState(); }, 500); } else { - // Second ESC triggers rewind + // Second ESC resetEscapeState(); - onSubmit('/rewind'); + if (buffer.text.length > 0) { + buffer.setText(''); + resetCompletionState(); + } else { + if (history.length > 0) { + onSubmit('/rewind'); + } + } } return; } @@ -880,6 +887,7 @@ export const InputPrompt: React.FC = ({ onSubmit, activePtyId, setEmbeddedShellFocused, + history, ], ); diff --git a/packages/cli/src/ui/components/StatusDisplay.test.tsx b/packages/cli/src/ui/components/StatusDisplay.test.tsx index 1b8cfbdcb9..8861b3c62a 100644 --- a/packages/cli/src/ui/components/StatusDisplay.test.tsx +++ b/packages/cli/src/ui/components/StatusDisplay.test.tsx @@ -11,6 +11,7 @@ import { StatusDisplay } from './StatusDisplay.js'; import { UIStateContext, type UIState } from '../contexts/UIStateContext.js'; import { ConfigContext } from '../contexts/ConfigContext.js'; import { SettingsContext } from '../contexts/SettingsContext.js'; +import type { TextBuffer } from './shared/text-buffer.js'; // Mock child components to simplify testing vi.mock('./ContextSummaryDisplay.js', () => ({ @@ -23,8 +24,13 @@ vi.mock('./HookStatusDisplay.js', () => ({ HookStatusDisplay: () => Mock Hook Status Display, })); +// Use a type that allows partial buffer for mocking purposes +type UIStateOverrides = Partial> & { + buffer?: Partial; +}; + // Create mock context providers -const createMockUIState = (overrides: Partial = {}): UIState => +const createMockUIState = (overrides: UIStateOverrides = {}): UIState => ({ ctrlCPressedOnce: false, warningMessage: null, @@ -35,6 +41,8 @@ const createMockUIState = (overrides: Partial = {}): UIState => ideContextState: null, geminiMdFileCount: 0, contextFileNames: [], + buffer: { text: '' }, + history: [{ id: 1, type: 'user', text: 'test' }], ...overrides, }) as UIState; @@ -147,9 +155,22 @@ describe('StatusDisplay', () => { expect(lastFrame()).toMatchSnapshot(); }); - it('renders Escape prompt', () => { + it('renders Escape prompt when buffer is empty', () => { const uiState = createMockUIState({ showEscapePrompt: true, + buffer: { text: '' }, + }); + const { lastFrame } = renderStatusDisplay( + { hideContextSummary: false }, + uiState, + ); + expect(lastFrame()).toMatchSnapshot(); + }); + + it('renders Escape prompt when buffer is NOT empty', () => { + const uiState = createMockUIState({ + showEscapePrompt: true, + buffer: { text: 'some text' }, }); const { lastFrame } = renderStatusDisplay( { hideContextSummary: false }, diff --git a/packages/cli/src/ui/components/StatusDisplay.tsx b/packages/cli/src/ui/components/StatusDisplay.tsx index 2259d2c96f..45dcef10ba 100644 --- a/packages/cli/src/ui/components/StatusDisplay.tsx +++ b/packages/cli/src/ui/components/StatusDisplay.tsx @@ -45,7 +45,18 @@ export const StatusDisplay: React.FC = ({ } if (uiState.showEscapePrompt) { - return Press Esc again to rewind.; + const isPromptEmpty = uiState.buffer.text.length === 0; + const hasHistory = uiState.history.length > 0; + + if (isPromptEmpty && !hasHistory) { + return null; + } + + return ( + + Press Esc again to {isPromptEmpty ? 'rewind' : 'clear prompt'}. + + ); } if (uiState.queueErrorMessage) { diff --git a/packages/cli/src/ui/components/__snapshots__/StatusDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/StatusDisplay.test.tsx.snap index 521f642a9a..4f6c4f2231 100644 --- a/packages/cli/src/ui/components/__snapshots__/StatusDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/StatusDisplay.test.tsx.snap @@ -10,7 +10,9 @@ exports[`StatusDisplay > renders ContextSummaryDisplay by default 1`] = `"Mock C exports[`StatusDisplay > renders Ctrl+D prompt 1`] = `"Press Ctrl+D again to exit."`; -exports[`StatusDisplay > renders Escape prompt 1`] = `"Press Esc again to rewind."`; +exports[`StatusDisplay > renders Escape prompt when buffer is NOT empty 1`] = `"Press Esc again to clear prompt."`; + +exports[`StatusDisplay > renders Escape prompt when buffer is empty 1`] = `"Press Esc again to rewind."`; exports[`StatusDisplay > renders HookStatusDisplay when hooks are active 1`] = `"Mock Hook Status Display"`;