summaryrefslogtreecommitdiff
path: root/packages/cli/src/ui/components/shared/vim-buffer-actions.ts
diff options
context:
space:
mode:
authorSijie Wang <[email protected]>2025-07-25 15:36:42 -0700
committerGitHub <[email protected]>2025-07-25 22:36:42 +0000
commitfbdc8d5ab3f76aef32af6a8f516d97771c56a7ac (patch)
tree2167cd9ab4c5a1378d466e735bc41e167ea4d904 /packages/cli/src/ui/components/shared/vim-buffer-actions.ts
parentaa71438684dd0350acf62fc01d1e6244fd4d3f51 (diff)
Vim mode (#3936)
Diffstat (limited to 'packages/cli/src/ui/components/shared/vim-buffer-actions.ts')
-rw-r--r--packages/cli/src/ui/components/shared/vim-buffer-actions.ts887
1 files changed, 887 insertions, 0 deletions
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;
+ }
+ }
+}