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

@@ -1703,6 +1703,25 @@ export type TextBufferAction =
| { type: 'vim_change_to_first_nonwhitespace' }
| { type: 'vim_delete_to_first_line'; payload: { count: number } }
| { type: 'vim_delete_to_last_line'; payload: { count: number } }
| { type: 'vim_delete_char_before'; payload: { count: number } }
| { type: 'vim_toggle_case'; payload: { count: number } }
| { type: 'vim_replace_char'; payload: { char: string; count: number } }
| {
type: 'vim_find_char_forward';
payload: { char: string; count: number; till: boolean };
}
| {
type: 'vim_find_char_backward';
payload: { char: string; count: number; till: boolean };
}
| {
type: 'vim_delete_to_char_forward';
payload: { char: string; count: number; till: boolean };
}
| {
type: 'vim_delete_to_char_backward';
payload: { char: string; count: number; till: boolean };
}
| {
type: 'toggle_paste_expansion';
payload: { id: string; row: number; col: number };
@@ -2484,6 +2503,13 @@ function textBufferReducerLogic(
case 'vim_change_to_first_nonwhitespace':
case 'vim_delete_to_first_line':
case 'vim_delete_to_last_line':
case 'vim_delete_char_before':
case 'vim_toggle_case':
case 'vim_replace_char':
case 'vim_find_char_forward':
case 'vim_find_char_backward':
case 'vim_delete_to_char_forward':
case 'vim_delete_to_char_backward':
return handleVimAction(state, action as VimAction);
case 'toggle_paste_expansion': {
@@ -3043,6 +3069,58 @@ export function useTextBuffer({
dispatch({ type: 'vim_delete_char', payload: { count } });
}, []);
const vimDeleteCharBefore = useCallback((count: number): void => {
dispatch({ type: 'vim_delete_char_before', payload: { count } });
}, []);
const vimToggleCase = useCallback((count: number): void => {
dispatch({ type: 'vim_toggle_case', payload: { count } });
}, []);
const vimReplaceChar = useCallback((char: string, count: number): void => {
dispatch({ type: 'vim_replace_char', payload: { char, count } });
}, []);
const vimFindCharForward = useCallback(
(char: string, count: number, till: boolean): void => {
dispatch({
type: 'vim_find_char_forward',
payload: { char, count, till },
});
},
[],
);
const vimFindCharBackward = useCallback(
(char: string, count: number, till: boolean): void => {
dispatch({
type: 'vim_find_char_backward',
payload: { char, count, till },
});
},
[],
);
const vimDeleteToCharForward = useCallback(
(char: string, count: number, till: boolean): void => {
dispatch({
type: 'vim_delete_to_char_forward',
payload: { char, count, till },
});
},
[],
);
const vimDeleteToCharBackward = useCallback(
(char: string, count: number, till: boolean): void => {
dispatch({
type: 'vim_delete_to_char_backward',
payload: { char, count, till },
});
},
[],
);
const vimInsertAtCursor = useCallback((): void => {
dispatch({ type: 'vim_insert_at_cursor' });
}, []);
@@ -3542,6 +3620,13 @@ export function useTextBuffer({
vimMoveBigWordBackward,
vimMoveBigWordEnd,
vimDeleteChar,
vimDeleteCharBefore,
vimToggleCase,
vimReplaceChar,
vimFindCharForward,
vimFindCharBackward,
vimDeleteToCharForward,
vimDeleteToCharBackward,
vimInsertAtCursor,
vimAppendAtCursor,
vimOpenLineBelow,
@@ -3630,6 +3715,13 @@ export function useTextBuffer({
vimMoveBigWordBackward,
vimMoveBigWordEnd,
vimDeleteChar,
vimDeleteCharBefore,
vimToggleCase,
vimReplaceChar,
vimFindCharForward,
vimFindCharBackward,
vimDeleteToCharForward,
vimDeleteToCharBackward,
vimInsertAtCursor,
vimAppendAtCursor,
vimOpenLineBelow,
@@ -3937,6 +4029,20 @@ export interface TextBuffer {
* Delete N characters at cursor (vim 'x' command)
*/
vimDeleteChar: (count: number) => void;
/** Delete N characters before cursor (vim 'X') */
vimDeleteCharBefore: (count: number) => void;
/** Toggle case of N characters at cursor (vim '~') */
vimToggleCase: (count: number) => void;
/** Replace N characters at cursor with char, stay in NORMAL mode (vim 'r') */
vimReplaceChar: (char: string, count: number) => void;
/** Move to Nth occurrence of char forward on line; till=true stops before it (vim 'f'/'t') */
vimFindCharForward: (char: string, count: number, till: boolean) => void;
/** Move to Nth occurrence of char backward on line; till=true stops after it (vim 'F'/'T') */
vimFindCharBackward: (char: string, count: number, till: boolean) => void;
/** Delete from cursor to Nth occurrence of char forward; till=true excludes the char (vim 'df'/'dt') */
vimDeleteToCharForward: (char: string, count: number, till: boolean) => void;
/** Delete from Nth occurrence of char backward to cursor; till=true excludes the char (vim 'dF'/'dT') */
vimDeleteToCharBackward: (char: string, count: number, till: boolean) => void;
/**
* Enter insert mode at cursor (vim 'i' command)
*/

View File

@@ -1727,4 +1727,416 @@ describe('vim-buffer-actions', () => {
});
});
});
describe('Character manipulation commands (X, ~, r, f/F/t/T)', () => {
describe('vim_delete_char_before (X)', () => {
it('should delete the character before the cursor', () => {
const state = createTestState(['hello'], 0, 3);
const result = handleVimAction(state, {
type: 'vim_delete_char_before' as const,
payload: { count: 1 },
});
expect(result.lines[0]).toBe('helo');
expect(result.cursorCol).toBe(2);
});
it('should delete N characters before the cursor', () => {
const state = createTestState(['hello world'], 0, 5);
const result = handleVimAction(state, {
type: 'vim_delete_char_before' as const,
payload: { count: 3 },
});
expect(result.lines[0]).toBe('he world');
expect(result.cursorCol).toBe(2);
});
it('should clamp to start of line when count exceeds position', () => {
const state = createTestState(['hello'], 0, 2);
const result = handleVimAction(state, {
type: 'vim_delete_char_before' as const,
payload: { count: 10 },
});
expect(result.lines[0]).toBe('llo');
expect(result.cursorCol).toBe(0);
});
it('should do nothing when cursor is at column 0', () => {
const state = createTestState(['hello'], 0, 0);
const result = handleVimAction(state, {
type: 'vim_delete_char_before' as const,
payload: { count: 1 },
});
expect(result.lines[0]).toBe('hello');
expect(result.cursorCol).toBe(0);
});
it('should push undo state', () => {
const state = createTestState(['hello'], 0, 3);
const result = handleVimAction(state, {
type: 'vim_delete_char_before' as const,
payload: { count: 1 },
});
expect(result.undoStack.length).toBeGreaterThan(0);
});
});
describe('vim_toggle_case (~)', () => {
it('should toggle lowercase to uppercase', () => {
const state = createTestState(['hello'], 0, 0);
const result = handleVimAction(state, {
type: 'vim_toggle_case' as const,
payload: { count: 1 },
});
expect(result.lines[0]).toBe('Hello');
expect(result.cursorCol).toBe(1);
});
it('should toggle uppercase to lowercase', () => {
const state = createTestState(['HELLO'], 0, 0);
const result = handleVimAction(state, {
type: 'vim_toggle_case' as const,
payload: { count: 1 },
});
expect(result.lines[0]).toBe('hELLO');
expect(result.cursorCol).toBe(1);
});
it('should toggle N characters', () => {
const state = createTestState(['hello world'], 0, 0);
const result = handleVimAction(state, {
type: 'vim_toggle_case' as const,
payload: { count: 5 },
});
expect(result.lines[0]).toBe('HELLO world');
expect(result.cursorCol).toBe(5); // cursor advances past the toggled range
});
it('should clamp count to end of line', () => {
const state = createTestState(['hi'], 0, 1);
const result = handleVimAction(state, {
type: 'vim_toggle_case' as const,
payload: { count: 100 },
});
expect(result.lines[0]).toBe('hI');
expect(result.cursorCol).toBe(1);
});
it('should do nothing when cursor is past end of line', () => {
const state = createTestState(['hi'], 0, 5);
const result = handleVimAction(state, {
type: 'vim_toggle_case' as const,
payload: { count: 1 },
});
expect(result.lines[0]).toBe('hi');
});
it('should push undo state', () => {
const state = createTestState(['hello'], 0, 0);
const result = handleVimAction(state, {
type: 'vim_toggle_case' as const,
payload: { count: 1 },
});
expect(result.undoStack.length).toBeGreaterThan(0);
});
});
describe('vim_replace_char (r)', () => {
it('should replace the character under the cursor', () => {
const state = createTestState(['hello'], 0, 1);
const result = handleVimAction(state, {
type: 'vim_replace_char' as const,
payload: { char: 'a', count: 1 },
});
expect(result.lines[0]).toBe('hallo');
expect(result.cursorCol).toBe(1);
});
it('should replace N characters with the given char', () => {
const state = createTestState(['hello'], 0, 1);
const result = handleVimAction(state, {
type: 'vim_replace_char' as const,
payload: { char: 'x', count: 3 },
});
expect(result.lines[0]).toBe('hxxxo');
expect(result.cursorCol).toBe(3); // cursor at last replaced char
});
it('should clamp replace count to end of line', () => {
const state = createTestState(['hi'], 0, 1);
const result = handleVimAction(state, {
type: 'vim_replace_char' as const,
payload: { char: 'z', count: 100 },
});
expect(result.lines[0]).toBe('hz');
expect(result.cursorCol).toBe(1);
});
it('should do nothing when cursor is past end of line', () => {
const state = createTestState(['hi'], 0, 5);
const result = handleVimAction(state, {
type: 'vim_replace_char' as const,
payload: { char: 'z', count: 1 },
});
expect(result.lines[0]).toBe('hi');
});
it('should push undo state', () => {
const state = createTestState(['hello'], 0, 0);
const result = handleVimAction(state, {
type: 'vim_replace_char' as const,
payload: { char: 'x', count: 1 },
});
expect(result.undoStack.length).toBeGreaterThan(0);
});
});
type FindActionCase = {
label: string;
type: 'vim_find_char_forward' | 'vim_find_char_backward';
cursorStart: number;
char: string;
count: number;
till: boolean;
expectedCol: number;
};
it.each<FindActionCase>([
{
label: 'f: move to char',
type: 'vim_find_char_forward',
cursorStart: 0,
char: 'o',
count: 1,
till: false,
expectedCol: 4,
},
{
label: 'f: Nth occurrence',
type: 'vim_find_char_forward',
cursorStart: 0,
char: 'o',
count: 2,
till: false,
expectedCol: 7,
},
{
label: 't: move before char',
type: 'vim_find_char_forward',
cursorStart: 0,
char: 'o',
count: 1,
till: true,
expectedCol: 3,
},
{
label: 'f: not found',
type: 'vim_find_char_forward',
cursorStart: 0,
char: 'z',
count: 1,
till: false,
expectedCol: 0,
},
{
label: 'f: skip char at cursor',
type: 'vim_find_char_forward',
cursorStart: 1,
char: 'h',
count: 1,
till: false,
expectedCol: 1,
},
{
label: 'F: move to char',
type: 'vim_find_char_backward',
cursorStart: 10,
char: 'o',
count: 1,
till: false,
expectedCol: 7,
},
{
label: 'F: Nth occurrence',
type: 'vim_find_char_backward',
cursorStart: 10,
char: 'o',
count: 2,
till: false,
expectedCol: 4,
},
{
label: 'T: move after char',
type: 'vim_find_char_backward',
cursorStart: 10,
char: 'o',
count: 1,
till: true,
expectedCol: 8,
},
{
label: 'F: not found',
type: 'vim_find_char_backward',
cursorStart: 4,
char: 'z',
count: 1,
till: false,
expectedCol: 4,
},
{
label: 'F: skip char at cursor',
type: 'vim_find_char_backward',
cursorStart: 3,
char: 'o',
count: 1,
till: false,
expectedCol: 3,
},
])('$label', ({ type, cursorStart, char, count, till, expectedCol }) => {
const line =
type === 'vim_find_char_forward' ? ['hello world'] : ['hello world'];
const state = createTestState(line, 0, cursorStart);
const result = handleVimAction(state, {
type,
payload: { char, count, till },
});
expect(result.cursorCol).toBe(expectedCol);
});
});
describe('Unicode character support in find operations', () => {
it('vim_find_char_forward: finds multi-byte char (é) correctly', () => {
const state = createTestState(['café world'], 0, 0);
const result = handleVimAction(state, {
type: 'vim_find_char_forward' as const,
payload: { char: 'é', count: 1, till: false },
});
expect(result.cursorCol).toBe(3); // 'c','a','f','é' — é is at index 3
expect(result.lines[0]).toBe('café world');
});
it('vim_find_char_backward: finds multi-byte char (é) correctly', () => {
const state = createTestState(['café world'], 0, 9);
const result = handleVimAction(state, {
type: 'vim_find_char_backward' as const,
payload: { char: 'é', count: 1, till: false },
});
expect(result.cursorCol).toBe(3);
});
it('vim_delete_to_char_forward: handles multi-byte target char', () => {
const state = createTestState(['café world'], 0, 0);
const result = handleVimAction(state, {
type: 'vim_delete_to_char_forward' as const,
payload: { char: 'é', count: 1, till: false },
});
// Deletes 'caf' + 'é' → ' world' remains
expect(result.lines[0]).toBe(' world');
expect(result.cursorCol).toBe(0);
});
it('vim_delete_to_char_forward (till): stops before multi-byte char', () => {
const state = createTestState(['café world'], 0, 0);
const result = handleVimAction(state, {
type: 'vim_delete_to_char_forward' as const,
payload: { char: 'é', count: 1, till: true },
});
// Deletes 'caf', keeps 'é world'
expect(result.lines[0]).toBe('é world');
expect(result.cursorCol).toBe(0);
});
});
describe('vim_delete_to_char_forward (df/dt)', () => {
it('df: deletes from cursor through found char (inclusive)', () => {
const state = createTestState(['hello world'], 0, 0);
const result = handleVimAction(state, {
type: 'vim_delete_to_char_forward' as const,
payload: { char: 'o', count: 1, till: false },
});
expect(result.lines[0]).toBe(' world');
expect(result.cursorCol).toBe(0);
});
it('dt: deletes from cursor up to (not including) found char', () => {
const state = createTestState(['hello world'], 0, 0);
const result = handleVimAction(state, {
type: 'vim_delete_to_char_forward' as const,
payload: { char: 'o', count: 1, till: true },
});
expect(result.lines[0]).toBe('o world');
expect(result.cursorCol).toBe(0);
});
it('df with count: deletes to Nth occurrence', () => {
const state = createTestState(['hello world'], 0, 0);
const result = handleVimAction(state, {
type: 'vim_delete_to_char_forward' as const,
payload: { char: 'o', count: 2, till: false },
});
expect(result.lines[0]).toBe('rld');
expect(result.cursorCol).toBe(0);
});
it('does nothing if char not found', () => {
const state = createTestState(['hello'], 0, 0);
const result = handleVimAction(state, {
type: 'vim_delete_to_char_forward' as const,
payload: { char: 'z', count: 1, till: false },
});
expect(result.lines[0]).toBe('hello');
expect(result.cursorCol).toBe(0);
});
it('pushes undo state', () => {
const state = createTestState(['hello world'], 0, 0);
const result = handleVimAction(state, {
type: 'vim_delete_to_char_forward' as const,
payload: { char: 'o', count: 1, till: false },
});
expect(result.undoStack.length).toBeGreaterThan(0);
});
});
describe('vim_delete_to_char_backward (dF/dT)', () => {
it('dF: deletes from found char through cursor (inclusive)', () => {
const state = createTestState(['hello world'], 0, 7);
const result = handleVimAction(state, {
type: 'vim_delete_to_char_backward' as const,
payload: { char: 'o', count: 1, till: false },
});
// cursor at 7 ('o' in world), dFo finds 'o' at col 4
// delete [4, 8) — both ends inclusive → 'hell' + 'rld'
expect(result.lines[0]).toBe('hellrld');
expect(result.cursorCol).toBe(4);
});
it('dT: deletes from found+1 through cursor (inclusive)', () => {
const state = createTestState(['hello world'], 0, 7);
const result = handleVimAction(state, {
type: 'vim_delete_to_char_backward' as const,
payload: { char: 'o', count: 1, till: true },
});
// dTo finds 'o' at col 4, deletes [5, 8) → 'hello' + 'rld'
expect(result.lines[0]).toBe('hellorld');
expect(result.cursorCol).toBe(5);
});
it('does nothing if char not found', () => {
const state = createTestState(['hello'], 0, 4);
const result = handleVimAction(state, {
type: 'vim_delete_to_char_backward' as const,
payload: { char: 'z', count: 1, till: false },
});
expect(result.lines[0]).toBe('hello');
expect(result.cursorCol).toBe(4);
});
it('pushes undo state', () => {
const state = createTestState(['hello world'], 0, 7);
const result = handleVimAction(state, {
type: 'vim_delete_to_char_backward' as const,
payload: { char: 'o', count: 1, till: false },
});
expect(result.undoStack.length).toBeGreaterThan(0);
});
});
});

View File

@@ -24,6 +24,13 @@ import { assumeExhaustive } from '@google/gemini-cli-core';
export type VimAction = Extract<
TextBufferAction,
| { type: 'vim_delete_char_before' }
| { type: 'vim_toggle_case' }
| { type: 'vim_replace_char' }
| { type: 'vim_find_char_forward' }
| { type: 'vim_find_char_backward' }
| { type: 'vim_delete_to_char_forward' }
| { type: 'vim_delete_to_char_backward' }
| { type: 'vim_delete_word_forward' }
| { type: 'vim_delete_word_backward' }
| { type: 'vim_delete_word_end' }
@@ -73,6 +80,35 @@ export type VimAction = Extract<
| { type: 'vim_escape_insert_mode' }
>;
/**
* Find the Nth occurrence of `char` in `codePoints`, starting at `start` and
* stepping by `direction` (+1 forward, -1 backward). Returns the index or -1.
*/
function findCharInLine(
codePoints: string[],
char: string,
count: number,
start: number,
direction: 1 | -1,
): number {
let found = -1;
let hits = 0;
for (
let i = start;
direction === 1 ? i < codePoints.length : i >= 0;
i += direction
) {
if (codePoints[i] === char) {
hits++;
if (hits >= count) {
found = i;
break;
}
}
}
return found;
}
export function handleVimAction(
state: TextBufferState,
action: VimAction,
@@ -1183,6 +1219,151 @@ export function handleVimAction(
};
}
case 'vim_delete_char_before': {
const { count } = action.payload;
if (cursorCol > 0) {
const deleteStart = Math.max(0, cursorCol - count);
const nextState = detachExpandedPaste(pushUndo(state));
return replaceRangeInternal(
nextState,
cursorRow,
deleteStart,
cursorRow,
cursorCol,
'',
);
}
return state;
}
case 'vim_toggle_case': {
const { count } = action.payload;
const currentLine = lines[cursorRow] || '';
const lineLen = cpLen(currentLine);
if (cursorCol >= lineLen) return state;
const end = Math.min(cursorCol + count, lineLen);
const codePoints = toCodePoints(currentLine);
for (let i = cursorCol; i < end; i++) {
const ch = codePoints[i];
const upper = ch.toUpperCase();
const lower = ch.toLowerCase();
codePoints[i] = ch === upper ? lower : upper;
}
const newLine = codePoints.join('');
const nextState = detachExpandedPaste(pushUndo(state));
const newLines = [...nextState.lines];
newLines[cursorRow] = newLine;
const newCol = Math.min(end, lineLen > 0 ? lineLen - 1 : 0);
return {
...nextState,
lines: newLines,
cursorCol: newCol,
preferredCol: null,
};
}
case 'vim_replace_char': {
const { char, count } = action.payload;
const currentLine = lines[cursorRow] || '';
const lineLen = cpLen(currentLine);
if (cursorCol >= lineLen) return state;
const replaceCount = Math.min(count, lineLen - cursorCol);
const replacement = char.repeat(replaceCount);
const nextState = detachExpandedPaste(pushUndo(state));
const resultState = replaceRangeInternal(
nextState,
cursorRow,
cursorCol,
cursorRow,
cursorCol + replaceCount,
replacement,
);
return {
...resultState,
cursorCol: cursorCol + replaceCount - 1,
preferredCol: null,
};
}
case 'vim_delete_to_char_forward': {
const { char, count, till } = action.payload;
const lineCodePoints = toCodePoints(lines[cursorRow] || '');
const found = findCharInLine(
lineCodePoints,
char,
count,
cursorCol + 1,
1,
);
if (found === -1) return state;
const endCol = till ? found : found + 1;
const nextState = detachExpandedPaste(pushUndo(state));
return replaceRangeInternal(
nextState,
cursorRow,
cursorCol,
cursorRow,
endCol,
'',
);
}
case 'vim_delete_to_char_backward': {
const { char, count, till } = action.payload;
const lineCodePoints = toCodePoints(lines[cursorRow] || '');
const found = findCharInLine(
lineCodePoints,
char,
count,
cursorCol - 1,
-1,
);
if (found === -1) return state;
const startCol = till ? found + 1 : found;
const endCol = cursorCol + 1; // inclusive: cursor char is part of the deletion
if (startCol >= endCol) return state;
const nextState = detachExpandedPaste(pushUndo(state));
const resultState = replaceRangeInternal(
nextState,
cursorRow,
startCol,
cursorRow,
endCol,
'',
);
return { ...resultState, cursorCol: startCol, preferredCol: null };
}
case 'vim_find_char_forward': {
const { char, count, till } = action.payload;
const lineCodePoints = toCodePoints(lines[cursorRow] || '');
const found = findCharInLine(
lineCodePoints,
char,
count,
cursorCol + 1,
1,
);
if (found === -1) return state;
const newCol = till ? Math.max(cursorCol, found - 1) : found;
return { ...state, cursorCol: newCol, preferredCol: null };
}
case 'vim_find_char_backward': {
const { char, count, till } = action.payload;
const lineCodePoints = toCodePoints(lines[cursorRow] || '');
const found = findCharInLine(
lineCodePoints,
char,
count,
cursorCol - 1,
-1,
);
if (found === -1) return state;
const newCol = till ? Math.min(cursorCol, found + 1) : found;
return { ...state, cursorCol: newCol, preferredCol: null };
}
default: {
// This should never happen if TypeScript is working correctly
assumeExhaustive(action);