feat(cli): Add W, B, E Vim motions and operator support (#16209)

Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
This commit is contained in:
Adam DeMuri
2026-02-05 10:29:30 -07:00
committed by GitHub
parent 1cae5ab158
commit ee2c8eef19
6 changed files with 836 additions and 82 deletions

View File

@@ -11,41 +11,31 @@ import {
replaceRangeInternal,
pushUndo,
detachExpandedPaste,
isWordCharStrict,
isWordCharWithCombining,
isCombiningMark,
findNextWordAcrossLines,
findPrevWordAcrossLines,
findNextBigWordAcrossLines,
findPrevBigWordAcrossLines,
findWordEndInLine,
findBigWordEndInLine,
} from './text-buffer.js';
import { cpLen, toCodePoints } from '../../utils/textUtils.js';
import { assumeExhaustive } from '@google/gemini-cli-core';
// Check if we're at the end of a base word (on the last base character)
// Returns true if current position has a base character followed only by combining marks until non-word
function isAtEndOfBaseWord(lineCodePoints: string[], col: number): boolean {
if (!isWordCharStrict(lineCodePoints[col])) return false;
// Look ahead to see if we have only combining marks followed by non-word
let i = col + 1;
// Skip any combining marks
while (i < lineCodePoints.length && isCombiningMark(lineCodePoints[i])) {
i++;
}
// If we hit end of line or non-word character, we were at end of base word
return i >= lineCodePoints.length || !isWordCharStrict(lineCodePoints[i]);
}
export type VimAction = Extract<
TextBufferAction,
| { type: 'vim_delete_word_forward' }
| { type: 'vim_delete_word_backward' }
| { type: 'vim_delete_word_end' }
| { type: 'vim_delete_big_word_forward' }
| { type: 'vim_delete_big_word_backward' }
| { type: 'vim_delete_big_word_end' }
| { type: 'vim_change_word_forward' }
| { type: 'vim_change_word_backward' }
| { type: 'vim_change_word_end' }
| { type: 'vim_change_big_word_forward' }
| { type: 'vim_change_big_word_backward' }
| { type: 'vim_change_big_word_end' }
| { type: 'vim_delete_line' }
| { type: 'vim_change_line' }
| { type: 'vim_delete_to_end_of_line' }
@@ -58,6 +48,9 @@ export type VimAction = Extract<
| { type: 'vim_move_word_forward' }
| { type: 'vim_move_word_backward' }
| { type: 'vim_move_word_end' }
| { type: 'vim_move_big_word_forward' }
| { type: 'vim_move_big_word_backward' }
| { type: 'vim_move_big_word_end' }
| { type: 'vim_delete_char' }
| { type: 'vim_insert_at_cursor' }
| { type: 'vim_append_at_cursor' }
@@ -93,14 +86,15 @@ export function handleVimAction(
endRow = nextWord.row;
endCol = nextWord.col;
} else {
// No more words, delete/change to end of current word or line
// No more words. Check if we can delete to the end of the current word.
const currentLine = lines[endRow] || '';
const wordEnd = findWordEndInLine(currentLine, endCol);
if (wordEnd !== null) {
endCol = wordEnd + 1; // Include the character at word end
} else {
endCol = cpLen(currentLine);
// Found word end, delete up to (and including) it
endCol = wordEnd + 1;
}
// If wordEnd is null, we are likely on trailing whitespace, so do nothing.
break;
}
}
@@ -119,6 +113,48 @@ export function handleVimAction(
return state;
}
case 'vim_delete_big_word_forward':
case 'vim_change_big_word_forward': {
const { count } = action.payload;
let endRow = cursorRow;
let endCol = cursorCol;
for (let i = 0; i < count; i++) {
const nextWord = findNextBigWordAcrossLines(
lines,
endRow,
endCol,
true,
);
if (nextWord) {
endRow = nextWord.row;
endCol = nextWord.col;
} else {
// No more words. Check if we can delete to the end of the current big word.
const currentLine = lines[endRow] || '';
const wordEnd = findBigWordEndInLine(currentLine, endCol);
if (wordEnd !== null) {
endCol = wordEnd + 1;
}
break;
}
}
if (endRow !== cursorRow || endCol !== cursorCol) {
const nextState = pushUndo(state);
return replaceRangeInternal(
nextState,
cursorRow,
cursorCol,
endRow,
endCol,
'',
);
}
return state;
}
case 'vim_delete_word_backward':
case 'vim_change_word_backward': {
const { count } = action.payload;
@@ -149,6 +185,36 @@ export function handleVimAction(
return state;
}
case 'vim_delete_big_word_backward':
case 'vim_change_big_word_backward': {
const { count } = action.payload;
let startRow = cursorRow;
let startCol = cursorCol;
for (let i = 0; i < count; i++) {
const prevWord = findPrevBigWordAcrossLines(lines, startRow, startCol);
if (prevWord) {
startRow = prevWord.row;
startCol = prevWord.col;
} else {
break;
}
}
if (startRow !== cursorRow || startCol !== cursorCol) {
const nextState = pushUndo(state);
return replaceRangeInternal(
nextState,
startRow,
startCol,
cursorRow,
cursorCol,
'',
);
}
return state;
}
case 'vim_delete_word_end':
case 'vim_change_word_end': {
const { count } = action.payload;
@@ -202,6 +268,59 @@ export function handleVimAction(
return state;
}
case 'vim_delete_big_word_end':
case 'vim_change_big_word_end': {
const { count } = action.payload;
let row = cursorRow;
let col = cursorCol;
let endRow = cursorRow;
let endCol = cursorCol;
for (let i = 0; i < count; i++) {
const wordEnd = findNextBigWordAcrossLines(lines, row, col, false);
if (wordEnd) {
endRow = wordEnd.row;
endCol = wordEnd.col + 1; // Include the character at word end
// For next iteration, move to start of next word
if (i < count - 1) {
const nextWord = findNextBigWordAcrossLines(
lines,
wordEnd.row,
wordEnd.col + 1,
true,
);
if (nextWord) {
row = nextWord.row;
col = nextWord.col;
} else {
break; // No more words
}
}
} else {
break;
}
}
// Ensure we don't go past the end of the last line
if (endRow < lines.length) {
const lineLen = cpLen(lines[endRow] || '');
endCol = Math.min(endCol, lineLen);
}
if (endRow !== cursorRow || endCol !== cursorCol) {
const nextState = pushUndo(state);
return replaceRangeInternal(
nextState,
cursorRow,
cursorCol,
endRow,
endCol,
'',
);
}
return state;
}
case 'vim_delete_line': {
const { count } = action.payload;
if (lines.length === 0) return state;
@@ -540,6 +659,30 @@ export function handleVimAction(
};
}
case 'vim_move_big_word_forward': {
const { count } = action.payload;
let row = cursorRow;
let col = cursorCol;
for (let i = 0; i < count; i++) {
const nextWord = findNextBigWordAcrossLines(lines, row, col, true);
if (nextWord) {
row = nextWord.row;
col = nextWord.col;
} else {
// No more words to move to
break;
}
}
return {
...state,
cursorRow: row,
cursorCol: col,
preferredCol: null,
};
}
case 'vim_move_word_backward': {
const { count } = action.payload;
let row = cursorRow;
@@ -563,43 +706,35 @@ export function handleVimAction(
};
}
case 'vim_move_big_word_backward': {
const { count } = action.payload;
let row = cursorRow;
let col = cursorCol;
for (let i = 0; i < count; i++) {
const prevWord = findPrevBigWordAcrossLines(lines, row, col);
if (prevWord) {
row = prevWord.row;
col = prevWord.col;
} else {
break;
}
}
return {
...state,
cursorRow: row,
cursorCol: col,
preferredCol: null,
};
}
case 'vim_move_word_end': {
const { count } = action.payload;
let row = cursorRow;
let col = cursorCol;
for (let i = 0; i < count; i++) {
// Special handling for the first iteration when we're at end of word
if (i === 0) {
const currentLine = lines[row] || '';
const lineCodePoints = toCodePoints(currentLine);
// Check if we're at the end of a word (on the last base character)
const atEndOfWord =
col < lineCodePoints.length &&
isWordCharStrict(lineCodePoints[col]) &&
(col + 1 >= lineCodePoints.length ||
!isWordCharWithCombining(lineCodePoints[col + 1]) ||
// Or if we're on a base char followed only by combining marks until non-word
(isWordCharStrict(lineCodePoints[col]) &&
isAtEndOfBaseWord(lineCodePoints, col)));
if (atEndOfWord) {
// We're already at end of word, find next word end
const nextWord = findNextWordAcrossLines(
lines,
row,
col + 1,
false,
);
if (nextWord) {
row = nextWord.row;
col = nextWord.col;
continue;
}
}
}
const wordEnd = findNextWordAcrossLines(lines, row, col, false);
if (wordEnd) {
row = wordEnd.row;
@@ -617,6 +752,29 @@ export function handleVimAction(
};
}
case 'vim_move_big_word_end': {
const { count } = action.payload;
let row = cursorRow;
let col = cursorCol;
for (let i = 0; i < count; i++) {
const wordEnd = findNextBigWordAcrossLines(lines, row, col, false);
if (wordEnd) {
row = wordEnd.row;
col = wordEnd.col;
} else {
break;
}
}
return {
...state,
cursorRow: row,
cursorCol: col,
preferredCol: null,
};
}
case 'vim_delete_char': {
const { count } = action.payload;
const { cursorRow, cursorCol, lines } = state;