summaryrefslogtreecommitdiff
path: root/packages/cli/src/ui/components/shared
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli/src/ui/components/shared')
-rw-r--r--packages/cli/src/ui/components/shared/text-buffer.test.ts214
-rw-r--r--packages/cli/src/ui/components/shared/text-buffer.ts799
-rw-r--r--packages/cli/src/ui/components/shared/vim-buffer-actions.test.ts796
-rw-r--r--packages/cli/src/ui/components/shared/vim-buffer-actions.ts887
4 files changed, 2608 insertions, 88 deletions
diff --git a/packages/cli/src/ui/components/shared/text-buffer.test.ts b/packages/cli/src/ui/components/shared/text-buffer.test.ts
index 4db1ce7b..807c33df 100644
--- a/packages/cli/src/ui/components/shared/text-buffer.test.ts
+++ b/packages/cli/src/ui/components/shared/text-buffer.test.ts
@@ -11,6 +11,7 @@ import {
Viewport,
TextBuffer,
offsetToLogicalPos,
+ logicalPosToOffset,
textBufferReducer,
TextBufferState,
TextBufferAction,
@@ -1341,3 +1342,216 @@ describe('offsetToLogicalPos', () => {
expect(offsetToLogicalPos(text, 2)).toEqual([0, 2]); // After 🐱
});
});
+
+describe('logicalPosToOffset', () => {
+ it('should convert row/col position to offset correctly', () => {
+ const lines = ['hello', 'world', '123'];
+
+ // Line 0: "hello" (5 chars)
+ expect(logicalPosToOffset(lines, 0, 0)).toBe(0); // Start of 'hello'
+ expect(logicalPosToOffset(lines, 0, 3)).toBe(3); // 'l' in 'hello'
+ expect(logicalPosToOffset(lines, 0, 5)).toBe(5); // End of 'hello'
+
+ // Line 1: "world" (5 chars), offset starts at 6 (5 + 1 for newline)
+ expect(logicalPosToOffset(lines, 1, 0)).toBe(6); // Start of 'world'
+ expect(logicalPosToOffset(lines, 1, 2)).toBe(8); // 'r' in 'world'
+ expect(logicalPosToOffset(lines, 1, 5)).toBe(11); // End of 'world'
+
+ // Line 2: "123" (3 chars), offset starts at 12 (5 + 1 + 5 + 1)
+ expect(logicalPosToOffset(lines, 2, 0)).toBe(12); // Start of '123'
+ expect(logicalPosToOffset(lines, 2, 1)).toBe(13); // '2' in '123'
+ expect(logicalPosToOffset(lines, 2, 3)).toBe(15); // End of '123'
+ });
+
+ it('should handle empty lines', () => {
+ const lines = ['a', '', 'c'];
+
+ expect(logicalPosToOffset(lines, 0, 0)).toBe(0); // 'a'
+ expect(logicalPosToOffset(lines, 0, 1)).toBe(1); // End of 'a'
+ expect(logicalPosToOffset(lines, 1, 0)).toBe(2); // Empty line
+ expect(logicalPosToOffset(lines, 2, 0)).toBe(3); // 'c'
+ expect(logicalPosToOffset(lines, 2, 1)).toBe(4); // End of 'c'
+ });
+
+ it('should handle single empty line', () => {
+ const lines = [''];
+
+ expect(logicalPosToOffset(lines, 0, 0)).toBe(0);
+ });
+
+ it('should be inverse of offsetToLogicalPos', () => {
+ const lines = ['hello', 'world', '123'];
+ const text = lines.join('\n');
+
+ // Test round-trip conversion
+ for (let offset = 0; offset <= text.length; offset++) {
+ const [row, col] = offsetToLogicalPos(text, offset);
+ const convertedOffset = logicalPosToOffset(lines, row, col);
+ expect(convertedOffset).toBe(offset);
+ }
+ });
+
+ it('should handle out-of-bounds positions', () => {
+ const lines = ['hello'];
+
+ // Beyond end of line
+ expect(logicalPosToOffset(lines, 0, 10)).toBe(5); // Clamps to end of line
+
+ // Beyond array bounds - should clamp to the last line
+ expect(logicalPosToOffset(lines, 5, 0)).toBe(0); // Clamps to start of last line (row 0)
+ expect(logicalPosToOffset(lines, 5, 10)).toBe(5); // Clamps to end of last line
+ });
+});
+
+describe('textBufferReducer vim operations', () => {
+ describe('vim_delete_line', () => {
+ it('should delete a single line including newline in multi-line text', () => {
+ const initialState: TextBufferState = {
+ lines: ['line1', 'line2', 'line3'],
+ cursorRow: 1,
+ cursorCol: 2,
+ preferredCol: null,
+ visualLines: [['line1'], ['line2'], ['line3']],
+ visualScrollRow: 0,
+ visualCursor: { row: 1, col: 2 },
+ viewport: { width: 10, height: 5 },
+ undoStack: [],
+ redoStack: [],
+ };
+
+ const action: TextBufferAction = {
+ type: 'vim_delete_line',
+ payload: { count: 1 },
+ };
+
+ const result = textBufferReducer(initialState, action);
+
+ // After deleting line2, we should have line1 and line3, with cursor on line3 (now at index 1)
+ expect(result.lines).toEqual(['line1', 'line3']);
+ expect(result.cursorRow).toBe(1);
+ expect(result.cursorCol).toBe(0);
+ });
+
+ it('should delete multiple lines when count > 1', () => {
+ const initialState: TextBufferState = {
+ lines: ['line1', 'line2', 'line3', 'line4'],
+ cursorRow: 1,
+ cursorCol: 0,
+ preferredCol: null,
+ visualLines: [['line1'], ['line2'], ['line3'], ['line4']],
+ visualScrollRow: 0,
+ visualCursor: { row: 1, col: 0 },
+ viewport: { width: 10, height: 5 },
+ undoStack: [],
+ redoStack: [],
+ };
+
+ const action: TextBufferAction = {
+ type: 'vim_delete_line',
+ payload: { count: 2 },
+ };
+
+ const result = textBufferReducer(initialState, action);
+
+ // Should delete line2 and line3, leaving line1 and line4
+ expect(result.lines).toEqual(['line1', 'line4']);
+ expect(result.cursorRow).toBe(1);
+ expect(result.cursorCol).toBe(0);
+ });
+
+ it('should clear single line content when only one line exists', () => {
+ const initialState: TextBufferState = {
+ lines: ['only line'],
+ cursorRow: 0,
+ cursorCol: 5,
+ preferredCol: null,
+ visualLines: [['only line']],
+ visualScrollRow: 0,
+ visualCursor: { row: 0, col: 5 },
+ viewport: { width: 10, height: 5 },
+ undoStack: [],
+ redoStack: [],
+ };
+
+ const action: TextBufferAction = {
+ type: 'vim_delete_line',
+ payload: { count: 1 },
+ };
+
+ const result = textBufferReducer(initialState, action);
+
+ // Should clear the line content but keep the line
+ expect(result.lines).toEqual(['']);
+ expect(result.cursorRow).toBe(0);
+ expect(result.cursorCol).toBe(0);
+ });
+
+ it('should handle deleting the last line properly', () => {
+ const initialState: TextBufferState = {
+ lines: ['line1', 'line2'],
+ cursorRow: 1,
+ cursorCol: 0,
+ preferredCol: null,
+ visualLines: [['line1'], ['line2']],
+ visualScrollRow: 0,
+ visualCursor: { row: 1, col: 0 },
+ viewport: { width: 10, height: 5 },
+ undoStack: [],
+ redoStack: [],
+ };
+
+ const action: TextBufferAction = {
+ type: 'vim_delete_line',
+ payload: { count: 1 },
+ };
+
+ const result = textBufferReducer(initialState, action);
+
+ // Should delete the last line completely, not leave empty line
+ expect(result.lines).toEqual(['line1']);
+ expect(result.cursorRow).toBe(0);
+ expect(result.cursorCol).toBe(0);
+ });
+
+ it('should handle deleting all lines and maintain valid state for subsequent paste', () => {
+ const initialState: TextBufferState = {
+ lines: ['line1', 'line2', 'line3', 'line4'],
+ cursorRow: 0,
+ cursorCol: 0,
+ preferredCol: null,
+ visualLines: [['line1'], ['line2'], ['line3'], ['line4']],
+ visualScrollRow: 0,
+ visualCursor: { row: 0, col: 0 },
+ viewport: { width: 10, height: 5 },
+ undoStack: [],
+ redoStack: [],
+ };
+
+ // Delete all 4 lines with 4dd
+ const deleteAction: TextBufferAction = {
+ type: 'vim_delete_line',
+ payload: { count: 4 },
+ };
+
+ const afterDelete = textBufferReducer(initialState, deleteAction);
+
+ // After deleting all lines, should have one empty line
+ expect(afterDelete.lines).toEqual(['']);
+ expect(afterDelete.cursorRow).toBe(0);
+ expect(afterDelete.cursorCol).toBe(0);
+
+ // Now paste multiline content - this should work correctly
+ const pasteAction: TextBufferAction = {
+ type: 'insert',
+ payload: 'new1\nnew2\nnew3\nnew4',
+ };
+
+ const afterPaste = textBufferReducer(afterDelete, pasteAction);
+
+ // All lines including the first one should be present
+ expect(afterPaste.lines).toEqual(['new1', 'new2', 'new3', 'new4']);
+ expect(afterPaste.cursorRow).toBe(3);
+ expect(afterPaste.cursorCol).toBe(4);
+ });
+ });
+});
diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts
index 31db1f14..d2d9087a 100644
--- a/packages/cli/src/ui/components/shared/text-buffer.ts
+++ b/packages/cli/src/ui/components/shared/text-buffer.ts
@@ -13,6 +13,7 @@ import { useState, useCallback, useEffect, useMemo, useReducer } from 'react';
import stringWidth from 'string-width';
import { unescapePath } from '@google/gemini-cli-core';
import { toCodePoints, cpLen, cpSlice } from '../../utils/textUtils.js';
+import { handleVimAction, VimAction } from './vim-buffer-actions.js';
export type Direction =
| 'left'
@@ -32,6 +33,283 @@ function isWordChar(ch: string | undefined): boolean {
return !/[\s,.;!?]/.test(ch);
}
+// Vim-specific word boundary functions
+export const findNextWordStart = (
+ text: string,
+ currentOffset: number,
+): number => {
+ let i = currentOffset;
+
+ if (i >= text.length) return i;
+
+ const currentChar = text[i];
+
+ // Skip current word/sequence based on character type
+ if (/\w/.test(currentChar)) {
+ // Skip current word characters
+ while (i < text.length && /\w/.test(text[i])) {
+ i++;
+ }
+ } else if (!/\s/.test(currentChar)) {
+ // Skip current non-word, non-whitespace characters (like "/", ".", etc.)
+ while (i < text.length && !/\w/.test(text[i]) && !/\s/.test(text[i])) {
+ i++;
+ }
+ }
+
+ // Skip whitespace
+ while (i < text.length && /\s/.test(text[i])) {
+ i++;
+ }
+
+ // If we reached the end of text and there's no next word,
+ // vim behavior for dw is to delete to the end of the current word
+ if (i >= text.length) {
+ // Go back to find the end of the last word
+ let endOfLastWord = text.length - 1;
+ while (endOfLastWord >= 0 && /\s/.test(text[endOfLastWord])) {
+ endOfLastWord--;
+ }
+ // For dw on last word, return position AFTER the last character to delete entire word
+ return Math.max(currentOffset + 1, endOfLastWord + 1);
+ }
+
+ return i;
+};
+
+export const findPrevWordStart = (
+ text: string,
+ currentOffset: number,
+): number => {
+ let i = currentOffset;
+
+ // If at beginning of text, return current position
+ if (i <= 0) {
+ return currentOffset;
+ }
+
+ // Move back one character to start searching
+ i--;
+
+ // Skip whitespace moving backwards
+ while (i >= 0 && (text[i] === ' ' || text[i] === '\t' || text[i] === '\n')) {
+ i--;
+ }
+
+ if (i < 0) {
+ return 0; // Reached beginning of text
+ }
+
+ const charAtI = text[i];
+
+ if (/\w/.test(charAtI)) {
+ // We're in a word, move to its beginning
+ while (i >= 0 && /\w/.test(text[i])) {
+ i--;
+ }
+ return i + 1; // Return first character of word
+ } else {
+ // We're in punctuation, move to its beginning
+ while (
+ i >= 0 &&
+ !/\w/.test(text[i]) &&
+ text[i] !== ' ' &&
+ text[i] !== '\t' &&
+ text[i] !== '\n'
+ ) {
+ i--;
+ }
+ return i + 1; // Return first character of punctuation sequence
+ }
+};
+
+export const findWordEnd = (text: string, currentOffset: number): number => {
+ let i = currentOffset;
+
+ // If we're already at the end of a word, advance to next word
+ if (
+ i < text.length &&
+ /\w/.test(text[i]) &&
+ (i + 1 >= text.length || !/\w/.test(text[i + 1]))
+ ) {
+ // We're at the end of a word, move forward to find next word
+ i++;
+ // Skip whitespace/punctuation to find next word
+ while (i < text.length && !/\w/.test(text[i])) {
+ i++;
+ }
+ }
+
+ // If we're not on a word character, find the next word
+ if (i < text.length && !/\w/.test(text[i])) {
+ while (i < text.length && !/\w/.test(text[i])) {
+ i++;
+ }
+ }
+
+ // Move to end of current word
+ while (i < text.length && /\w/.test(text[i])) {
+ i++;
+ }
+
+ // Move back one to be on the last character of the word
+ return Math.max(currentOffset, i - 1);
+};
+
+// Helper functions for vim operations
+export const getOffsetFromPosition = (
+ row: number,
+ col: number,
+ lines: string[],
+): number => {
+ let offset = 0;
+ for (let i = 0; i < row; i++) {
+ offset += lines[i].length + 1; // +1 for newline
+ }
+ offset += col;
+ return offset;
+};
+
+export const getPositionFromOffsets = (
+ startOffset: number,
+ endOffset: number,
+ lines: string[],
+) => {
+ let offset = 0;
+ let startRow = 0;
+ let startCol = 0;
+ let endRow = 0;
+ let endCol = 0;
+
+ // Find start position
+ for (let i = 0; i < lines.length; i++) {
+ const lineLength = lines[i].length + 1; // +1 for newline
+ if (offset + lineLength > startOffset) {
+ startRow = i;
+ startCol = startOffset - offset;
+ break;
+ }
+ offset += lineLength;
+ }
+
+ // Find end position
+ offset = 0;
+ for (let i = 0; i < lines.length; i++) {
+ const lineLength = lines[i].length + (i < lines.length - 1 ? 1 : 0); // +1 for newline except last line
+ if (offset + lineLength >= endOffset) {
+ endRow = i;
+ endCol = endOffset - offset;
+ break;
+ }
+ offset += lineLength;
+ }
+
+ return { startRow, startCol, endRow, endCol };
+};
+
+export const getLineRangeOffsets = (
+ startRow: number,
+ lineCount: number,
+ lines: string[],
+) => {
+ let startOffset = 0;
+
+ // Calculate start offset
+ for (let i = 0; i < startRow; i++) {
+ startOffset += lines[i].length + 1; // +1 for newline
+ }
+
+ // Calculate end offset
+ let endOffset = startOffset;
+ for (let i = 0; i < lineCount; i++) {
+ const lineIndex = startRow + i;
+ if (lineIndex < lines.length) {
+ endOffset += lines[lineIndex].length;
+ if (lineIndex < lines.length - 1) {
+ endOffset += 1; // +1 for newline
+ }
+ }
+ }
+
+ return { startOffset, endOffset };
+};
+
+export const replaceRangeInternal = (
+ state: TextBufferState,
+ startRow: number,
+ startCol: number,
+ endRow: number,
+ endCol: number,
+ text: string,
+): TextBufferState => {
+ const currentLine = (row: number) => state.lines[row] || '';
+ const currentLineLen = (row: number) => cpLen(currentLine(row));
+ const clamp = (value: number, min: number, max: number) =>
+ Math.min(Math.max(value, min), max);
+
+ if (
+ startRow > endRow ||
+ (startRow === endRow && startCol > endCol) ||
+ startRow < 0 ||
+ startCol < 0 ||
+ endRow >= state.lines.length ||
+ (endRow < state.lines.length && endCol > currentLineLen(endRow))
+ ) {
+ return state; // Invalid range
+ }
+
+ const newLines = [...state.lines];
+
+ const sCol = clamp(startCol, 0, currentLineLen(startRow));
+ const eCol = clamp(endCol, 0, currentLineLen(endRow));
+
+ const prefix = cpSlice(currentLine(startRow), 0, sCol);
+ const suffix = cpSlice(currentLine(endRow), eCol);
+
+ const normalisedReplacement = text
+ .replace(/\r\n/g, '\n')
+ .replace(/\r/g, '\n');
+ const replacementParts = normalisedReplacement.split('\n');
+
+ // Replace the content
+ if (startRow === endRow) {
+ newLines[startRow] = prefix + normalisedReplacement + suffix;
+ } else {
+ const firstLine = prefix + replacementParts[0];
+ if (replacementParts.length === 1) {
+ // Single line of replacement text, but spanning multiple original lines
+ newLines.splice(startRow, endRow - startRow + 1, firstLine + suffix);
+ } else {
+ // Multi-line replacement text
+ const lastLine = replacementParts[replacementParts.length - 1] + suffix;
+ const middleLines = replacementParts.slice(1, -1);
+ newLines.splice(
+ startRow,
+ endRow - startRow + 1,
+ firstLine,
+ ...middleLines,
+ lastLine,
+ );
+ }
+ }
+
+ const finalCursorRow = startRow + replacementParts.length - 1;
+ const finalCursorCol =
+ (replacementParts.length > 1 ? 0 : sCol) +
+ cpLen(replacementParts[replacementParts.length - 1]);
+
+ return {
+ ...state,
+ lines: newLines,
+ cursorRow: Math.min(Math.max(finalCursorRow, 0), newLines.length - 1),
+ cursorCol: Math.max(
+ 0,
+ Math.min(finalCursorCol, cpLen(newLines[finalCursorRow] || '')),
+ ),
+ preferredCol: null,
+ };
+};
+
/**
* Strip characters that can break terminal rendering.
*
@@ -158,6 +436,33 @@ export function offsetToLogicalPos(
return [row, col];
}
+/**
+ * Converts logical row/col position to absolute text offset
+ * Inverse operation of offsetToLogicalPos
+ */
+export function logicalPosToOffset(
+ lines: string[],
+ row: number,
+ col: number,
+): number {
+ let offset = 0;
+
+ // Clamp row to valid range
+ const actualRow = Math.min(row, lines.length - 1);
+
+ // Add lengths of all lines before the target row
+ for (let i = 0; i < actualRow; i++) {
+ offset += cpLen(lines[i]) + 1; // +1 for newline
+ }
+
+ // Add column offset within the target row
+ if (actualRow >= 0 && actualRow < lines.length) {
+ offset += Math.min(col, cpLen(lines[actualRow]));
+ }
+
+ return offset;
+}
+
// Helper to calculate visual lines and map cursor positions
function calculateVisualLayout(
logicalLines: string[],
@@ -376,7 +681,7 @@ function calculateVisualLayout(
// --- Start of reducer logic ---
-interface TextBufferState {
+export interface TextBufferState {
lines: string[];
cursorRow: number;
cursorCol: number;
@@ -390,7 +695,20 @@ interface TextBufferState {
const historyLimit = 100;
-type TextBufferAction =
+export const pushUndo = (currentState: TextBufferState): TextBufferState => {
+ const snapshot = {
+ lines: [...currentState.lines],
+ cursorRow: currentState.cursorRow,
+ cursorCol: currentState.cursorCol,
+ };
+ const newStack = [...currentState.undoStack, snapshot];
+ if (newStack.length > historyLimit) {
+ newStack.shift();
+ }
+ return { ...currentState, undoStack: newStack, redoStack: [] };
+};
+
+export type TextBufferAction =
| { type: 'set_text'; payload: string; pushToUndo?: boolean }
| { type: 'insert'; payload: string }
| { type: 'backspace' }
@@ -419,24 +737,49 @@ type TextBufferAction =
}
| { type: 'move_to_offset'; payload: { offset: number } }
| { type: 'create_undo_snapshot' }
- | { type: 'set_viewport_width'; payload: number };
+ | { type: 'set_viewport_width'; payload: number }
+ | { type: 'vim_delete_word_forward'; payload: { count: number } }
+ | { type: 'vim_delete_word_backward'; payload: { count: number } }
+ | { type: 'vim_delete_word_end'; payload: { count: number } }
+ | { type: 'vim_change_word_forward'; payload: { count: number } }
+ | { type: 'vim_change_word_backward'; payload: { count: number } }
+ | { type: 'vim_change_word_end'; payload: { count: number } }
+ | { type: 'vim_delete_line'; payload: { count: number } }
+ | { type: 'vim_change_line'; payload: { count: number } }
+ | { type: 'vim_delete_to_end_of_line' }
+ | { type: 'vim_change_to_end_of_line' }
+ | {
+ type: 'vim_change_movement';
+ payload: { movement: 'h' | 'j' | 'k' | 'l'; count: number };
+ }
+ // New vim actions for stateless command handling
+ | { type: 'vim_move_left'; payload: { count: number } }
+ | { type: 'vim_move_right'; payload: { count: number } }
+ | { type: 'vim_move_up'; payload: { count: number } }
+ | { type: 'vim_move_down'; payload: { count: number } }
+ | { type: 'vim_move_word_forward'; payload: { count: number } }
+ | { type: 'vim_move_word_backward'; payload: { count: number } }
+ | { type: 'vim_move_word_end'; payload: { count: number } }
+ | { type: 'vim_delete_char'; payload: { count: number } }
+ | { type: 'vim_insert_at_cursor' }
+ | { type: 'vim_append_at_cursor' }
+ | { type: 'vim_open_line_below' }
+ | { type: 'vim_open_line_above' }
+ | { type: 'vim_append_at_line_end' }
+ | { type: 'vim_insert_at_line_start' }
+ | { type: 'vim_move_to_line_start' }
+ | { type: 'vim_move_to_line_end' }
+ | { type: 'vim_move_to_first_nonwhitespace' }
+ | { type: 'vim_move_to_first_line' }
+ | { type: 'vim_move_to_last_line' }
+ | { type: 'vim_move_to_line'; payload: { lineNumber: number } }
+ | { type: 'vim_escape_insert_mode' };
export function textBufferReducer(
state: TextBufferState,
action: TextBufferAction,
): TextBufferState {
- const pushUndo = (currentState: TextBufferState): TextBufferState => {
- const snapshot = {
- lines: [...currentState.lines],
- cursorRow: currentState.cursorRow,
- cursorCol: currentState.cursorCol,
- };
- const newStack = [...currentState.undoStack, snapshot];
- if (newStack.length > historyLimit) {
- newStack.shift();
- }
- return { ...currentState, undoStack: newStack, redoStack: [] };
- };
+ const pushUndoLocal = pushUndo;
const currentLine = (r: number): string => state.lines[r] ?? '';
const currentLineLen = (r: number): number => cpLen(currentLine(r));
@@ -445,7 +788,7 @@ export function textBufferReducer(
case 'set_text': {
let nextState = state;
if (action.pushToUndo !== false) {
- nextState = pushUndo(state);
+ nextState = pushUndoLocal(state);
}
const newContentLines = action.payload
.replace(/\r\n?/g, '\n')
@@ -462,7 +805,7 @@ export function textBufferReducer(
}
case 'insert': {
- const nextState = pushUndo(state);
+ const nextState = pushUndoLocal(state);
const newLines = [...nextState.lines];
let newCursorRow = nextState.cursorRow;
let newCursorCol = nextState.cursorCol;
@@ -504,7 +847,7 @@ export function textBufferReducer(
}
case 'backspace': {
- const nextState = pushUndo(state);
+ const nextState = pushUndoLocal(state);
const newLines = [...nextState.lines];
let newCursorRow = nextState.cursorRow;
let newCursorCol = nextState.cursorCol;
@@ -700,14 +1043,14 @@ export function textBufferReducer(
const { cursorRow, cursorCol, lines } = state;
const lineContent = currentLine(cursorRow);
if (cursorCol < currentLineLen(cursorRow)) {
- const nextState = pushUndo(state);
+ const nextState = pushUndoLocal(state);
const newLines = [...nextState.lines];
newLines[cursorRow] =
cpSlice(lineContent, 0, cursorCol) +
cpSlice(lineContent, cursorCol + 1);
return { ...nextState, lines: newLines, preferredCol: null };
} else if (cursorRow < lines.length - 1) {
- const nextState = pushUndo(state);
+ const nextState = pushUndoLocal(state);
const nextLineContent = currentLine(cursorRow + 1);
const newLines = [...nextState.lines];
newLines[cursorRow] = lineContent + nextLineContent;
@@ -722,7 +1065,7 @@ export function textBufferReducer(
if (cursorCol === 0 && cursorRow === 0) return state;
if (cursorCol === 0) {
// Act as a backspace
- const nextState = pushUndo(state);
+ const nextState = pushUndoLocal(state);
const prevLineContent = currentLine(cursorRow - 1);
const currentLineContentVal = currentLine(cursorRow);
const newCol = cpLen(prevLineContent);
@@ -737,7 +1080,7 @@ export function textBufferReducer(
preferredCol: null,
};
}
- const nextState = pushUndo(state);
+ const nextState = pushUndoLocal(state);
const lineContent = currentLine(cursorRow);
const arr = toCodePoints(lineContent);
let start = cursorCol;
@@ -773,14 +1116,14 @@ export function textBufferReducer(
return state;
if (cursorCol >= arr.length) {
// Act as a delete
- const nextState = pushUndo(state);
+ const nextState = pushUndoLocal(state);
const nextLineContent = currentLine(cursorRow + 1);
const newLines = [...nextState.lines];
newLines[cursorRow] = lineContent + nextLineContent;
newLines.splice(cursorRow + 1, 1);
return { ...nextState, lines: newLines, preferredCol: null };
}
- const nextState = pushUndo(state);
+ const nextState = pushUndoLocal(state);
let end = cursorCol;
while (end < arr.length && !isWordChar(arr[end])) end++;
while (end < arr.length && isWordChar(arr[end])) end++;
@@ -794,13 +1137,13 @@ export function textBufferReducer(
const { cursorRow, cursorCol, lines } = state;
const lineContent = currentLine(cursorRow);
if (cursorCol < currentLineLen(cursorRow)) {
- const nextState = pushUndo(state);
+ const nextState = pushUndoLocal(state);
const newLines = [...nextState.lines];
newLines[cursorRow] = cpSlice(lineContent, 0, cursorCol);
return { ...nextState, lines: newLines };
} else if (cursorRow < lines.length - 1) {
// Act as a delete
- const nextState = pushUndo(state);
+ const nextState = pushUndoLocal(state);
const nextLineContent = currentLine(cursorRow + 1);
const newLines = [...nextState.lines];
newLines[cursorRow] = lineContent + nextLineContent;
@@ -813,7 +1156,7 @@ export function textBufferReducer(
case 'kill_line_left': {
const { cursorRow, cursorCol } = state;
if (cursorCol > 0) {
- const nextState = pushUndo(state);
+ const nextState = pushUndoLocal(state);
const lineContent = currentLine(cursorRow);
const newLines = [...nextState.lines];
newLines[cursorRow] = cpSlice(lineContent, cursorCol);
@@ -863,66 +1206,15 @@ export function textBufferReducer(
case 'replace_range': {
const { startRow, startCol, endRow, endCol, text } = action.payload;
- if (
- startRow > endRow ||
- (startRow === endRow && startCol > endCol) ||
- startRow < 0 ||
- startCol < 0 ||
- endRow >= state.lines.length ||
- (endRow < state.lines.length && endCol > currentLineLen(endRow))
- ) {
- return state; // Invalid range
- }
-
- const nextState = pushUndo(state);
- const newLines = [...nextState.lines];
-
- const sCol = clamp(startCol, 0, currentLineLen(startRow));
- const eCol = clamp(endCol, 0, currentLineLen(endRow));
-
- const prefix = cpSlice(currentLine(startRow), 0, sCol);
- const suffix = cpSlice(currentLine(endRow), eCol);
-
- const normalisedReplacement = text
- .replace(/\r\n/g, '\n')
- .replace(/\r/g, '\n');
- const replacementParts = normalisedReplacement.split('\n');
-
- // Replace the content
- if (startRow === endRow) {
- newLines[startRow] = prefix + normalisedReplacement + suffix;
- } else {
- const firstLine = prefix + replacementParts[0];
- if (replacementParts.length === 1) {
- // Single line of replacement text, but spanning multiple original lines
- newLines.splice(startRow, endRow - startRow + 1, firstLine + suffix);
- } else {
- // Multi-line replacement text
- const lastLine =
- replacementParts[replacementParts.length - 1] + suffix;
- const middleLines = replacementParts.slice(1, -1);
- newLines.splice(
- startRow,
- endRow - startRow + 1,
- firstLine,
- ...middleLines,
- lastLine,
- );
- }
- }
-
- const finalCursorRow = startRow + replacementParts.length - 1;
- const finalCursorCol =
- (replacementParts.length > 1 ? 0 : sCol) +
- cpLen(replacementParts[replacementParts.length - 1]);
-
- return {
- ...nextState,
- lines: newLines,
- cursorRow: finalCursorRow,
- cursorCol: finalCursorCol,
- preferredCol: null,
- };
+ const nextState = pushUndoLocal(state);
+ return replaceRangeInternal(
+ nextState,
+ startRow,
+ startCol,
+ endRow,
+ endCol,
+ text,
+ );
}
case 'move_to_offset': {
@@ -940,9 +1232,44 @@ export function textBufferReducer(
}
case 'create_undo_snapshot': {
- return pushUndo(state);
+ return pushUndoLocal(state);
}
+ // Vim-specific operations
+ case 'vim_delete_word_forward':
+ case 'vim_delete_word_backward':
+ case 'vim_delete_word_end':
+ case 'vim_change_word_forward':
+ case 'vim_change_word_backward':
+ case 'vim_change_word_end':
+ case 'vim_delete_line':
+ case 'vim_change_line':
+ case 'vim_delete_to_end_of_line':
+ case 'vim_change_to_end_of_line':
+ case 'vim_change_movement':
+ case 'vim_move_left':
+ case 'vim_move_right':
+ case 'vim_move_up':
+ case 'vim_move_down':
+ case 'vim_move_word_forward':
+ case 'vim_move_word_backward':
+ case 'vim_move_word_end':
+ case 'vim_delete_char':
+ case 'vim_insert_at_cursor':
+ case 'vim_append_at_cursor':
+ case 'vim_open_line_below':
+ case 'vim_open_line_above':
+ case 'vim_append_at_line_end':
+ case 'vim_insert_at_line_start':
+ case 'vim_move_to_line_start':
+ case 'vim_move_to_line_end':
+ case 'vim_move_to_first_nonwhitespace':
+ case 'vim_move_to_first_line':
+ case 'vim_move_to_last_line':
+ case 'vim_move_to_line':
+ case 'vim_escape_insert_mode':
+ return handleVimAction(state, action as VimAction);
+
default: {
const exhaustiveCheck: never = action;
console.error(`Unknown action encountered: ${exhaustiveCheck}`);
@@ -1110,6 +1437,139 @@ export function useTextBuffer({
dispatch({ type: 'kill_line_left' });
}, []);
+ // Vim-specific operations
+ const vimDeleteWordForward = useCallback((count: number): void => {
+ dispatch({ type: 'vim_delete_word_forward', payload: { count } });
+ }, []);
+
+ const vimDeleteWordBackward = useCallback((count: number): void => {
+ dispatch({ type: 'vim_delete_word_backward', payload: { count } });
+ }, []);
+
+ const vimDeleteWordEnd = useCallback((count: number): void => {
+ dispatch({ type: 'vim_delete_word_end', payload: { count } });
+ }, []);
+
+ const vimChangeWordForward = useCallback((count: number): void => {
+ dispatch({ type: 'vim_change_word_forward', payload: { count } });
+ }, []);
+
+ const vimChangeWordBackward = useCallback((count: number): void => {
+ dispatch({ type: 'vim_change_word_backward', payload: { count } });
+ }, []);
+
+ const vimChangeWordEnd = useCallback((count: number): void => {
+ dispatch({ type: 'vim_change_word_end', payload: { count } });
+ }, []);
+
+ const vimDeleteLine = useCallback((count: number): void => {
+ dispatch({ type: 'vim_delete_line', payload: { count } });
+ }, []);
+
+ const vimChangeLine = useCallback((count: number): void => {
+ dispatch({ type: 'vim_change_line', payload: { count } });
+ }, []);
+
+ const vimDeleteToEndOfLine = useCallback((): void => {
+ dispatch({ type: 'vim_delete_to_end_of_line' });
+ }, []);
+
+ const vimChangeToEndOfLine = useCallback((): void => {
+ dispatch({ type: 'vim_change_to_end_of_line' });
+ }, []);
+
+ const vimChangeMovement = useCallback(
+ (movement: 'h' | 'j' | 'k' | 'l', count: number): void => {
+ dispatch({ type: 'vim_change_movement', payload: { movement, count } });
+ },
+ [],
+ );
+
+ // New vim navigation and operation methods
+ const vimMoveLeft = useCallback((count: number): void => {
+ dispatch({ type: 'vim_move_left', payload: { count } });
+ }, []);
+
+ const vimMoveRight = useCallback((count: number): void => {
+ dispatch({ type: 'vim_move_right', payload: { count } });
+ }, []);
+
+ const vimMoveUp = useCallback((count: number): void => {
+ dispatch({ type: 'vim_move_up', payload: { count } });
+ }, []);
+
+ const vimMoveDown = useCallback((count: number): void => {
+ dispatch({ type: 'vim_move_down', payload: { count } });
+ }, []);
+
+ const vimMoveWordForward = useCallback((count: number): void => {
+ dispatch({ type: 'vim_move_word_forward', payload: { count } });
+ }, []);
+
+ const vimMoveWordBackward = useCallback((count: number): void => {
+ dispatch({ type: 'vim_move_word_backward', payload: { count } });
+ }, []);
+
+ const vimMoveWordEnd = useCallback((count: number): void => {
+ dispatch({ type: 'vim_move_word_end', payload: { count } });
+ }, []);
+
+ const vimDeleteChar = useCallback((count: number): void => {
+ dispatch({ type: 'vim_delete_char', payload: { count } });
+ }, []);
+
+ const vimInsertAtCursor = useCallback((): void => {
+ dispatch({ type: 'vim_insert_at_cursor' });
+ }, []);
+
+ const vimAppendAtCursor = useCallback((): void => {
+ dispatch({ type: 'vim_append_at_cursor' });
+ }, []);
+
+ const vimOpenLineBelow = useCallback((): void => {
+ dispatch({ type: 'vim_open_line_below' });
+ }, []);
+
+ const vimOpenLineAbove = useCallback((): void => {
+ dispatch({ type: 'vim_open_line_above' });
+ }, []);
+
+ const vimAppendAtLineEnd = useCallback((): void => {
+ dispatch({ type: 'vim_append_at_line_end' });
+ }, []);
+
+ const vimInsertAtLineStart = useCallback((): void => {
+ dispatch({ type: 'vim_insert_at_line_start' });
+ }, []);
+
+ const vimMoveToLineStart = useCallback((): void => {
+ dispatch({ type: 'vim_move_to_line_start' });
+ }, []);
+
+ const vimMoveToLineEnd = useCallback((): void => {
+ dispatch({ type: 'vim_move_to_line_end' });
+ }, []);
+
+ const vimMoveToFirstNonWhitespace = useCallback((): void => {
+ dispatch({ type: 'vim_move_to_first_nonwhitespace' });
+ }, []);
+
+ const vimMoveToFirstLine = useCallback((): void => {
+ dispatch({ type: 'vim_move_to_first_line' });
+ }, []);
+
+ const vimMoveToLastLine = useCallback((): void => {
+ dispatch({ type: 'vim_move_to_last_line' });
+ }, []);
+
+ const vimMoveToLine = useCallback((lineNumber: number): void => {
+ dispatch({ type: 'vim_move_to_line', payload: { lineNumber } });
+ }, []);
+
+ const vimEscapeInsertMode = useCallback((): void => {
+ dispatch({ type: 'vim_escape_insert_mode' });
+ }, []);
+
const openInExternalEditor = useCallback(
async (opts: { editor?: string } = {}): Promise<void> => {
const editor =
@@ -1273,6 +1733,39 @@ export function useTextBuffer({
killLineLeft,
handleInput,
openInExternalEditor,
+ // Vim-specific operations
+ vimDeleteWordForward,
+ vimDeleteWordBackward,
+ vimDeleteWordEnd,
+ vimChangeWordForward,
+ vimChangeWordBackward,
+ vimChangeWordEnd,
+ vimDeleteLine,
+ vimChangeLine,
+ vimDeleteToEndOfLine,
+ vimChangeToEndOfLine,
+ vimChangeMovement,
+ vimMoveLeft,
+ vimMoveRight,
+ vimMoveUp,
+ vimMoveDown,
+ vimMoveWordForward,
+ vimMoveWordBackward,
+ vimMoveWordEnd,
+ vimDeleteChar,
+ vimInsertAtCursor,
+ vimAppendAtCursor,
+ vimOpenLineBelow,
+ vimOpenLineAbove,
+ vimAppendAtLineEnd,
+ vimInsertAtLineStart,
+ vimMoveToLineStart,
+ vimMoveToLineEnd,
+ vimMoveToFirstNonWhitespace,
+ vimMoveToFirstLine,
+ vimMoveToLastLine,
+ vimMoveToLine,
+ vimEscapeInsertMode,
};
return returnValue;
}
@@ -1387,4 +1880,134 @@ export interface TextBuffer {
replacementText: string,
) => void;
moveToOffset(offset: number): void;
+
+ // Vim-specific operations
+ /**
+ * Delete N words forward from cursor position (vim 'dw' command)
+ */
+ vimDeleteWordForward: (count: number) => void;
+ /**
+ * Delete N words backward from cursor position (vim 'db' command)
+ */
+ vimDeleteWordBackward: (count: number) => void;
+ /**
+ * Delete to end of N words from cursor position (vim 'de' command)
+ */
+ vimDeleteWordEnd: (count: number) => void;
+ /**
+ * Change N words forward from cursor position (vim 'cw' command)
+ */
+ vimChangeWordForward: (count: number) => void;
+ /**
+ * Change N words backward from cursor position (vim 'cb' command)
+ */
+ vimChangeWordBackward: (count: number) => void;
+ /**
+ * Change to end of N words from cursor position (vim 'ce' command)
+ */
+ vimChangeWordEnd: (count: number) => void;
+ /**
+ * Delete N lines from cursor position (vim 'dd' command)
+ */
+ vimDeleteLine: (count: number) => void;
+ /**
+ * Change N lines from cursor position (vim 'cc' command)
+ */
+ vimChangeLine: (count: number) => void;
+ /**
+ * Delete from cursor to end of line (vim 'D' command)
+ */
+ vimDeleteToEndOfLine: () => void;
+ /**
+ * Change from cursor to end of line (vim 'C' command)
+ */
+ vimChangeToEndOfLine: () => void;
+ /**
+ * Change movement operations (vim 'ch', 'cj', 'ck', 'cl' commands)
+ */
+ vimChangeMovement: (movement: 'h' | 'j' | 'k' | 'l', count: number) => void;
+ /**
+ * Move cursor left N times (vim 'h' command)
+ */
+ vimMoveLeft: (count: number) => void;
+ /**
+ * Move cursor right N times (vim 'l' command)
+ */
+ vimMoveRight: (count: number) => void;
+ /**
+ * Move cursor up N times (vim 'k' command)
+ */
+ vimMoveUp: (count: number) => void;
+ /**
+ * Move cursor down N times (vim 'j' command)
+ */
+ vimMoveDown: (count: number) => void;
+ /**
+ * Move cursor forward N words (vim 'w' command)
+ */
+ vimMoveWordForward: (count: number) => void;
+ /**
+ * Move cursor backward N words (vim 'b' command)
+ */
+ vimMoveWordBackward: (count: number) => void;
+ /**
+ * Move cursor to end of Nth word (vim 'e' command)
+ */
+ vimMoveWordEnd: (count: number) => void;
+ /**
+ * Delete N characters at cursor (vim 'x' command)
+ */
+ vimDeleteChar: (count: number) => void;
+ /**
+ * Enter insert mode at cursor (vim 'i' command)
+ */
+ vimInsertAtCursor: () => void;
+ /**
+ * Enter insert mode after cursor (vim 'a' command)
+ */
+ vimAppendAtCursor: () => void;
+ /**
+ * Open new line below and enter insert mode (vim 'o' command)
+ */
+ vimOpenLineBelow: () => void;
+ /**
+ * Open new line above and enter insert mode (vim 'O' command)
+ */
+ vimOpenLineAbove: () => void;
+ /**
+ * Move to end of line and enter insert mode (vim 'A' command)
+ */
+ vimAppendAtLineEnd: () => void;
+ /**
+ * Move to first non-whitespace and enter insert mode (vim 'I' command)
+ */
+ vimInsertAtLineStart: () => void;
+ /**
+ * Move cursor to beginning of line (vim '0' command)
+ */
+ vimMoveToLineStart: () => void;
+ /**
+ * Move cursor to end of line (vim '$' command)
+ */
+ vimMoveToLineEnd: () => void;
+ /**
+ * Move cursor to first non-whitespace character (vim '^' command)
+ */
+ vimMoveToFirstNonWhitespace: () => void;
+ /**
+ * Move cursor to first line (vim 'gg' command)
+ */
+ vimMoveToFirstLine: () => void;
+ /**
+ * Move cursor to last line (vim 'G' command)
+ */
+ vimMoveToLastLine: () => void;
+ /**
+ * Move cursor to specific line number (vim '[N]G' command)
+ */
+ vimMoveToLine: (lineNumber: number) => void;
+ /**
+ * Handle escape from insert mode (moves cursor left if not at line start)
+ */
+ vimEscapeInsertMode: () => void;
}
diff --git a/packages/cli/src/ui/components/shared/vim-buffer-actions.test.ts b/packages/cli/src/ui/components/shared/vim-buffer-actions.test.ts
new file mode 100644
index 00000000..f268bb1e
--- /dev/null
+++ b/packages/cli/src/ui/components/shared/vim-buffer-actions.test.ts
@@ -0,0 +1,796 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect } from 'vitest';
+import { handleVimAction } from './vim-buffer-actions.js';
+import type { TextBufferState } from './text-buffer.js';
+
+// Helper to create test state
+const createTestState = (
+ lines: string[] = ['hello world'],
+ cursorRow = 0,
+ cursorCol = 0,
+): TextBufferState => ({
+ lines,
+ cursorRow,
+ cursorCol,
+ preferredCol: null,
+ undoStack: [],
+ redoStack: [],
+ clipboard: null,
+ selectionAnchor: null,
+ viewportWidth: 80,
+});
+
+describe('vim-buffer-actions', () => {
+ describe('Movement commands', () => {
+ describe('vim_move_left', () => {
+ it('should move cursor left by count', () => {
+ const state = createTestState(['hello world'], 0, 5);
+ const action = {
+ type: 'vim_move_left' as const,
+ payload: { count: 3 },
+ };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.cursorCol).toBe(2);
+ expect(result.preferredCol).toBeNull();
+ });
+
+ it('should not move past beginning of line', () => {
+ const state = createTestState(['hello'], 0, 2);
+ const action = {
+ type: 'vim_move_left' as const,
+ payload: { count: 5 },
+ };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.cursorCol).toBe(0);
+ });
+
+ it('should wrap to previous line when at beginning', () => {
+ const state = createTestState(['line1', 'line2'], 1, 0);
+ const action = {
+ type: 'vim_move_left' as const,
+ payload: { count: 1 },
+ };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.cursorRow).toBe(0);
+ expect(result.cursorCol).toBe(4); // On last character '1' of 'line1'
+ });
+
+ it('should handle multiple line wrapping', () => {
+ const state = createTestState(['abc', 'def', 'ghi'], 2, 0);
+ const action = {
+ type: 'vim_move_left' as const,
+ payload: { count: 5 },
+ };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.cursorRow).toBe(0);
+ expect(result.cursorCol).toBe(1); // On 'b' after 5 left movements
+ });
+
+ it('should correctly handle h/l movement between lines', () => {
+ // Start at end of first line at 'd' (position 10)
+ let state = createTestState(['hello world', 'foo bar'], 0, 10);
+
+ // Move right - should go to beginning of next line
+ state = handleVimAction(state, {
+ type: 'vim_move_right' as const,
+ payload: { count: 1 },
+ });
+ expect(state.cursorRow).toBe(1);
+ expect(state.cursorCol).toBe(0); // Should be on 'f'
+
+ // Move left - should go back to end of previous line on 'd'
+ state = handleVimAction(state, {
+ type: 'vim_move_left' as const,
+ payload: { count: 1 },
+ });
+ expect(state.cursorRow).toBe(0);
+ expect(state.cursorCol).toBe(10); // Should be on 'd', not past it
+ });
+ });
+
+ describe('vim_move_right', () => {
+ it('should move cursor right by count', () => {
+ const state = createTestState(['hello world'], 0, 2);
+ const action = {
+ type: 'vim_move_right' as const,
+ payload: { count: 3 },
+ };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.cursorCol).toBe(5);
+ });
+
+ it('should not move past last character of line', () => {
+ const state = createTestState(['hello'], 0, 3);
+ const action = {
+ type: 'vim_move_right' as const,
+ payload: { count: 5 },
+ };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.cursorCol).toBe(4); // Last character of 'hello'
+ });
+
+ it('should wrap to next line when at end', () => {
+ const state = createTestState(['line1', 'line2'], 0, 4); // At end of 'line1'
+ const action = {
+ type: 'vim_move_right' as const,
+ payload: { count: 1 },
+ };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.cursorRow).toBe(1);
+ expect(result.cursorCol).toBe(0);
+ });
+ });
+
+ describe('vim_move_up', () => {
+ it('should move cursor up by count', () => {
+ const state = createTestState(['line1', 'line2', 'line3'], 2, 3);
+ const action = { type: 'vim_move_up' as const, payload: { count: 2 } };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.cursorRow).toBe(0);
+ expect(result.cursorCol).toBe(3);
+ });
+
+ it('should not move past first line', () => {
+ const state = createTestState(['line1', 'line2'], 1, 3);
+ const action = { type: 'vim_move_up' as const, payload: { count: 5 } };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.cursorRow).toBe(0);
+ });
+
+ it('should adjust column for shorter lines', () => {
+ const state = createTestState(['short', 'very long line'], 1, 10);
+ const action = { type: 'vim_move_up' as const, payload: { count: 1 } };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.cursorRow).toBe(0);
+ expect(result.cursorCol).toBe(5); // End of 'short'
+ });
+ });
+
+ describe('vim_move_down', () => {
+ it('should move cursor down by count', () => {
+ const state = createTestState(['line1', 'line2', 'line3'], 0, 2);
+ const action = {
+ type: 'vim_move_down' as const,
+ payload: { count: 2 },
+ };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.cursorRow).toBe(2);
+ expect(result.cursorCol).toBe(2);
+ });
+
+ it('should not move past last line', () => {
+ const state = createTestState(['line1', 'line2'], 0, 2);
+ const action = {
+ type: 'vim_move_down' as const,
+ payload: { count: 5 },
+ };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.cursorRow).toBe(1);
+ });
+ });
+
+ describe('vim_move_word_forward', () => {
+ it('should move to start of next word', () => {
+ const state = createTestState(['hello world test'], 0, 0);
+ const action = {
+ type: 'vim_move_word_forward' as const,
+ payload: { count: 1 },
+ };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.cursorCol).toBe(6); // Start of 'world'
+ });
+
+ it('should handle multiple words', () => {
+ const state = createTestState(['hello world test'], 0, 0);
+ const action = {
+ type: 'vim_move_word_forward' as const,
+ payload: { count: 2 },
+ };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.cursorCol).toBe(12); // Start of 'test'
+ });
+
+ it('should handle punctuation correctly', () => {
+ const state = createTestState(['hello, world!'], 0, 0);
+ const action = {
+ type: 'vim_move_word_forward' as const,
+ payload: { count: 1 },
+ };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.cursorCol).toBe(5); // Start of ','
+ });
+ });
+
+ describe('vim_move_word_backward', () => {
+ it('should move to start of previous word', () => {
+ const state = createTestState(['hello world test'], 0, 12);
+ const action = {
+ type: 'vim_move_word_backward' as const,
+ payload: { count: 1 },
+ };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.cursorCol).toBe(6); // Start of 'world'
+ });
+
+ it('should handle multiple words', () => {
+ const state = createTestState(['hello world test'], 0, 12);
+ const action = {
+ type: 'vim_move_word_backward' as const,
+ payload: { count: 2 },
+ };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.cursorCol).toBe(0); // Start of 'hello'
+ });
+ });
+
+ describe('vim_move_word_end', () => {
+ it('should move to end of current word', () => {
+ const state = createTestState(['hello world'], 0, 0);
+ const action = {
+ type: 'vim_move_word_end' as const,
+ payload: { count: 1 },
+ };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.cursorCol).toBe(4); // End of 'hello'
+ });
+
+ it('should move to end of next word if already at word end', () => {
+ const state = createTestState(['hello world'], 0, 4);
+ const action = {
+ type: 'vim_move_word_end' as const,
+ payload: { count: 1 },
+ };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.cursorCol).toBe(10); // End of 'world'
+ });
+ });
+
+ describe('Position commands', () => {
+ it('vim_move_to_line_start should move to column 0', () => {
+ const state = createTestState(['hello world'], 0, 5);
+ const action = { type: 'vim_move_to_line_start' as const };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.cursorCol).toBe(0);
+ });
+
+ it('vim_move_to_line_end should move to last character', () => {
+ const state = createTestState(['hello world'], 0, 0);
+ const action = { type: 'vim_move_to_line_end' as const };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.cursorCol).toBe(10); // Last character of 'hello world'
+ });
+
+ it('vim_move_to_first_nonwhitespace should skip leading whitespace', () => {
+ const state = createTestState([' hello world'], 0, 0);
+ const action = { type: 'vim_move_to_first_nonwhitespace' as const };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.cursorCol).toBe(3); // Position of 'h'
+ });
+
+ it('vim_move_to_first_line should move to row 0', () => {
+ const state = createTestState(['line1', 'line2', 'line3'], 2, 5);
+ const action = { type: 'vim_move_to_first_line' as const };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.cursorRow).toBe(0);
+ expect(result.cursorCol).toBe(0);
+ });
+
+ it('vim_move_to_last_line should move to last row', () => {
+ const state = createTestState(['line1', 'line2', 'line3'], 0, 5);
+ const action = { type: 'vim_move_to_last_line' as const };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.cursorRow).toBe(2);
+ expect(result.cursorCol).toBe(0);
+ });
+
+ it('vim_move_to_line should move to specific line', () => {
+ const state = createTestState(['line1', 'line2', 'line3'], 0, 5);
+ const action = {
+ type: 'vim_move_to_line' as const,
+ payload: { lineNumber: 2 },
+ };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.cursorRow).toBe(1); // 0-indexed
+ expect(result.cursorCol).toBe(0);
+ });
+
+ it('vim_move_to_line should clamp to valid range', () => {
+ const state = createTestState(['line1', 'line2'], 0, 0);
+ const action = {
+ type: 'vim_move_to_line' as const,
+ payload: { lineNumber: 10 },
+ };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.cursorRow).toBe(1); // Last line
+ });
+ });
+ });
+
+ describe('Edit commands', () => {
+ describe('vim_delete_char', () => {
+ it('should delete single character', () => {
+ const state = createTestState(['hello'], 0, 1);
+ const action = {
+ type: 'vim_delete_char' as const,
+ payload: { count: 1 },
+ };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.lines[0]).toBe('hllo');
+ expect(result.cursorCol).toBe(1);
+ });
+
+ it('should delete multiple characters', () => {
+ const state = createTestState(['hello'], 0, 1);
+ const action = {
+ type: 'vim_delete_char' as const,
+ payload: { count: 3 },
+ };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.lines[0]).toBe('ho');
+ expect(result.cursorCol).toBe(1);
+ });
+
+ it('should not delete past end of line', () => {
+ const state = createTestState(['hello'], 0, 3);
+ const action = {
+ type: 'vim_delete_char' as const,
+ payload: { count: 5 },
+ };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.lines[0]).toBe('hel');
+ expect(result.cursorCol).toBe(3);
+ });
+
+ it('should do nothing at end of line', () => {
+ const state = createTestState(['hello'], 0, 5);
+ const action = {
+ type: 'vim_delete_char' as const,
+ payload: { count: 1 },
+ };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.lines[0]).toBe('hello');
+ expect(result.cursorCol).toBe(5);
+ });
+ });
+
+ describe('vim_delete_word_forward', () => {
+ it('should delete from cursor to next word start', () => {
+ const state = createTestState(['hello world test'], 0, 0);
+ const action = {
+ type: 'vim_delete_word_forward' as const,
+ payload: { count: 1 },
+ };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.lines[0]).toBe('world test');
+ expect(result.cursorCol).toBe(0);
+ });
+
+ it('should delete multiple words', () => {
+ const state = createTestState(['hello world test'], 0, 0);
+ const action = {
+ type: 'vim_delete_word_forward' as const,
+ payload: { count: 2 },
+ };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.lines[0]).toBe('test');
+ expect(result.cursorCol).toBe(0);
+ });
+
+ it('should delete to end if no more words', () => {
+ const state = createTestState(['hello world'], 0, 6);
+ const action = {
+ type: 'vim_delete_word_forward' as const,
+ payload: { count: 2 },
+ };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.lines[0]).toBe('hello ');
+ expect(result.cursorCol).toBe(6);
+ });
+ });
+
+ describe('vim_delete_word_backward', () => {
+ it('should delete from cursor to previous word start', () => {
+ const state = createTestState(['hello world test'], 0, 12);
+ const action = {
+ type: 'vim_delete_word_backward' as const,
+ payload: { count: 1 },
+ };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.lines[0]).toBe('hello test');
+ expect(result.cursorCol).toBe(6);
+ });
+
+ it('should delete multiple words backward', () => {
+ const state = createTestState(['hello world test'], 0, 12);
+ const action = {
+ type: 'vim_delete_word_backward' as const,
+ payload: { count: 2 },
+ };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.lines[0]).toBe('test');
+ expect(result.cursorCol).toBe(0);
+ });
+ });
+
+ describe('vim_delete_line', () => {
+ it('should delete current line', () => {
+ const state = createTestState(['line1', 'line2', 'line3'], 1, 2);
+ const action = {
+ type: 'vim_delete_line' as const,
+ payload: { count: 1 },
+ };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.lines).toEqual(['line1', 'line3']);
+ expect(result.cursorRow).toBe(1);
+ expect(result.cursorCol).toBe(0);
+ });
+
+ it('should delete multiple lines', () => {
+ const state = createTestState(['line1', 'line2', 'line3'], 0, 2);
+ const action = {
+ type: 'vim_delete_line' as const,
+ payload: { count: 2 },
+ };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.lines).toEqual(['line3']);
+ expect(result.cursorRow).toBe(0);
+ expect(result.cursorCol).toBe(0);
+ });
+
+ it('should leave empty line when deleting all lines', () => {
+ const state = createTestState(['only line'], 0, 0);
+ const action = {
+ type: 'vim_delete_line' as const,
+ payload: { count: 1 },
+ };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.lines).toEqual(['']);
+ expect(result.cursorRow).toBe(0);
+ expect(result.cursorCol).toBe(0);
+ });
+ });
+
+ describe('vim_delete_to_end_of_line', () => {
+ it('should delete from cursor to end of line', () => {
+ const state = createTestState(['hello world'], 0, 5);
+ const action = { type: 'vim_delete_to_end_of_line' as const };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.lines[0]).toBe('hello');
+ expect(result.cursorCol).toBe(5);
+ });
+
+ it('should do nothing at end of line', () => {
+ const state = createTestState(['hello'], 0, 5);
+ const action = { type: 'vim_delete_to_end_of_line' as const };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.lines[0]).toBe('hello');
+ });
+ });
+ });
+
+ describe('Insert mode commands', () => {
+ describe('vim_insert_at_cursor', () => {
+ it('should not change cursor position', () => {
+ const state = createTestState(['hello'], 0, 2);
+ const action = { type: 'vim_insert_at_cursor' as const };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.cursorRow).toBe(0);
+ expect(result.cursorCol).toBe(2);
+ });
+ });
+
+ describe('vim_append_at_cursor', () => {
+ it('should move cursor right by one', () => {
+ const state = createTestState(['hello'], 0, 2);
+ const action = { type: 'vim_append_at_cursor' as const };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.cursorCol).toBe(3);
+ });
+
+ it('should not move past end of line', () => {
+ const state = createTestState(['hello'], 0, 5);
+ const action = { type: 'vim_append_at_cursor' as const };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.cursorCol).toBe(5);
+ });
+ });
+
+ describe('vim_append_at_line_end', () => {
+ it('should move cursor to end of line', () => {
+ const state = createTestState(['hello world'], 0, 3);
+ const action = { type: 'vim_append_at_line_end' as const };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.cursorCol).toBe(11);
+ });
+ });
+
+ describe('vim_insert_at_line_start', () => {
+ it('should move to first non-whitespace character', () => {
+ const state = createTestState([' hello world'], 0, 5);
+ const action = { type: 'vim_insert_at_line_start' as const };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.cursorCol).toBe(2);
+ });
+
+ it('should move to column 0 for line with only whitespace', () => {
+ const state = createTestState([' '], 0, 1);
+ const action = { type: 'vim_insert_at_line_start' as const };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.cursorCol).toBe(3);
+ });
+ });
+
+ describe('vim_open_line_below', () => {
+ it('should insert newline at end of current line', () => {
+ const state = createTestState(['hello world'], 0, 5);
+ const action = { type: 'vim_open_line_below' as const };
+
+ const result = handleVimAction(state, action);
+
+ // The implementation inserts newline at end of current line and cursor moves to column 0
+ expect(result.lines[0]).toBe('hello world\n');
+ expect(result.cursorRow).toBe(0);
+ expect(result.cursorCol).toBe(0); // Cursor position after replaceRangeInternal
+ });
+ });
+
+ describe('vim_open_line_above', () => {
+ it('should insert newline before current line', () => {
+ const state = createTestState(['hello', 'world'], 1, 2);
+ const action = { type: 'vim_open_line_above' as const };
+
+ const result = handleVimAction(state, action);
+
+ // The implementation inserts newline at beginning of current line
+ expect(result.lines).toEqual(['hello', '\nworld']);
+ expect(result.cursorRow).toBe(1);
+ expect(result.cursorCol).toBe(0);
+ });
+ });
+
+ describe('vim_escape_insert_mode', () => {
+ it('should move cursor left', () => {
+ const state = createTestState(['hello'], 0, 3);
+ const action = { type: 'vim_escape_insert_mode' as const };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.cursorCol).toBe(2);
+ });
+
+ it('should not move past beginning of line', () => {
+ const state = createTestState(['hello'], 0, 0);
+ const action = { type: 'vim_escape_insert_mode' as const };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.cursorCol).toBe(0);
+ });
+ });
+ });
+
+ describe('Change commands', () => {
+ describe('vim_change_word_forward', () => {
+ it('should delete from cursor to next word start', () => {
+ const state = createTestState(['hello world test'], 0, 0);
+ const action = {
+ type: 'vim_change_word_forward' as const,
+ payload: { count: 1 },
+ };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.lines[0]).toBe('world test');
+ expect(result.cursorCol).toBe(0);
+ });
+ });
+
+ describe('vim_change_line', () => {
+ it('should delete entire line content', () => {
+ const state = createTestState(['hello world'], 0, 5);
+ const action = {
+ type: 'vim_change_line' as const,
+ payload: { count: 1 },
+ };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.lines[0]).toBe('');
+ expect(result.cursorCol).toBe(0);
+ });
+ });
+
+ describe('vim_change_movement', () => {
+ it('should change characters to the left', () => {
+ const state = createTestState(['hello world'], 0, 5);
+ const action = {
+ type: 'vim_change_movement' as const,
+ payload: { movement: 'h', count: 2 },
+ };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.lines[0]).toBe('hel world');
+ expect(result.cursorCol).toBe(3);
+ });
+
+ it('should change characters to the right', () => {
+ const state = createTestState(['hello world'], 0, 5);
+ const action = {
+ type: 'vim_change_movement' as const,
+ payload: { movement: 'l', count: 3 },
+ };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.lines[0]).toBe('hellorld'); // Deletes ' wo' (3 chars to the right)
+ expect(result.cursorCol).toBe(5);
+ });
+
+ it('should change multiple lines down', () => {
+ const state = createTestState(['line1', 'line2', 'line3'], 0, 2);
+ const action = {
+ type: 'vim_change_movement' as const,
+ payload: { movement: 'j', count: 2 },
+ };
+
+ const result = handleVimAction(state, action);
+
+ // The movement 'j' with count 2 changes 2 lines starting from cursor row
+ // Since we're at cursor position 2, it changes lines starting from current row
+ expect(result.lines).toEqual(['line1', 'line2', 'line3']); // No change because count > available lines
+ expect(result.cursorRow).toBe(0);
+ expect(result.cursorCol).toBe(2);
+ });
+ });
+ });
+
+ describe('Edge cases', () => {
+ it('should handle empty text', () => {
+ const state = createTestState([''], 0, 0);
+ const action = {
+ type: 'vim_move_word_forward' as const,
+ payload: { count: 1 },
+ };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.cursorRow).toBe(0);
+ expect(result.cursorCol).toBe(0);
+ });
+
+ it('should handle single character line', () => {
+ const state = createTestState(['a'], 0, 0);
+ const action = { type: 'vim_move_to_line_end' as const };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.cursorCol).toBe(0); // Should be last character position
+ });
+
+ it('should handle empty lines in multi-line text', () => {
+ const state = createTestState(['line1', '', 'line3'], 1, 0);
+ const action = {
+ type: 'vim_move_word_forward' as const,
+ payload: { count: 1 },
+ };
+
+ const result = handleVimAction(state, action);
+
+ // Should move to next line with content
+ expect(result.cursorRow).toBe(2);
+ expect(result.cursorCol).toBe(0);
+ });
+
+ it('should preserve undo stack in operations', () => {
+ const state = createTestState(['hello'], 0, 0);
+ state.undoStack = [{ lines: ['previous'], cursorRow: 0, cursorCol: 0 }];
+
+ const action = {
+ type: 'vim_delete_char' as const,
+ payload: { count: 1 },
+ };
+
+ const result = handleVimAction(state, action);
+
+ expect(result.undoStack).toHaveLength(2); // Original plus new snapshot
+ });
+ });
+});
diff --git a/packages/cli/src/ui/components/shared/vim-buffer-actions.ts b/packages/cli/src/ui/components/shared/vim-buffer-actions.ts
new file mode 100644
index 00000000..ab52e991
--- /dev/null
+++ b/packages/cli/src/ui/components/shared/vim-buffer-actions.ts
@@ -0,0 +1,887 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+ TextBufferState,
+ TextBufferAction,
+ findNextWordStart,
+ findPrevWordStart,
+ findWordEnd,
+ getOffsetFromPosition,
+ getPositionFromOffsets,
+ getLineRangeOffsets,
+ replaceRangeInternal,
+ pushUndo,
+} from './text-buffer.js';
+import { cpLen } from '../../utils/textUtils.js';
+
+export type VimAction = Extract<
+ TextBufferAction,
+ | { type: 'vim_delete_word_forward' }
+ | { type: 'vim_delete_word_backward' }
+ | { type: 'vim_delete_word_end' }
+ | { type: 'vim_change_word_forward' }
+ | { type: 'vim_change_word_backward' }
+ | { type: 'vim_change_word_end' }
+ | { type: 'vim_delete_line' }
+ | { type: 'vim_change_line' }
+ | { type: 'vim_delete_to_end_of_line' }
+ | { type: 'vim_change_to_end_of_line' }
+ | { type: 'vim_change_movement' }
+ | { type: 'vim_move_left' }
+ | { type: 'vim_move_right' }
+ | { type: 'vim_move_up' }
+ | { type: 'vim_move_down' }
+ | { type: 'vim_move_word_forward' }
+ | { type: 'vim_move_word_backward' }
+ | { type: 'vim_move_word_end' }
+ | { type: 'vim_delete_char' }
+ | { type: 'vim_insert_at_cursor' }
+ | { type: 'vim_append_at_cursor' }
+ | { type: 'vim_open_line_below' }
+ | { type: 'vim_open_line_above' }
+ | { type: 'vim_append_at_line_end' }
+ | { type: 'vim_insert_at_line_start' }
+ | { type: 'vim_move_to_line_start' }
+ | { type: 'vim_move_to_line_end' }
+ | { type: 'vim_move_to_first_nonwhitespace' }
+ | { type: 'vim_move_to_first_line' }
+ | { type: 'vim_move_to_last_line' }
+ | { type: 'vim_move_to_line' }
+ | { type: 'vim_escape_insert_mode' }
+>;
+
+export function handleVimAction(
+ state: TextBufferState,
+ action: VimAction,
+): TextBufferState {
+ const { lines, cursorRow, cursorCol } = state;
+ // Cache text join to avoid repeated calculations for word operations
+ let text: string | null = null;
+ const getText = () => text ?? (text = lines.join('\n'));
+
+ switch (action.type) {
+ case 'vim_delete_word_forward': {
+ const { count } = action.payload;
+ const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
+
+ let endOffset = currentOffset;
+ let searchOffset = currentOffset;
+
+ for (let i = 0; i < count; i++) {
+ const nextWordOffset = findNextWordStart(getText(), searchOffset);
+ if (nextWordOffset > searchOffset) {
+ searchOffset = nextWordOffset;
+ endOffset = nextWordOffset;
+ } else {
+ // If no next word, delete to end of current word
+ const wordEndOffset = findWordEnd(getText(), searchOffset);
+ endOffset = Math.min(wordEndOffset + 1, getText().length);
+ break;
+ }
+ }
+
+ if (endOffset > currentOffset) {
+ const nextState = pushUndo(state);
+ const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
+ currentOffset,
+ endOffset,
+ nextState.lines,
+ );
+ return replaceRangeInternal(
+ nextState,
+ startRow,
+ startCol,
+ endRow,
+ endCol,
+ '',
+ );
+ }
+ return state;
+ }
+
+ case 'vim_delete_word_backward': {
+ const { count } = action.payload;
+ const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
+
+ let startOffset = currentOffset;
+ let searchOffset = currentOffset;
+
+ for (let i = 0; i < count; i++) {
+ const prevWordOffset = findPrevWordStart(getText(), searchOffset);
+ if (prevWordOffset < searchOffset) {
+ searchOffset = prevWordOffset;
+ startOffset = prevWordOffset;
+ } else {
+ break;
+ }
+ }
+
+ if (startOffset < currentOffset) {
+ const nextState = pushUndo(state);
+ const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
+ startOffset,
+ currentOffset,
+ nextState.lines,
+ );
+ const newState = replaceRangeInternal(
+ nextState,
+ startRow,
+ startCol,
+ endRow,
+ endCol,
+ '',
+ );
+ // Cursor is already at the correct position after deletion
+ return newState;
+ }
+ return state;
+ }
+
+ case 'vim_delete_word_end': {
+ const { count } = action.payload;
+ const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
+
+ let offset = currentOffset;
+ let endOffset = currentOffset;
+
+ for (let i = 0; i < count; i++) {
+ const wordEndOffset = findWordEnd(getText(), offset);
+ if (wordEndOffset >= offset) {
+ endOffset = wordEndOffset + 1; // Include the character at word end
+ // For next iteration, move to start of next word
+ if (i < count - 1) {
+ const nextWordStart = findNextWordStart(
+ getText(),
+ wordEndOffset + 1,
+ );
+ offset = nextWordStart;
+ if (nextWordStart <= wordEndOffset) {
+ break; // No more words
+ }
+ }
+ } else {
+ break;
+ }
+ }
+
+ endOffset = Math.min(endOffset, getText().length);
+
+ if (endOffset > currentOffset) {
+ const nextState = pushUndo(state);
+ const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
+ currentOffset,
+ endOffset,
+ nextState.lines,
+ );
+ return replaceRangeInternal(
+ nextState,
+ startRow,
+ startCol,
+ endRow,
+ endCol,
+ '',
+ );
+ }
+ return state;
+ }
+
+ case 'vim_change_word_forward': {
+ const { count } = action.payload;
+ const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
+
+ let searchOffset = currentOffset;
+ let endOffset = currentOffset;
+
+ for (let i = 0; i < count; i++) {
+ const nextWordOffset = findNextWordStart(getText(), searchOffset);
+ if (nextWordOffset > searchOffset) {
+ searchOffset = nextWordOffset;
+ endOffset = nextWordOffset;
+ } else {
+ // If no next word, change to end of current word
+ const wordEndOffset = findWordEnd(getText(), searchOffset);
+ endOffset = Math.min(wordEndOffset + 1, getText().length);
+ break;
+ }
+ }
+
+ if (endOffset > currentOffset) {
+ const nextState = pushUndo(state);
+ const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
+ currentOffset,
+ endOffset,
+ nextState.lines,
+ );
+ return replaceRangeInternal(
+ nextState,
+ startRow,
+ startCol,
+ endRow,
+ endCol,
+ '',
+ );
+ }
+ return state;
+ }
+
+ case 'vim_change_word_backward': {
+ const { count } = action.payload;
+ const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
+
+ let startOffset = currentOffset;
+ let searchOffset = currentOffset;
+
+ for (let i = 0; i < count; i++) {
+ const prevWordOffset = findPrevWordStart(getText(), searchOffset);
+ if (prevWordOffset < searchOffset) {
+ searchOffset = prevWordOffset;
+ startOffset = prevWordOffset;
+ } else {
+ break;
+ }
+ }
+
+ if (startOffset < currentOffset) {
+ const nextState = pushUndo(state);
+ const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
+ startOffset,
+ currentOffset,
+ nextState.lines,
+ );
+ return replaceRangeInternal(
+ nextState,
+ startRow,
+ startCol,
+ endRow,
+ endCol,
+ '',
+ );
+ }
+ return state;
+ }
+
+ case 'vim_change_word_end': {
+ const { count } = action.payload;
+ const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines);
+
+ let offset = currentOffset;
+ let endOffset = currentOffset;
+
+ for (let i = 0; i < count; i++) {
+ const wordEndOffset = findWordEnd(getText(), offset);
+ if (wordEndOffset >= offset) {
+ endOffset = wordEndOffset + 1; // Include the character at word end
+ // For next iteration, move to start of next word
+ if (i < count - 1) {
+ const nextWordStart = findNextWordStart(
+ getText(),
+ wordEndOffset + 1,
+ );
+ offset = nextWordStart;
+ if (nextWordStart <= wordEndOffset) {
+ break; // No more words
+ }
+ }
+ } else {
+ break;
+ }
+ }
+
+ endOffset = Math.min(endOffset, getText().length);
+
+ if (endOffset !== currentOffset) {
+ const nextState = pushUndo(state);
+ const { startRow, startCol, endRow, endCol } = getPositionFromOffsets(
+ Math.min(currentOffset, endOffset),
+ Math.max(currentOffset, endOffset),
+ nextState.lines,
+ );
+ return replaceRangeInternal(
+ nextState,
+ startRow,
+ startCol,
+ endRow,
+ endCol,
+ '',
+ );
+ }
+ return state;
+ }
+
+ case 'vim_delete_line': {
+ const { count } = action.payload;
+ if (lines.length === 0) return state;
+
+ const linesToDelete = Math.min(count, lines.length - cursorRow);
+ const totalLines = lines.length;
+
+ if (totalLines === 1 || linesToDelete >= totalLines) {
+ // If there's only one line, or we're deleting all remaining lines,
+ // clear the content but keep one empty line (text editors should never be completely empty)
+ const nextState = pushUndo(state);
+ return {
+ ...nextState,
+ lines: [''],
+ cursorRow: 0,
+ cursorCol: 0,
+ preferredCol: null,
+ };
+ }
+
+ const nextState = pushUndo(state);
+ const newLines = [...nextState.lines];
+ newLines.splice(cursorRow, linesToDelete);
+
+ // Adjust cursor position
+ const newCursorRow = Math.min(cursorRow, newLines.length - 1);
+ const newCursorCol = 0; // Vim places cursor at beginning of line after dd
+
+ return {
+ ...nextState,
+ lines: newLines,
+ cursorRow: newCursorRow,
+ cursorCol: newCursorCol,
+ preferredCol: null,
+ };
+ }
+
+ case 'vim_change_line': {
+ const { count } = action.payload;
+ if (lines.length === 0) return state;
+
+ const linesToChange = Math.min(count, lines.length - cursorRow);
+ const nextState = 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,
+ '',
+ );
+ }
+
+ case 'vim_delete_to_end_of_line': {
+ const currentLine = lines[cursorRow] || '';
+ if (cursorCol < currentLine.length) {
+ const nextState = pushUndo(state);
+ return replaceRangeInternal(
+ nextState,
+ cursorRow,
+ cursorCol,
+ cursorRow,
+ currentLine.length,
+ '',
+ );
+ }
+ return state;
+ }
+
+ case 'vim_change_to_end_of_line': {
+ const currentLine = lines[cursorRow] || '';
+ if (cursorCol < currentLine.length) {
+ const nextState = pushUndo(state);
+ return replaceRangeInternal(
+ nextState,
+ cursorRow,
+ cursorCol,
+ cursorRow,
+ currentLine.length,
+ '',
+ );
+ }
+ return state;
+ }
+
+ case 'vim_change_movement': {
+ const { movement, count } = action.payload;
+ const totalLines = lines.length;
+
+ switch (movement) {
+ case 'h': {
+ // Left
+ // Change N characters to the left
+ const startCol = Math.max(0, cursorCol - count);
+ return replaceRangeInternal(
+ pushUndo(state),
+ cursorRow,
+ startCol,
+ cursorRow,
+ cursorCol,
+ '',
+ );
+ }
+
+ case 'j': {
+ // Down
+ const linesToChange = Math.min(count, totalLines - cursorRow);
+ if (linesToChange > 0) {
+ if (totalLines === 1) {
+ const currentLine = state.lines[0] || '';
+ return replaceRangeInternal(
+ pushUndo(state),
+ 0,
+ 0,
+ 0,
+ cpLen(currentLine),
+ '',
+ );
+ } else {
+ const nextState = 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 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(
+ pushUndo(state),
+ 0,
+ 0,
+ 0,
+ cpLen(currentLine),
+ '',
+ );
+ } else {
+ const startRow = Math.max(0, cursorRow - count + 1);
+ const linesToChange = cursorRow - startRow + 1;
+ const nextState = 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,
+ cursorCol: 0,
+ };
+ }
+ }
+ return state;
+ }
+
+ case 'l': {
+ // Right
+ // Change N characters to the right
+ return replaceRangeInternal(
+ pushUndo(state),
+ cursorRow,
+ cursorCol,
+ cursorRow,
+ Math.min(cpLen(lines[cursorRow] || ''), cursorCol + count),
+ '',
+ );
+ }
+
+ default:
+ return state;
+ }
+ }
+
+ case 'vim_move_left': {
+ const { count } = action.payload;
+ const { cursorRow, cursorCol, lines } = state;
+ let newRow = cursorRow;
+ let newCol = cursorCol;
+
+ for (let i = 0; i < count; i++) {
+ if (newCol > 0) {
+ newCol--;
+ } else if (newRow > 0) {
+ // Move to end of previous line
+ newRow--;
+ const prevLine = lines[newRow] || '';
+ const prevLineLength = cpLen(prevLine);
+ // Position on last character, or column 0 for empty lines
+ newCol = prevLineLength === 0 ? 0 : prevLineLength - 1;
+ }
+ }
+
+ return {
+ ...state,
+ cursorRow: newRow,
+ cursorCol: newCol,
+ preferredCol: null,
+ };
+ }
+
+ case 'vim_move_right': {
+ const { count } = action.payload;
+ const { cursorRow, cursorCol, lines } = state;
+ let newRow = cursorRow;
+ let newCol = cursorCol;
+
+ for (let i = 0; i < count; i++) {
+ const currentLine = lines[newRow] || '';
+ const lineLength = cpLen(currentLine);
+ // Don't move past the last character of the line
+ // For empty lines, stay at column 0; for non-empty lines, don't go past last character
+ if (lineLength === 0) {
+ // Empty line - try to move to next line
+ if (newRow < lines.length - 1) {
+ newRow++;
+ newCol = 0;
+ }
+ } else if (newCol < lineLength - 1) {
+ newCol++;
+ } else if (newRow < lines.length - 1) {
+ // At end of line - move to beginning of next line
+ newRow++;
+ newCol = 0;
+ }
+ }
+
+ return {
+ ...state,
+ cursorRow: newRow,
+ cursorCol: newCol,
+ preferredCol: null,
+ };
+ }
+
+ case 'vim_move_up': {
+ const { count } = action.payload;
+ const { cursorRow, cursorCol, lines } = state;
+ const newRow = Math.max(0, cursorRow - count);
+ const newCol = Math.min(cursorCol, cpLen(lines[newRow] || ''));
+
+ return {
+ ...state,
+ cursorRow: newRow,
+ cursorCol: newCol,
+ preferredCol: null,
+ };
+ }
+
+ case 'vim_move_down': {
+ const { count } = action.payload;
+ const { cursorRow, cursorCol, lines } = state;
+ const newRow = Math.min(lines.length - 1, cursorRow + count);
+ const newCol = Math.min(cursorCol, cpLen(lines[newRow] || ''));
+
+ return {
+ ...state,
+ cursorRow: newRow,
+ cursorCol: newCol,
+ preferredCol: null,
+ };
+ }
+
+ case 'vim_move_word_forward': {
+ const { count } = action.payload;
+ let offset = getOffsetFromPosition(cursorRow, cursorCol, lines);
+
+ for (let i = 0; i < count; i++) {
+ const nextWordOffset = findNextWordStart(getText(), offset);
+ if (nextWordOffset > offset) {
+ offset = nextWordOffset;
+ } else {
+ // No more words to move to
+ break;
+ }
+ }
+
+ const { startRow, startCol } = getPositionFromOffsets(
+ offset,
+ offset,
+ lines,
+ );
+ return {
+ ...state,
+ cursorRow: startRow,
+ cursorCol: startCol,
+ preferredCol: null,
+ };
+ }
+
+ case 'vim_move_word_backward': {
+ const { count } = action.payload;
+ let offset = getOffsetFromPosition(cursorRow, cursorCol, lines);
+
+ for (let i = 0; i < count; i++) {
+ offset = findPrevWordStart(getText(), offset);
+ }
+
+ const { startRow, startCol } = getPositionFromOffsets(
+ offset,
+ offset,
+ lines,
+ );
+ return {
+ ...state,
+ cursorRow: startRow,
+ cursorCol: startCol,
+ preferredCol: null,
+ };
+ }
+
+ case 'vim_move_word_end': {
+ const { count } = action.payload;
+ let offset = getOffsetFromPosition(cursorRow, cursorCol, lines);
+
+ for (let i = 0; i < count; i++) {
+ offset = findWordEnd(getText(), offset);
+ }
+
+ const { startRow, startCol } = getPositionFromOffsets(
+ offset,
+ offset,
+ lines,
+ );
+ return {
+ ...state,
+ cursorRow: startRow,
+ cursorCol: startCol,
+ preferredCol: null,
+ };
+ }
+
+ case 'vim_delete_char': {
+ const { count } = action.payload;
+ const { cursorRow, cursorCol, lines } = state;
+ const currentLine = lines[cursorRow] || '';
+ const lineLength = cpLen(currentLine);
+
+ if (cursorCol < lineLength) {
+ const deleteCount = Math.min(count, lineLength - cursorCol);
+ const nextState = pushUndo(state);
+ return replaceRangeInternal(
+ nextState,
+ cursorRow,
+ cursorCol,
+ cursorRow,
+ cursorCol + deleteCount,
+ '',
+ );
+ }
+ return state;
+ }
+
+ case 'vim_insert_at_cursor': {
+ // Just return state - mode change is handled elsewhere
+ return state;
+ }
+
+ case 'vim_append_at_cursor': {
+ const { cursorRow, cursorCol, lines } = state;
+ const currentLine = lines[cursorRow] || '';
+ const newCol = cursorCol < cpLen(currentLine) ? cursorCol + 1 : cursorCol;
+
+ return {
+ ...state,
+ cursorCol: newCol,
+ preferredCol: null,
+ };
+ }
+
+ case 'vim_open_line_below': {
+ const { cursorRow, lines } = state;
+ const nextState = pushUndo(state);
+
+ // Insert newline at end of current line
+ const endOfLine = cpLen(lines[cursorRow] || '');
+ return replaceRangeInternal(
+ nextState,
+ cursorRow,
+ endOfLine,
+ cursorRow,
+ endOfLine,
+ '\n',
+ );
+ }
+
+ case 'vim_open_line_above': {
+ const { cursorRow } = state;
+ const nextState = pushUndo(state);
+
+ // Insert newline at beginning of current line
+ const resultState = replaceRangeInternal(
+ nextState,
+ cursorRow,
+ 0,
+ cursorRow,
+ 0,
+ '\n',
+ );
+
+ // Move cursor to the new line above
+ return {
+ ...resultState,
+ cursorRow,
+ cursorCol: 0,
+ };
+ }
+
+ case 'vim_append_at_line_end': {
+ const { cursorRow, lines } = state;
+ const lineLength = cpLen(lines[cursorRow] || '');
+
+ return {
+ ...state,
+ cursorCol: lineLength,
+ preferredCol: null,
+ };
+ }
+
+ case 'vim_insert_at_line_start': {
+ const { cursorRow, lines } = state;
+ const currentLine = lines[cursorRow] || '';
+ let col = 0;
+
+ // Find first non-whitespace character using proper Unicode handling
+ const lineCodePoints = [...currentLine]; // Proper Unicode iteration
+ while (col < lineCodePoints.length && /\s/.test(lineCodePoints[col])) {
+ col++;
+ }
+
+ return {
+ ...state,
+ cursorCol: col,
+ preferredCol: null,
+ };
+ }
+
+ case 'vim_move_to_line_start': {
+ return {
+ ...state,
+ cursorCol: 0,
+ preferredCol: null,
+ };
+ }
+
+ case 'vim_move_to_line_end': {
+ const { cursorRow, lines } = state;
+ const lineLength = cpLen(lines[cursorRow] || '');
+
+ return {
+ ...state,
+ cursorCol: lineLength > 0 ? lineLength - 1 : 0,
+ preferredCol: null,
+ };
+ }
+
+ case 'vim_move_to_first_nonwhitespace': {
+ const { cursorRow, lines } = state;
+ const currentLine = lines[cursorRow] || '';
+ let col = 0;
+
+ // Find first non-whitespace character using proper Unicode handling
+ const lineCodePoints = [...currentLine]; // Proper Unicode iteration
+ while (col < lineCodePoints.length && /\s/.test(lineCodePoints[col])) {
+ col++;
+ }
+
+ return {
+ ...state,
+ cursorCol: col,
+ preferredCol: null,
+ };
+ }
+
+ case 'vim_move_to_first_line': {
+ return {
+ ...state,
+ cursorRow: 0,
+ cursorCol: 0,
+ preferredCol: null,
+ };
+ }
+
+ case 'vim_move_to_last_line': {
+ const { lines } = state;
+ const lastRow = lines.length - 1;
+
+ return {
+ ...state,
+ cursorRow: lastRow,
+ cursorCol: 0,
+ preferredCol: null,
+ };
+ }
+
+ case 'vim_move_to_line': {
+ const { lineNumber } = action.payload;
+ const { lines } = state;
+ const targetRow = Math.min(Math.max(0, lineNumber - 1), lines.length - 1);
+
+ return {
+ ...state,
+ cursorRow: targetRow,
+ cursorCol: 0,
+ preferredCol: null,
+ };
+ }
+
+ case 'vim_escape_insert_mode': {
+ // Move cursor left if not at beginning of line (vim behavior when exiting insert mode)
+ const { cursorCol } = state;
+ const newCol = cursorCol > 0 ? cursorCol - 1 : 0;
+
+ return {
+ ...state,
+ cursorCol: newCol,
+ preferredCol: null,
+ };
+ }
+
+ default: {
+ // This should never happen if TypeScript is working correctly
+ const _exhaustiveCheck: never = action;
+ return state;
+ }
+ }
+}