mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-11 14:40:52 -07:00
feat(cli): add quick clear input shortcuts in vim mode (#17470)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
This commit is contained in:
committed by
GitHub
parent
4827333c48
commit
b8319bee76
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user