feat(ui): add missing vim mode motions (X, ~, r, f/F/t/T, df/dt and friends) (#21932)

This commit is contained in:
Ali Anari
2026-03-10 20:27:06 -07:00
committed by GitHub
parent 5020d8fa57
commit 8b09ccc288
5 changed files with 1307 additions and 9 deletions

View File

@@ -4,7 +4,15 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import {
describe,
it,
expect,
vi,
beforeEach,
afterEach,
type Mock,
} from 'vitest';
import type React from 'react';
import { act } from 'react';
import { renderHook } from '../../test-utils/render.js';
@@ -166,6 +174,13 @@ describe('useVim hook', () => {
vimChangeBigWordBackward: vi.fn(),
vimChangeBigWordEnd: vi.fn(),
vimDeleteChar: vi.fn(),
vimDeleteCharBefore: vi.fn(),
vimToggleCase: vi.fn(),
vimReplaceChar: vi.fn(),
vimFindCharForward: vi.fn(),
vimFindCharBackward: vi.fn(),
vimDeleteToCharForward: vi.fn(),
vimDeleteToCharBackward: vi.fn(),
vimInsertAtCursor: vi.fn(),
vimAppendAtCursor: vi.fn().mockImplementation(() => {
// Append moves cursor right (vim 'a' behavior - position after current char)
@@ -1939,4 +1954,435 @@ describe('useVim hook', () => {
expect(handled!).toBe(false);
});
});
describe('Character deletion and case toggle (X, ~)', () => {
it('X: should call vimDeleteCharBefore', () => {
const { result } = renderVimHook();
exitInsertMode(result);
let handled: boolean;
act(() => {
handled = result.current.handleInput(createKey({ sequence: 'X' }));
});
expect(handled!).toBe(true);
expect(mockBuffer.vimDeleteCharBefore).toHaveBeenCalledWith(1);
});
it('~: should call vimToggleCase', () => {
const { result } = renderVimHook();
exitInsertMode(result);
let handled: boolean;
act(() => {
handled = result.current.handleInput(createKey({ sequence: '~' }));
});
expect(handled!).toBe(true);
expect(mockBuffer.vimToggleCase).toHaveBeenCalledWith(1);
});
it('X can be repeated with dot (.)', () => {
const { result } = renderVimHook();
exitInsertMode(result);
act(() => {
result.current.handleInput(createKey({ sequence: 'X' }));
});
expect(mockBuffer.vimDeleteCharBefore).toHaveBeenCalledTimes(1);
act(() => {
result.current.handleInput(createKey({ sequence: '.' }));
});
expect(mockBuffer.vimDeleteCharBefore).toHaveBeenCalledTimes(2);
});
it('~ can be repeated with dot (.)', () => {
const { result } = renderVimHook();
exitInsertMode(result);
act(() => {
result.current.handleInput(createKey({ sequence: '~' }));
});
expect(mockBuffer.vimToggleCase).toHaveBeenCalledTimes(1);
act(() => {
result.current.handleInput(createKey({ sequence: '.' }));
});
expect(mockBuffer.vimToggleCase).toHaveBeenCalledTimes(2);
});
it('3X calls vimDeleteCharBefore with count=3', () => {
const { result } = renderVimHook();
exitInsertMode(result);
act(() => {
result.current.handleInput(createKey({ sequence: '3' }));
});
act(() => {
result.current.handleInput(createKey({ sequence: 'X' }));
});
expect(mockBuffer.vimDeleteCharBefore).toHaveBeenCalledWith(3);
});
it('2~ calls vimToggleCase with count=2', () => {
const { result } = renderVimHook();
exitInsertMode(result);
act(() => {
result.current.handleInput(createKey({ sequence: '2' }));
});
act(() => {
result.current.handleInput(createKey({ sequence: '~' }));
});
expect(mockBuffer.vimToggleCase).toHaveBeenCalledWith(2);
});
});
describe('Replace character (r)', () => {
it('r{char}: should call vimReplaceChar with the next key', () => {
const { result } = renderVimHook();
exitInsertMode(result);
act(() => {
result.current.handleInput(createKey({ sequence: 'r' }));
});
act(() => {
result.current.handleInput(createKey({ sequence: 'x' }));
});
expect(mockBuffer.vimReplaceChar).toHaveBeenCalledWith('x', 1);
});
it('r: should consume the pending char without passing through', () => {
const { result } = renderVimHook();
exitInsertMode(result);
let rHandled: boolean;
let charHandled: boolean;
act(() => {
rHandled = result.current.handleInput(createKey({ sequence: 'r' }));
});
act(() => {
charHandled = result.current.handleInput(createKey({ sequence: 'a' }));
});
expect(rHandled!).toBe(true);
expect(charHandled!).toBe(true);
expect(mockBuffer.vimReplaceChar).toHaveBeenCalledWith('a', 1);
});
it('Escape cancels pending r (pendingFindOp cleared on Esc)', () => {
const { result } = renderVimHook();
exitInsertMode(result);
act(() => {
result.current.handleInput(createKey({ sequence: 'r' }));
});
act(() => {
result.current.handleInput(
createKey({ sequence: '\u001b', name: 'escape' }),
);
});
act(() => {
result.current.handleInput(createKey({ sequence: 'a' }));
});
expect(mockBuffer.vimReplaceChar).not.toHaveBeenCalled();
});
it('2rx calls vimReplaceChar with count=2', () => {
const { result } = renderVimHook();
exitInsertMode(result);
act(() => {
result.current.handleInput(createKey({ sequence: '2' }));
});
act(() => {
result.current.handleInput(createKey({ sequence: 'r' }));
});
act(() => {
result.current.handleInput(createKey({ sequence: 'x' }));
});
expect(mockBuffer.vimReplaceChar).toHaveBeenCalledWith('x', 2);
});
it('r{char} is dot-repeatable', () => {
const { result } = renderVimHook();
exitInsertMode(result);
act(() => {
result.current.handleInput(createKey({ sequence: 'r' }));
});
act(() => {
result.current.handleInput(createKey({ sequence: 'z' }));
});
expect(mockBuffer.vimReplaceChar).toHaveBeenCalledWith('z', 1);
act(() => {
result.current.handleInput(createKey({ sequence: '.' }));
});
expect(mockBuffer.vimReplaceChar).toHaveBeenCalledTimes(2);
expect(mockBuffer.vimReplaceChar).toHaveBeenLastCalledWith('z', 1);
});
});
describe('Character find motions (f, F, t, T, ;, ,)', () => {
type FindCase = {
key: string;
char: string;
mockFn: 'vimFindCharForward' | 'vimFindCharBackward';
till: boolean;
};
it.each<FindCase>([
{ key: 'f', char: 'o', mockFn: 'vimFindCharForward', till: false },
{ key: 'F', char: 'o', mockFn: 'vimFindCharBackward', till: false },
{ key: 't', char: 'w', mockFn: 'vimFindCharForward', till: true },
{ key: 'T', char: 'w', mockFn: 'vimFindCharBackward', till: true },
])(
'$key{char}: calls $mockFn (till=$till)',
({ key, char, mockFn, till }) => {
const { result } = renderVimHook();
exitInsertMode(result);
act(() => {
result.current.handleInput(createKey({ sequence: key }));
});
act(() => {
result.current.handleInput(createKey({ sequence: char }));
});
expect(mockBuffer[mockFn]).toHaveBeenCalledWith(char, 1, till);
},
);
it(';: should repeat last f forward find', () => {
const { result } = renderVimHook();
exitInsertMode(result);
// f o
act(() => {
result.current.handleInput(createKey({ sequence: 'f' }));
});
act(() => {
result.current.handleInput(createKey({ sequence: 'o' }));
});
// ;
act(() => {
result.current.handleInput(createKey({ sequence: ';' }));
});
expect(mockBuffer.vimFindCharForward).toHaveBeenCalledTimes(2);
expect(mockBuffer.vimFindCharForward).toHaveBeenLastCalledWith(
'o',
1,
false,
);
});
it(',: should repeat last f find in reverse direction', () => {
const { result } = renderVimHook();
exitInsertMode(result);
// f o
act(() => {
result.current.handleInput(createKey({ sequence: 'f' }));
});
act(() => {
result.current.handleInput(createKey({ sequence: 'o' }));
});
// ,
act(() => {
result.current.handleInput(createKey({ sequence: ',' }));
});
expect(mockBuffer.vimFindCharBackward).toHaveBeenCalledWith(
'o',
1,
false,
);
});
it('; and , should do nothing if no prior find', () => {
const { result } = renderVimHook();
exitInsertMode(result);
act(() => {
result.current.handleInput(createKey({ sequence: ';' }));
});
act(() => {
result.current.handleInput(createKey({ sequence: ',' }));
});
expect(mockBuffer.vimFindCharForward).not.toHaveBeenCalled();
expect(mockBuffer.vimFindCharBackward).not.toHaveBeenCalled();
});
it('Escape cancels pending f (pendingFindOp cleared on Esc)', () => {
const { result } = renderVimHook();
exitInsertMode(result);
act(() => {
result.current.handleInput(createKey({ sequence: 'f' }));
});
act(() => {
result.current.handleInput(
createKey({ sequence: '\u001b', name: 'escape' }),
);
});
// o should NOT be consumed as find target
act(() => {
result.current.handleInput(createKey({ sequence: 'o' }));
});
expect(mockBuffer.vimFindCharForward).not.toHaveBeenCalled();
});
it('2fo calls vimFindCharForward with count=2', () => {
const { result } = renderVimHook();
exitInsertMode(result);
act(() => {
result.current.handleInput(createKey({ sequence: '2' }));
});
act(() => {
result.current.handleInput(createKey({ sequence: 'f' }));
});
act(() => {
result.current.handleInput(createKey({ sequence: 'o' }));
});
expect(mockBuffer.vimFindCharForward).toHaveBeenCalledWith('o', 2, false);
});
});
describe('Operator + find motions (df, dt, dF, dT, cf, ct, cF, cT)', () => {
it('df{char}: executes delete-to-char, not a dangling operator', () => {
const { result } = renderVimHook();
exitInsertMode(result);
act(() => {
result.current.handleInput(createKey({ sequence: 'd' }));
});
act(() => {
result.current.handleInput(createKey({ sequence: 'f' }));
});
act(() => {
result.current.handleInput(createKey({ sequence: 'x' }));
});
expect(mockBuffer.vimDeleteToCharForward).toHaveBeenCalledWith(
'x',
1,
false,
);
expect(mockBuffer.vimFindCharForward).not.toHaveBeenCalled();
// Next key is a fresh normal-mode command — no dangling state
act(() => {
result.current.handleInput(createKey({ sequence: 'l' }));
});
expect(mockBuffer.vimMoveRight).toHaveBeenCalledWith(1);
});
// operator + find/till motions (df, dt, dF, dT, cf, ct, ...)
type OperatorFindCase = {
operator: string;
findKey: string;
mockFn: 'vimDeleteToCharForward' | 'vimDeleteToCharBackward';
till: boolean;
entersInsert: boolean;
};
it.each<OperatorFindCase>([
{
operator: 'd',
findKey: 'f',
mockFn: 'vimDeleteToCharForward',
till: false,
entersInsert: false,
},
{
operator: 'd',
findKey: 't',
mockFn: 'vimDeleteToCharForward',
till: true,
entersInsert: false,
},
{
operator: 'd',
findKey: 'F',
mockFn: 'vimDeleteToCharBackward',
till: false,
entersInsert: false,
},
{
operator: 'd',
findKey: 'T',
mockFn: 'vimDeleteToCharBackward',
till: true,
entersInsert: false,
},
{
operator: 'c',
findKey: 'f',
mockFn: 'vimDeleteToCharForward',
till: false,
entersInsert: true,
},
{
operator: 'c',
findKey: 't',
mockFn: 'vimDeleteToCharForward',
till: true,
entersInsert: true,
},
{
operator: 'c',
findKey: 'F',
mockFn: 'vimDeleteToCharBackward',
till: false,
entersInsert: true,
},
{
operator: 'c',
findKey: 'T',
mockFn: 'vimDeleteToCharBackward',
till: true,
entersInsert: true,
},
])(
'$operator$findKey{char}: calls $mockFn (till=$till, insert=$entersInsert)',
({ operator, findKey, mockFn, till, entersInsert }) => {
const { result } = renderVimHook();
exitInsertMode(result);
act(() => {
result.current.handleInput(createKey({ sequence: operator }));
});
act(() => {
result.current.handleInput(createKey({ sequence: findKey }));
});
act(() => {
result.current.handleInput(createKey({ sequence: 'o' }));
});
expect(mockBuffer[mockFn]).toHaveBeenCalledWith('o', 1, till);
if (entersInsert) {
expect(mockVimContext.setVimMode).toHaveBeenCalledWith('INSERT');
}
},
);
it('2df{char}: count is passed through to vimDeleteToCharForward', () => {
const { result } = renderVimHook();
exitInsertMode(result);
act(() => {
result.current.handleInput(createKey({ sequence: '2' }));
});
act(() => {
result.current.handleInput(createKey({ sequence: 'd' }));
});
act(() => {
result.current.handleInput(createKey({ sequence: 'f' }));
});
act(() => {
result.current.handleInput(createKey({ sequence: 'o' }));
});
expect(mockBuffer.vimDeleteToCharForward).toHaveBeenCalledWith(
'o',
2,
false,
);
});
});
});

View File

@@ -11,6 +11,7 @@ import { useVimMode } from '../contexts/VimModeContext.js';
import { debugLogger } from '@google/gemini-cli-core';
import { Command } from '../key/keyMatchers.js';
import { useKeyMatchers } from './useKeyMatchers.js';
import { toCodePoints } from '../utils/textUtils.js';
export type VimMode = 'NORMAL' | 'INSERT';
@@ -35,6 +36,9 @@ const CMD_TYPES = {
CHANGE_BIG_WORD_BACKWARD: 'cB',
CHANGE_BIG_WORD_END: 'cE',
DELETE_CHAR: 'x',
DELETE_CHAR_BEFORE: 'X',
TOGGLE_CASE: '~',
REPLACE_CHAR: 'r',
DELETE_LINE: 'dd',
CHANGE_LINE: 'cc',
DELETE_TO_EOL: 'D',
@@ -61,18 +65,25 @@ const CMD_TYPES = {
CHANGE_TO_LAST_LINE: 'cG',
} as const;
// Helper function to clear pending state
type PendingFindOp = {
op: 'f' | 'F' | 't' | 'T' | 'r';
operator: 'd' | 'c' | undefined;
count: number; // captured at keypress time, before CLEAR_PENDING_STATES resets it
};
const createClearPendingState = () => ({
count: 0,
pendingOperator: null as 'g' | 'd' | 'c' | 'dg' | 'cg' | null,
pendingFindOp: undefined as PendingFindOp | undefined,
});
// State and action types for useReducer
type VimState = {
mode: VimMode;
count: number;
pendingOperator: 'g' | 'd' | 'c' | 'dg' | 'cg' | null;
lastCommand: { type: string; count: number } | null;
pendingFindOp: PendingFindOp | undefined;
lastCommand: { type: string; count: number; char?: string } | null;
lastFind: { op: 'f' | 'F' | 't' | 'T'; char: string } | undefined;
};
type VimAction =
@@ -84,9 +95,14 @@ type VimAction =
type: 'SET_PENDING_OPERATOR';
operator: 'g' | 'd' | 'c' | 'dg' | 'cg' | null;
}
| { type: 'SET_PENDING_FIND_OP'; pendingFindOp: PendingFindOp | undefined }
| {
type: 'SET_LAST_FIND';
find: { op: 'f' | 'F' | 't' | 'T'; char: string } | undefined;
}
| {
type: 'SET_LAST_COMMAND';
command: { type: string; count: number } | null;
command: { type: string; count: number; char?: string } | null;
}
| { type: 'CLEAR_PENDING_STATES' }
| { type: 'ESCAPE_TO_NORMAL' };
@@ -95,7 +111,9 @@ const initialVimState: VimState = {
mode: 'INSERT',
count: 0,
pendingOperator: null,
pendingFindOp: undefined,
lastCommand: null,
lastFind: undefined,
};
// Reducer function
@@ -116,6 +134,12 @@ const vimReducer = (state: VimState, action: VimAction): VimState => {
case 'SET_PENDING_OPERATOR':
return { ...state, pendingOperator: action.operator };
case 'SET_PENDING_FIND_OP':
return { ...state, pendingFindOp: action.pendingFindOp };
case 'SET_LAST_FIND':
return { ...state, lastFind: action.find };
case 'SET_LAST_COMMAND':
return { ...state, lastCommand: action.command };
@@ -195,7 +219,7 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
/** Executes common commands to eliminate duplication in dot (.) repeat command */
const executeCommand = useCallback(
(cmdType: string, count: number) => {
(cmdType: string, count: number, char?: string) => {
switch (cmdType) {
case CMD_TYPES.DELETE_WORD_FORWARD: {
buffer.vimDeleteWordForward(count);
@@ -268,6 +292,21 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
break;
}
case CMD_TYPES.DELETE_CHAR_BEFORE: {
buffer.vimDeleteCharBefore(count);
break;
}
case CMD_TYPES.TOGGLE_CASE: {
buffer.vimToggleCase(count);
break;
}
case CMD_TYPES.REPLACE_CHAR: {
if (char) buffer.vimReplaceChar(char, count);
break;
}
case CMD_TYPES.DELETE_LINE: {
buffer.vimDeleteLine(count);
break;
@@ -597,7 +636,7 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
// Handle NORMAL mode
if (state.mode === 'NORMAL') {
if (keyMatchers[Command.ESCAPE](normalizedKey)) {
if (state.pendingOperator) {
if (state.pendingOperator || state.pendingFindOp) {
dispatch({ type: 'CLEAR_PENDING_STATES' });
lastEscapeTimestampRef.current = 0;
return true; // Handled by vim
@@ -627,6 +666,47 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
const repeatCount = getCurrentCount();
// Handle pending find/till/replace — consume the next char as the target
if (state.pendingFindOp !== undefined) {
const targetChar = normalizedKey.sequence;
const { op, operator, count: findCount } = state.pendingFindOp;
dispatch({ type: 'SET_PENDING_FIND_OP', pendingFindOp: undefined });
dispatch({ type: 'CLEAR_COUNT' });
if (targetChar && toCodePoints(targetChar).length === 1) {
if (op === 'r') {
buffer.vimReplaceChar(targetChar, findCount);
dispatch({
type: 'SET_LAST_COMMAND',
command: {
type: CMD_TYPES.REPLACE_CHAR,
count: findCount,
char: targetChar,
},
});
} else {
const isBackward = op === 'F' || op === 'T';
const isTill = op === 't' || op === 'T';
if (operator === 'd' || operator === 'c') {
const del = isBackward
? buffer.vimDeleteToCharBackward
: buffer.vimDeleteToCharForward;
del(targetChar, findCount, isTill);
if (operator === 'c') updateMode('INSERT');
} else {
const find = isBackward
? buffer.vimFindCharBackward
: buffer.vimFindCharForward;
find(targetChar, findCount, isTill);
dispatch({
type: 'SET_LAST_FIND',
find: { op, char: targetChar },
});
}
}
}
return true;
}
switch (normalizedKey.sequence) {
case 'h': {
// Check if this is part of a delete or change command (dh/ch)
@@ -789,8 +869,79 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
return true;
}
case 'X': {
buffer.vimDeleteCharBefore(repeatCount);
dispatch({
type: 'SET_LAST_COMMAND',
command: {
type: CMD_TYPES.DELETE_CHAR_BEFORE,
count: repeatCount,
},
});
dispatch({ type: 'CLEAR_COUNT' });
return true;
}
case '~': {
buffer.vimToggleCase(repeatCount);
dispatch({
type: 'SET_LAST_COMMAND',
command: { type: CMD_TYPES.TOGGLE_CASE, count: repeatCount },
});
dispatch({ type: 'CLEAR_COUNT' });
return true;
}
case 'r': {
// Replace char: next keypress is the replacement. Not composable with d/c.
dispatch({ type: 'CLEAR_PENDING_STATES' });
dispatch({
type: 'SET_PENDING_FIND_OP',
pendingFindOp: {
op: 'r',
operator: undefined,
count: repeatCount,
},
});
return true;
}
case 'f':
case 'F':
case 't':
case 'T': {
const op = normalizedKey.sequence;
const operator =
state.pendingOperator === 'd' || state.pendingOperator === 'c'
? state.pendingOperator
: undefined;
dispatch({ type: 'CLEAR_PENDING_STATES' });
dispatch({
type: 'SET_PENDING_FIND_OP',
pendingFindOp: { op, operator, count: repeatCount },
});
return true;
}
case ';':
case ',': {
if (state.lastFind) {
const { op, char } = state.lastFind;
const isForward = op === 'f' || op === 't';
const isTill = op === 't' || op === 'T';
const reverse = normalizedKey.sequence === ',';
const shouldMoveForward = reverse ? !isForward : isForward;
if (shouldMoveForward) {
buffer.vimFindCharForward(char, repeatCount, isTill);
} else {
buffer.vimFindCharBackward(char, repeatCount, isTill);
}
}
dispatch({ type: 'CLEAR_COUNT' });
return true;
}
case 'i': {
// Enter INSERT mode at current position
buffer.vimInsertAtCursor();
updateMode('INSERT');
dispatch({ type: 'CLEAR_COUNT' });
@@ -1107,7 +1258,7 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
const count = state.count > 0 ? state.count : cmdData.count;
// All repeatable commands are now handled by executeCommand
executeCommand(cmdData.type, count);
executeCommand(cmdData.type, count, cmdData.char);
}
dispatch({ type: 'CLEAR_COUNT' });
@@ -1194,7 +1345,9 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
state.mode,
state.count,
state.pendingOperator,
state.pendingFindOp,
state.lastCommand,
state.lastFind,
dispatch,
getCurrentCount,
handleChangeMovement,