feat(cli): add quick clear input shortcuts in vim mode (#17470)

Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
This commit is contained in:
Harsha Nadimpalli
2026-01-26 09:36:42 -08:00
committed by GitHub
parent 4827333c48
commit b8319bee76
2 changed files with 178 additions and 9 deletions

View File

@@ -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);
});
});
});

View File

@@ -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<number>(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,
],
);