From b8319bee76afea3361f9c5de70047bde035ee7ba Mon Sep 17 00:00:00 2001 From: Harsha Nadimpalli Date: Mon, 26 Jan 2026 09:36:42 -0800 Subject: [PATCH] feat(cli): add quick clear input shortcuts in vim mode (#17470) Co-authored-by: Tommaso Sciortino --- packages/cli/src/ui/hooks/vim.test.tsx | 138 +++++++++++++++++++++++++ packages/cli/src/ui/hooks/vim.ts | 49 +++++++-- 2 files changed, 178 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/ui/hooks/vim.test.tsx b/packages/cli/src/ui/hooks/vim.test.tsx index 372f5f03e4..bc0d15d9d1 100644 --- a/packages/cli/src/ui/hooks/vim.test.tsx +++ b/packages/cli/src/ui/hooks/vim.test.tsx @@ -89,6 +89,7 @@ const TEST_SEQUENCES = { LINE_START: createKey({ sequence: '0' }), LINE_END: createKey({ sequence: '$' }), REPEAT: createKey({ sequence: '.' }), + CTRL_C: createKey({ sequence: '\x03', name: 'c', ctrl: true }), } as const; describe('useVim hook', () => { @@ -1614,4 +1615,141 @@ describe('useVim hook', () => { }, ); }); + + describe('double-escape to clear buffer', () => { + beforeEach(() => { + mockBuffer = createMockBuffer('hello world'); + mockVimContext.vimEnabled = true; + mockVimContext.vimMode = 'NORMAL'; + mockHandleFinalSubmit = vi.fn(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('should clear buffer on double-escape in NORMAL mode', async () => { + const { result } = renderHook(() => + useVim(mockBuffer as TextBuffer, mockHandleFinalSubmit), + ); + + // First escape - should pass through (return false) + let handled: boolean; + await act(async () => { + handled = result.current.handleInput(TEST_SEQUENCES.ESCAPE); + }); + expect(handled!).toBe(false); + + // Second escape within timeout - should clear buffer (return true) + await act(async () => { + handled = result.current.handleInput(TEST_SEQUENCES.ESCAPE); + }); + expect(handled!).toBe(true); + expect(mockBuffer.setText).toHaveBeenCalledWith(''); + }); + + it('should clear buffer on double-escape in INSERT mode', async () => { + mockVimContext.vimMode = 'INSERT'; + const { result } = renderHook(() => + useVim(mockBuffer as TextBuffer, mockHandleFinalSubmit), + ); + + // First escape - switches to NORMAL mode + let handled: boolean; + await act(async () => { + handled = result.current.handleInput(TEST_SEQUENCES.ESCAPE); + }); + expect(handled!).toBe(true); + expect(mockBuffer.vimEscapeInsertMode).toHaveBeenCalled(); + + // Second escape within timeout - should clear buffer + await act(async () => { + handled = result.current.handleInput(TEST_SEQUENCES.ESCAPE); + }); + expect(handled!).toBe(true); + expect(mockBuffer.setText).toHaveBeenCalledWith(''); + }); + + it('should NOT clear buffer if escapes are too slow', async () => { + const { result } = renderHook(() => + useVim(mockBuffer as TextBuffer, mockHandleFinalSubmit), + ); + + // First escape + await act(async () => { + result.current.handleInput(TEST_SEQUENCES.ESCAPE); + }); + + // Wait longer than timeout (500ms) + await act(async () => { + vi.advanceTimersByTime(600); + }); + + // Second escape - should NOT clear buffer because timeout expired + let handled: boolean; + await act(async () => { + handled = result.current.handleInput(TEST_SEQUENCES.ESCAPE); + }); + // First escape of new sequence, passes through + expect(handled!).toBe(false); + expect(mockBuffer.setText).not.toHaveBeenCalled(); + }); + + it('should clear escape history when clearing pending operator', async () => { + const { result } = renderHook(() => + useVim(mockBuffer as TextBuffer, mockHandleFinalSubmit), + ); + + // First escape + await act(async () => { + result.current.handleInput(TEST_SEQUENCES.ESCAPE); + }); + + // Type 'd' to set pending operator + await act(async () => { + result.current.handleInput(TEST_SEQUENCES.DELETE); + }); + + // Escape to clear pending operator + await act(async () => { + result.current.handleInput(TEST_SEQUENCES.ESCAPE); + }); + + // Another escape - should NOT clear buffer (history was reset) + let handled: boolean; + await act(async () => { + handled = result.current.handleInput(TEST_SEQUENCES.ESCAPE); + }); + expect(handled!).toBe(false); + expect(mockBuffer.setText).not.toHaveBeenCalled(); + }); + + it('should pass Ctrl+C through to InputPrompt in NORMAL mode', async () => { + const { result } = renderHook(() => + useVim(mockBuffer as TextBuffer, mockHandleFinalSubmit), + ); + + let handled: boolean; + await act(async () => { + handled = result.current.handleInput(TEST_SEQUENCES.CTRL_C); + }); + // Should return false to let InputPrompt handle it + expect(handled!).toBe(false); + }); + + it('should pass Ctrl+C through to InputPrompt in INSERT mode', async () => { + mockVimContext.vimMode = 'INSERT'; + const { result } = renderHook(() => + useVim(mockBuffer as TextBuffer, mockHandleFinalSubmit), + ); + + let handled: boolean; + await act(async () => { + handled = result.current.handleInput(TEST_SEQUENCES.CTRL_C); + }); + // Should return false to let InputPrompt handle it + expect(handled!).toBe(false); + }); + }); }); diff --git a/packages/cli/src/ui/hooks/vim.ts b/packages/cli/src/ui/hooks/vim.ts index 2f39c38f43..2762c54de7 100644 --- a/packages/cli/src/ui/hooks/vim.ts +++ b/packages/cli/src/ui/hooks/vim.ts @@ -4,11 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { useCallback, useReducer, useEffect } from 'react'; +import { useCallback, useReducer, useEffect, useRef } from 'react'; import type { Key } from './useKeypress.js'; import type { TextBuffer } from '../components/shared/text-buffer.js'; import { useVimMode } from '../contexts/VimModeContext.js'; import { debugLogger } from '@google/gemini-cli-core'; +import { keyMatchers, Command } from '../keyMatchers.js'; export type VimMode = 'NORMAL' | 'INSERT'; @@ -16,6 +17,7 @@ export type VimMode = 'NORMAL' | 'INSERT'; const DIGIT_MULTIPLIER = 10; const DEFAULT_COUNT = 1; const DIGIT_1_TO_9 = /^[1-9]$/; +const DOUBLE_ESCAPE_TIMEOUT_MS = 500; // Timeout for double-escape to clear input // Command types const CMD_TYPES = { @@ -130,6 +132,9 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { const { vimEnabled, vimMode, setVimMode } = useVimMode(); const [state, dispatch] = useReducer(vimReducer, initialVimState); + // Track last escape timestamp for double-escape detection + const lastEscapeTimestampRef = useRef(0); + // Sync vim mode from context to local state useEffect(() => { dispatch({ type: 'SET_MODE', mode: vimMode }); @@ -150,6 +155,19 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { [state.count], ); + // Returns true if two escapes occurred within DOUBLE_ESCAPE_TIMEOUT_MS. + const checkDoubleEscape = useCallback((): boolean => { + const now = Date.now(); + const lastEscape = lastEscapeTimestampRef.current; + lastEscapeTimestampRef.current = now; + + if (now - lastEscape <= DOUBLE_ESCAPE_TIMEOUT_MS) { + lastEscapeTimestampRef.current = 0; + return true; + } + return false; + }, []); + /** Executes common commands to eliminate duplication in dot (.) repeat command */ const executeCommand = useCallback( (cmdType: string, count: number) => { @@ -247,9 +265,9 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { */ const handleInsertModeInput = useCallback( (normalizedKey: Key): boolean => { - // Handle escape key immediately - switch to NORMAL mode on any escape - if (normalizedKey.name === 'escape') { - // Vim behavior: move cursor left when exiting insert mode (unless at beginning of line) + if (keyMatchers[Command.ESCAPE](normalizedKey)) { + // Record for double-escape detection (clearing happens in NORMAL mode) + checkDoubleEscape(); buffer.vimEscapeInsertMode(); dispatch({ type: 'ESCAPE_TO_NORMAL' }); updateMode('NORMAL'); @@ -298,7 +316,7 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { buffer.handleInput(normalizedKey); return true; // Handled by vim }, - [buffer, dispatch, updateMode, onSubmit], + [buffer, dispatch, updateMode, onSubmit, checkDoubleEscape], ); /** @@ -401,6 +419,11 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { return false; } + // Let InputPrompt handle Ctrl+C for clearing input (works in all modes) + if (keyMatchers[Command.CLEAR_INPUT](normalizedKey)) { + return false; + } + // Handle INSERT mode if (state.mode === 'INSERT') { return handleInsertModeInput(normalizedKey); @@ -408,14 +431,21 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { // Handle NORMAL mode if (state.mode === 'NORMAL') { - // If in NORMAL mode, allow escape to pass through to other handlers - // if there's no pending operation. - if (normalizedKey.name === 'escape') { + if (keyMatchers[Command.ESCAPE](normalizedKey)) { if (state.pendingOperator) { dispatch({ type: 'CLEAR_PENDING_STATES' }); + lastEscapeTimestampRef.current = 0; return true; // Handled by vim } - return false; // Pass through to other handlers + + // Check for double-escape to clear buffer + if (checkDoubleEscape()) { + buffer.setText(''); + return true; + } + + // First escape in NORMAL mode - pass through for UI feedback + return false; } // Handle count input (numbers 1-9, and 0 if count > 0) @@ -776,6 +806,7 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { buffer, executeCommand, updateMode, + checkDoubleEscape, ], );