mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-01 17:02:29 -07:00
fix(vim): vim support that feels (more) complete (#18755)
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
This commit is contained in:
@@ -39,7 +39,13 @@ export type VimAction = Extract<
|
||||
| { type: 'vim_delete_line' }
|
||||
| { type: 'vim_change_line' }
|
||||
| { type: 'vim_delete_to_end_of_line' }
|
||||
| { type: 'vim_delete_to_start_of_line' }
|
||||
| { type: 'vim_delete_to_first_nonwhitespace' }
|
||||
| { type: 'vim_change_to_end_of_line' }
|
||||
| { type: 'vim_change_to_start_of_line' }
|
||||
| { type: 'vim_change_to_first_nonwhitespace' }
|
||||
| { type: 'vim_delete_to_first_line' }
|
||||
| { type: 'vim_delete_to_last_line' }
|
||||
| { type: 'vim_change_movement' }
|
||||
| { type: 'vim_move_left' }
|
||||
| { type: 'vim_move_right' }
|
||||
@@ -387,21 +393,253 @@ export function handleVimAction(
|
||||
|
||||
case 'vim_delete_to_end_of_line':
|
||||
case 'vim_change_to_end_of_line': {
|
||||
const { count } = action.payload;
|
||||
const currentLine = lines[cursorRow] || '';
|
||||
if (cursorCol < cpLen(currentLine)) {
|
||||
const totalLines = lines.length;
|
||||
|
||||
if (count === 1) {
|
||||
// Single line: delete from cursor to end of current line
|
||||
if (cursorCol < cpLen(currentLine)) {
|
||||
const nextState = detachExpandedPaste(pushUndo(state));
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
cursorRow,
|
||||
cpLen(currentLine),
|
||||
'',
|
||||
);
|
||||
}
|
||||
return state;
|
||||
} else {
|
||||
// Multi-line: delete from cursor to end of current line, plus (count-1) entire lines below
|
||||
// For example, 2D = delete to EOL + delete next line entirely
|
||||
const linesToDelete = Math.min(count - 1, totalLines - cursorRow - 1);
|
||||
const endRow = cursorRow + linesToDelete;
|
||||
|
||||
if (endRow === cursorRow) {
|
||||
// No additional lines to delete, just delete to EOL
|
||||
if (cursorCol < cpLen(currentLine)) {
|
||||
const nextState = detachExpandedPaste(pushUndo(state));
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
cursorRow,
|
||||
cpLen(currentLine),
|
||||
'',
|
||||
);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
// Delete from cursor position to end of endRow (including newlines)
|
||||
const nextState = detachExpandedPaste(pushUndo(state));
|
||||
const endLine = lines[endRow] || '';
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
endRow,
|
||||
cpLen(endLine),
|
||||
'',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
case 'vim_delete_to_start_of_line': {
|
||||
if (cursorCol > 0) {
|
||||
const nextState = detachExpandedPaste(pushUndo(state));
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
cursorRow,
|
||||
cpLen(currentLine),
|
||||
0,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
'',
|
||||
);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_delete_to_first_nonwhitespace': {
|
||||
// Delete from cursor to first non-whitespace character (vim 'd^')
|
||||
const currentLine = lines[cursorRow] || '';
|
||||
const lineCodePoints = toCodePoints(currentLine);
|
||||
let firstNonWs = 0;
|
||||
while (
|
||||
firstNonWs < lineCodePoints.length &&
|
||||
/\s/.test(lineCodePoints[firstNonWs])
|
||||
) {
|
||||
firstNonWs++;
|
||||
}
|
||||
// If line is all whitespace, firstNonWs would be lineCodePoints.length
|
||||
// In VIM, ^ on whitespace-only line goes to column 0
|
||||
if (firstNonWs >= lineCodePoints.length) {
|
||||
firstNonWs = 0;
|
||||
}
|
||||
// Delete between cursor and first non-whitespace (whichever direction)
|
||||
if (cursorCol !== firstNonWs) {
|
||||
const startCol = Math.min(cursorCol, firstNonWs);
|
||||
const endCol = Math.max(cursorCol, firstNonWs);
|
||||
const nextState = detachExpandedPaste(pushUndo(state));
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
cursorRow,
|
||||
startCol,
|
||||
cursorRow,
|
||||
endCol,
|
||||
'',
|
||||
);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_change_to_start_of_line': {
|
||||
// Change from cursor to start of line (vim 'c0')
|
||||
if (cursorCol > 0) {
|
||||
const nextState = detachExpandedPaste(pushUndo(state));
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
cursorRow,
|
||||
0,
|
||||
cursorRow,
|
||||
cursorCol,
|
||||
'',
|
||||
);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_change_to_first_nonwhitespace': {
|
||||
// Change from cursor to first non-whitespace character (vim 'c^')
|
||||
const currentLine = lines[cursorRow] || '';
|
||||
const lineCodePoints = toCodePoints(currentLine);
|
||||
let firstNonWs = 0;
|
||||
while (
|
||||
firstNonWs < lineCodePoints.length &&
|
||||
/\s/.test(lineCodePoints[firstNonWs])
|
||||
) {
|
||||
firstNonWs++;
|
||||
}
|
||||
// If line is all whitespace, firstNonWs would be lineCodePoints.length
|
||||
// In VIM, ^ on whitespace-only line goes to column 0
|
||||
if (firstNonWs >= lineCodePoints.length) {
|
||||
firstNonWs = 0;
|
||||
}
|
||||
// Change between cursor and first non-whitespace (whichever direction)
|
||||
if (cursorCol !== firstNonWs) {
|
||||
const startCol = Math.min(cursorCol, firstNonWs);
|
||||
const endCol = Math.max(cursorCol, firstNonWs);
|
||||
const nextState = detachExpandedPaste(pushUndo(state));
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
cursorRow,
|
||||
startCol,
|
||||
cursorRow,
|
||||
endCol,
|
||||
'',
|
||||
);
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'vim_delete_to_first_line': {
|
||||
// Delete from first line (or line N if count given) to current line (vim 'dgg' or 'd5gg')
|
||||
// count is the target line number (1-based), or 0 for first line
|
||||
const { count } = action.payload;
|
||||
const totalLines = lines.length;
|
||||
|
||||
// Determine target row (0-based)
|
||||
// count=0 means go to first line, count=N means go to line N (1-based)
|
||||
let targetRow: number;
|
||||
if (count > 0) {
|
||||
targetRow = Math.min(count - 1, totalLines - 1);
|
||||
} else {
|
||||
targetRow = 0;
|
||||
}
|
||||
|
||||
// Determine the range to delete (from min to max row, inclusive)
|
||||
const startRow = Math.min(cursorRow, targetRow);
|
||||
const endRow = Math.max(cursorRow, targetRow);
|
||||
const linesToDelete = endRow - startRow + 1;
|
||||
|
||||
if (linesToDelete >= totalLines) {
|
||||
// Deleting all lines - keep one empty line
|
||||
const nextState = detachExpandedPaste(pushUndo(state));
|
||||
return {
|
||||
...nextState,
|
||||
lines: [''],
|
||||
cursorRow: 0,
|
||||
cursorCol: 0,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
const nextState = detachExpandedPaste(pushUndo(state));
|
||||
const newLines = [...nextState.lines];
|
||||
newLines.splice(startRow, linesToDelete);
|
||||
|
||||
// Cursor goes to start of the deleted range, clamped to valid bounds
|
||||
const newCursorRow = Math.min(startRow, newLines.length - 1);
|
||||
|
||||
return {
|
||||
...nextState,
|
||||
lines: newLines,
|
||||
cursorRow: newCursorRow,
|
||||
cursorCol: 0,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_delete_to_last_line': {
|
||||
// Delete from current line to last line (vim 'dG') or to line N (vim 'd5G')
|
||||
// count is the target line number (1-based), or 0 for last line
|
||||
const { count } = action.payload;
|
||||
const totalLines = lines.length;
|
||||
|
||||
// Determine target row (0-based)
|
||||
// count=0 means go to last line, count=N means go to line N (1-based)
|
||||
let targetRow: number;
|
||||
if (count > 0) {
|
||||
targetRow = Math.min(count - 1, totalLines - 1);
|
||||
} else {
|
||||
targetRow = totalLines - 1;
|
||||
}
|
||||
|
||||
// Determine the range to delete (from min to max row, inclusive)
|
||||
const startRow = Math.min(cursorRow, targetRow);
|
||||
const endRow = Math.max(cursorRow, targetRow);
|
||||
const linesToDelete = endRow - startRow + 1;
|
||||
|
||||
if (linesToDelete >= totalLines) {
|
||||
// Deleting all lines - keep one empty line
|
||||
const nextState = detachExpandedPaste(pushUndo(state));
|
||||
return {
|
||||
...nextState,
|
||||
lines: [''],
|
||||
cursorRow: 0,
|
||||
cursorCol: 0,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
const nextState = detachExpandedPaste(pushUndo(state));
|
||||
const newLines = [...nextState.lines];
|
||||
newLines.splice(startRow, linesToDelete);
|
||||
|
||||
// Move cursor to the start of the deleted range (or last line if needed)
|
||||
const newCursorRow = Math.min(startRow, newLines.length - 1);
|
||||
|
||||
return {
|
||||
...nextState,
|
||||
lines: newLines,
|
||||
cursorRow: newCursorRow,
|
||||
cursorCol: 0,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
case 'vim_change_movement': {
|
||||
const { movement, count } = action.payload;
|
||||
const totalLines = lines.length;
|
||||
@@ -422,88 +660,65 @@ export function handleVimAction(
|
||||
}
|
||||
|
||||
case 'j': {
|
||||
// Down
|
||||
const linesToChange = Math.min(count, totalLines - cursorRow);
|
||||
// Down - delete/change current line + count lines below
|
||||
const linesToChange = Math.min(count + 1, totalLines - cursorRow);
|
||||
if (linesToChange > 0) {
|
||||
if (totalLines === 1) {
|
||||
const currentLine = state.lines[0] || '';
|
||||
return replaceRangeInternal(
|
||||
detachExpandedPaste(pushUndo(state)),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
cpLen(currentLine),
|
||||
'',
|
||||
);
|
||||
} else {
|
||||
if (linesToChange >= totalLines) {
|
||||
// Deleting all lines - keep one empty line
|
||||
const nextState = detachExpandedPaste(pushUndo(state));
|
||||
const { startOffset, endOffset } = getLineRangeOffsets(
|
||||
cursorRow,
|
||||
linesToChange,
|
||||
nextState.lines,
|
||||
);
|
||||
const { startRow, startCol, endRow, endCol } =
|
||||
getPositionFromOffsets(startOffset, endOffset, nextState.lines);
|
||||
return replaceRangeInternal(
|
||||
nextState,
|
||||
startRow,
|
||||
startCol,
|
||||
endRow,
|
||||
endCol,
|
||||
'',
|
||||
);
|
||||
return {
|
||||
...nextState,
|
||||
lines: [''],
|
||||
cursorRow: 0,
|
||||
cursorCol: 0,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
const nextState = detachExpandedPaste(pushUndo(state));
|
||||
const newLines = [...nextState.lines];
|
||||
newLines.splice(cursorRow, linesToChange);
|
||||
|
||||
return {
|
||||
...nextState,
|
||||
lines: newLines,
|
||||
cursorRow: Math.min(cursorRow, newLines.length - 1),
|
||||
cursorCol: 0,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
case 'k': {
|
||||
// Up
|
||||
const upLines = Math.min(count, cursorRow + 1);
|
||||
if (upLines > 0) {
|
||||
if (state.lines.length === 1) {
|
||||
const currentLine = state.lines[0] || '';
|
||||
return replaceRangeInternal(
|
||||
detachExpandedPaste(pushUndo(state)),
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
cpLen(currentLine),
|
||||
'',
|
||||
);
|
||||
} else {
|
||||
const startRow = Math.max(0, cursorRow - count + 1);
|
||||
const linesToChange = cursorRow - startRow + 1;
|
||||
// Up - delete/change current line + count lines above
|
||||
const startRow = Math.max(0, cursorRow - count);
|
||||
const linesToChange = cursorRow - startRow + 1;
|
||||
|
||||
if (linesToChange > 0) {
|
||||
if (linesToChange >= totalLines) {
|
||||
// Deleting all lines - keep one empty line
|
||||
const nextState = detachExpandedPaste(pushUndo(state));
|
||||
const { startOffset, endOffset } = getLineRangeOffsets(
|
||||
startRow,
|
||||
linesToChange,
|
||||
nextState.lines,
|
||||
);
|
||||
const {
|
||||
startRow: newStartRow,
|
||||
startCol,
|
||||
endRow,
|
||||
endCol,
|
||||
} = getPositionFromOffsets(
|
||||
startOffset,
|
||||
endOffset,
|
||||
nextState.lines,
|
||||
);
|
||||
const resultState = replaceRangeInternal(
|
||||
nextState,
|
||||
newStartRow,
|
||||
startCol,
|
||||
endRow,
|
||||
endCol,
|
||||
'',
|
||||
);
|
||||
return {
|
||||
...resultState,
|
||||
cursorRow: startRow,
|
||||
...nextState,
|
||||
lines: [''],
|
||||
cursorRow: 0,
|
||||
cursorCol: 0,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
|
||||
const nextState = detachExpandedPaste(pushUndo(state));
|
||||
const newLines = [...nextState.lines];
|
||||
newLines.splice(startRow, linesToChange);
|
||||
|
||||
return {
|
||||
...nextState,
|
||||
lines: newLines,
|
||||
cursorRow: Math.min(startRow, newLines.length - 1),
|
||||
cursorCol: 0,
|
||||
preferredCol: null,
|
||||
};
|
||||
}
|
||||
return state;
|
||||
}
|
||||
@@ -910,6 +1125,11 @@ export function handleVimAction(
|
||||
col++;
|
||||
}
|
||||
|
||||
// If line is all whitespace or empty, ^ goes to column 0 (standard Vim behavior)
|
||||
if (col >= lineCodePoints.length) {
|
||||
col = 0;
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
cursorCol: col,
|
||||
|
||||
Reference in New Issue
Block a user