mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-03-17 01:21:10 -07:00
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user