summaryrefslogtreecommitdiff
path: root/packages/cli/src/ui/hooks
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli/src/ui/hooks')
-rw-r--r--packages/cli/src/ui/hooks/slashCommandProcessor.test.ts1
-rw-r--r--packages/cli/src/ui/hooks/slashCommandProcessor.ts3
-rw-r--r--packages/cli/src/ui/hooks/useKeypress.ts7
-rw-r--r--packages/cli/src/ui/hooks/vim.test.ts1626
-rw-r--r--packages/cli/src/ui/hooks/vim.ts774
5 files changed, 2409 insertions, 2 deletions
diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts
index d308af46..ac9b79ec 100644
--- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts
+++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts
@@ -111,6 +111,7 @@ describe('useSlashCommandProcessor', () => {
vi.fn(), // toggleCorgiMode
mockSetQuittingMessages,
vi.fn(), // openPrivacyNotice
+ vi.fn(), // toggleVimEnabled
),
);
diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts
index 9e9dc21c..46b49329 100644
--- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts
+++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts
@@ -43,6 +43,7 @@ export const useSlashCommandProcessor = (
toggleCorgiMode: () => void,
setQuittingMessages: (message: HistoryItem[]) => void,
openPrivacyNotice: () => void,
+ toggleVimEnabled: () => Promise<boolean>,
) => {
const session = useSessionStats();
const [commands, setCommands] = useState<readonly SlashCommand[]>([]);
@@ -139,6 +140,7 @@ export const useSlashCommandProcessor = (
pendingItem: pendingCompressionItemRef.current,
setPendingItem: setPendingCompressionItem,
toggleCorgiMode,
+ toggleVimEnabled,
},
session: {
stats: session.stats,
@@ -158,6 +160,7 @@ export const useSlashCommandProcessor = (
pendingCompressionItemRef,
setPendingCompressionItem,
toggleCorgiMode,
+ toggleVimEnabled,
],
);
diff --git a/packages/cli/src/ui/hooks/useKeypress.ts b/packages/cli/src/ui/hooks/useKeypress.ts
index d3e3df5c..6c2b7e8f 100644
--- a/packages/cli/src/ui/hooks/useKeypress.ts
+++ b/packages/cli/src/ui/hooks/useKeypress.ts
@@ -147,12 +147,15 @@ export function useKeypress(
let rl: readline.Interface;
if (usePassthrough) {
- rl = readline.createInterface({ input: keypressStream });
+ rl = readline.createInterface({
+ input: keypressStream,
+ escapeCodeTimeout: 0,
+ });
readline.emitKeypressEvents(keypressStream, rl);
keypressStream.on('keypress', handleKeypress);
stdin.on('data', handleRawKeypress);
} else {
- rl = readline.createInterface({ input: stdin });
+ rl = readline.createInterface({ input: stdin, escapeCodeTimeout: 0 });
readline.emitKeypressEvents(stdin, rl);
stdin.on('keypress', handleKeypress);
}
diff --git a/packages/cli/src/ui/hooks/vim.test.ts b/packages/cli/src/ui/hooks/vim.test.ts
new file mode 100644
index 00000000..f939982f
--- /dev/null
+++ b/packages/cli/src/ui/hooks/vim.test.ts
@@ -0,0 +1,1626 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import React from 'react';
+import { useVim } from './vim.js';
+import type { TextBuffer } from '../components/shared/text-buffer.js';
+import { textBufferReducer } from '../components/shared/text-buffer.js';
+
+// Mock the VimModeContext
+const mockVimContext = {
+ vimEnabled: true,
+ vimMode: 'NORMAL' as const,
+ toggleVimEnabled: vi.fn(),
+ setVimMode: vi.fn(),
+};
+
+vi.mock('../contexts/VimModeContext.js', () => ({
+ useVimMode: () => mockVimContext,
+ VimModeProvider: ({ children }: { children: React.ReactNode }) => children,
+}));
+
+// Test constants
+const TEST_SEQUENCES = {
+ ESCAPE: { sequence: '\u001b', name: 'escape' },
+ LEFT: { sequence: 'h' },
+ RIGHT: { sequence: 'l' },
+ UP: { sequence: 'k' },
+ DOWN: { sequence: 'j' },
+ INSERT: { sequence: 'i' },
+ APPEND: { sequence: 'a' },
+ DELETE_CHAR: { sequence: 'x' },
+ DELETE: { sequence: 'd' },
+ CHANGE: { sequence: 'c' },
+ WORD_FORWARD: { sequence: 'w' },
+ WORD_BACKWARD: { sequence: 'b' },
+ WORD_END: { sequence: 'e' },
+ LINE_START: { sequence: '0' },
+ LINE_END: { sequence: '$' },
+ REPEAT: { sequence: '.' },
+} as const;
+
+describe('useVim hook', () => {
+ let mockBuffer: Partial<TextBuffer>;
+ let mockHandleFinalSubmit: vi.Mock;
+
+ const createMockBuffer = (
+ text = 'hello world',
+ cursor: [number, number] = [0, 5],
+ ) => {
+ const cursorState = { pos: cursor };
+ const lines = text.split('\n');
+
+ return {
+ lines,
+ get cursor() {
+ return cursorState.pos;
+ },
+ set cursor(newPos: [number, number]) {
+ cursorState.pos = newPos;
+ },
+ text,
+ move: vi.fn().mockImplementation((direction: string) => {
+ let [row, col] = cursorState.pos;
+ const _line = lines[row] || '';
+ if (direction === 'left') {
+ col = Math.max(0, col - 1);
+ } else if (direction === 'right') {
+ col = Math.min(line.length, col + 1);
+ } else if (direction === 'home') {
+ col = 0;
+ } else if (direction === 'end') {
+ col = line.length;
+ }
+ cursorState.pos = [row, col];
+ }),
+ del: vi.fn(),
+ moveToOffset: vi.fn(),
+ insert: vi.fn(),
+ newline: vi.fn(),
+ replaceRangeByOffset: vi.fn(),
+ handleInput: vi.fn(),
+ setText: vi.fn(),
+ // Vim-specific methods
+ vimDeleteWordForward: vi.fn(),
+ vimDeleteWordBackward: vi.fn(),
+ vimDeleteWordEnd: vi.fn(),
+ vimChangeWordForward: vi.fn(),
+ vimChangeWordBackward: vi.fn(),
+ vimChangeWordEnd: vi.fn(),
+ vimDeleteLine: vi.fn(),
+ vimChangeLine: vi.fn(),
+ vimDeleteToEndOfLine: vi.fn(),
+ vimChangeToEndOfLine: vi.fn(),
+ vimChangeMovement: vi.fn(),
+ vimMoveLeft: vi.fn(),
+ vimMoveRight: vi.fn(),
+ vimMoveUp: vi.fn(),
+ vimMoveDown: vi.fn(),
+ vimMoveWordForward: vi.fn(),
+ vimMoveWordBackward: vi.fn(),
+ vimMoveWordEnd: vi.fn(),
+ vimDeleteChar: vi.fn(),
+ vimInsertAtCursor: vi.fn(),
+ vimAppendAtCursor: vi.fn().mockImplementation(() => {
+ // Append moves cursor right (vim 'a' behavior - position after current char)
+ const [row, col] = cursorState.pos;
+ const _line = lines[row] || '';
+ // In vim, 'a' moves cursor to position after current character
+ // This allows inserting at the end of the line
+ cursorState.pos = [row, col + 1];
+ }),
+ vimOpenLineBelow: vi.fn(),
+ vimOpenLineAbove: vi.fn(),
+ vimAppendAtLineEnd: vi.fn(),
+ vimInsertAtLineStart: vi.fn(),
+ vimMoveToLineStart: vi.fn(),
+ vimMoveToLineEnd: vi.fn(),
+ vimMoveToFirstNonWhitespace: vi.fn(),
+ vimMoveToFirstLine: vi.fn(),
+ vimMoveToLastLine: vi.fn(),
+ vimMoveToLine: vi.fn(),
+ vimEscapeInsertMode: vi.fn().mockImplementation(() => {
+ // Escape moves cursor left unless at beginning of line
+ const [row, col] = cursorState.pos;
+ if (col > 0) {
+ cursorState.pos = [row, col - 1];
+ }
+ }),
+ };
+ };
+
+ const _createMockSettings = (vimMode = true) => ({
+ getValue: vi.fn().mockReturnValue(vimMode),
+ setValue: vi.fn(),
+ merged: { vimMode },
+ });
+
+ const renderVimHook = (buffer?: Partial<TextBuffer>) =>
+ renderHook(() =>
+ useVim((buffer || mockBuffer) as TextBuffer, mockHandleFinalSubmit),
+ );
+
+ const exitInsertMode = (result: {
+ current: {
+ handleInput: (input: { sequence: string; name: string }) => void;
+ };
+ }) => {
+ act(() => {
+ result.current.handleInput({ sequence: '\u001b', name: 'escape' });
+ });
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ mockHandleFinalSubmit = vi.fn();
+ mockBuffer = createMockBuffer();
+ // Reset mock context to default state
+ mockVimContext.vimEnabled = true;
+ mockVimContext.vimMode = 'NORMAL';
+ mockVimContext.toggleVimEnabled.mockClear();
+ mockVimContext.setVimMode.mockClear();
+ });
+
+ describe('Mode switching', () => {
+ it('should start in NORMAL mode', () => {
+ const { result } = renderVimHook();
+ expect(result.current.mode).toBe('NORMAL');
+ });
+
+ it('should switch to INSERT mode with i command', () => {
+ const { result } = renderVimHook();
+
+ act(() => {
+ result.current.handleInput(TEST_SEQUENCES.INSERT);
+ });
+
+ expect(result.current.mode).toBe('INSERT');
+ expect(mockVimContext.setVimMode).toHaveBeenCalledWith('INSERT');
+ });
+
+ it('should switch back to NORMAL mode with Escape', () => {
+ const { result } = renderVimHook();
+
+ act(() => {
+ result.current.handleInput(TEST_SEQUENCES.INSERT);
+ });
+ expect(result.current.mode).toBe('INSERT');
+
+ exitInsertMode(result);
+ expect(result.current.mode).toBe('NORMAL');
+ });
+
+ it('should properly handle escape followed immediately by a command', () => {
+ const testBuffer = createMockBuffer('hello world test', [0, 6]);
+ const { result } = renderVimHook(testBuffer);
+
+ act(() => {
+ result.current.handleInput({ sequence: 'i' });
+ });
+ expect(result.current.mode).toBe('INSERT');
+
+ vi.clearAllMocks();
+
+ exitInsertMode(result);
+ expect(result.current.mode).toBe('NORMAL');
+
+ act(() => {
+ result.current.handleInput({ sequence: 'b' });
+ });
+
+ expect(testBuffer.vimMoveWordBackward).toHaveBeenCalledWith(1);
+ });
+ });
+
+ describe('Navigation commands', () => {
+ it('should handle h (left movement)', () => {
+ const { result } = renderVimHook();
+
+ act(() => {
+ result.current.handleInput({ sequence: 'h' });
+ });
+
+ expect(mockBuffer.vimMoveLeft).toHaveBeenCalledWith(1);
+ });
+
+ it('should handle l (right movement)', () => {
+ const { result } = renderVimHook();
+
+ act(() => {
+ result.current.handleInput({ sequence: 'l' });
+ });
+
+ expect(mockBuffer.vimMoveRight).toHaveBeenCalledWith(1);
+ });
+
+ it('should handle j (down movement)', () => {
+ const testBuffer = createMockBuffer('first line\nsecond line');
+ const { result } = renderVimHook(testBuffer);
+
+ act(() => {
+ result.current.handleInput({ sequence: 'j' });
+ });
+
+ expect(testBuffer.vimMoveDown).toHaveBeenCalledWith(1);
+ });
+
+ it('should handle k (up movement)', () => {
+ const testBuffer = createMockBuffer('first line\nsecond line');
+ const { result } = renderVimHook(testBuffer);
+
+ act(() => {
+ result.current.handleInput({ sequence: 'k' });
+ });
+
+ expect(testBuffer.vimMoveUp).toHaveBeenCalledWith(1);
+ });
+
+ it('should handle 0 (move to start of line)', () => {
+ const { result } = renderVimHook();
+
+ act(() => {
+ result.current.handleInput({ sequence: '0' });
+ });
+
+ expect(mockBuffer.vimMoveToLineStart).toHaveBeenCalled();
+ });
+
+ it('should handle $ (move to end of line)', () => {
+ const { result } = renderVimHook();
+
+ act(() => {
+ result.current.handleInput({ sequence: '$' });
+ });
+
+ expect(mockBuffer.vimMoveToLineEnd).toHaveBeenCalled();
+ });
+ });
+
+ describe('Mode switching commands', () => {
+ it('should handle a (append after cursor)', () => {
+ const { result } = renderVimHook();
+
+ act(() => {
+ result.current.handleInput({ sequence: 'a' });
+ });
+
+ expect(mockBuffer.vimAppendAtCursor).toHaveBeenCalled();
+ expect(result.current.mode).toBe('INSERT');
+ });
+
+ it('should handle A (append at end of line)', () => {
+ const { result } = renderVimHook();
+
+ act(() => {
+ result.current.handleInput({ sequence: 'A' });
+ });
+
+ expect(mockBuffer.vimAppendAtLineEnd).toHaveBeenCalled();
+ expect(result.current.mode).toBe('INSERT');
+ });
+
+ it('should handle o (open line below)', () => {
+ const { result } = renderVimHook();
+
+ act(() => {
+ result.current.handleInput({ sequence: 'o' });
+ });
+
+ expect(mockBuffer.vimOpenLineBelow).toHaveBeenCalled();
+ expect(result.current.mode).toBe('INSERT');
+ });
+
+ it('should handle O (open line above)', () => {
+ const { result } = renderVimHook();
+
+ act(() => {
+ result.current.handleInput({ sequence: 'O' });
+ });
+
+ expect(mockBuffer.vimOpenLineAbove).toHaveBeenCalled();
+ expect(result.current.mode).toBe('INSERT');
+ });
+ });
+
+ describe('Edit commands', () => {
+ it('should handle x (delete character)', () => {
+ const { result } = renderVimHook();
+ vi.clearAllMocks();
+
+ act(() => {
+ result.current.handleInput({ sequence: 'x' });
+ });
+
+ expect(mockBuffer.vimDeleteChar).toHaveBeenCalledWith(1);
+ });
+
+ it('should move cursor left when deleting last character on line (vim behavior)', () => {
+ const testBuffer = createMockBuffer('hello', [0, 4]);
+ const { result } = renderVimHook(testBuffer);
+
+ act(() => {
+ result.current.handleInput({ sequence: 'x' });
+ });
+
+ expect(testBuffer.vimDeleteChar).toHaveBeenCalledWith(1);
+ });
+
+ it('should handle first d key (sets pending state)', () => {
+ const { result } = renderVimHook();
+
+ act(() => {
+ result.current.handleInput({ sequence: 'd' });
+ });
+
+ expect(mockBuffer.replaceRangeByOffset).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('Count handling', () => {
+ it('should handle count input and return to count 0 after command', () => {
+ const { result } = renderVimHook();
+
+ act(() => {
+ const handled = result.current.handleInput({ sequence: '3' });
+ expect(handled).toBe(true);
+ });
+
+ act(() => {
+ const handled = result.current.handleInput({ sequence: 'h' });
+ expect(handled).toBe(true);
+ });
+
+ expect(mockBuffer.vimMoveLeft).toHaveBeenCalledWith(3);
+ });
+
+ it('should only delete 1 character with x command when no count is specified', () => {
+ const testBuffer = createMockBuffer();
+ const { result } = renderVimHook(testBuffer);
+
+ act(() => {
+ result.current.handleInput({ sequence: 'x' });
+ });
+
+ expect(testBuffer.vimDeleteChar).toHaveBeenCalledWith(1);
+ });
+ });
+
+ describe('Word movement', () => {
+ it('should properly initialize vim hook with word movement support', () => {
+ const testBuffer = createMockBuffer('cat elephant mouse', [0, 0]);
+ const { result } = renderVimHook(testBuffer);
+
+ expect(result.current.vimModeEnabled).toBe(true);
+ expect(result.current.mode).toBe('NORMAL');
+ expect(result.current.handleInput).toBeDefined();
+ });
+
+ it('should support vim mode and basic operations across multiple lines', () => {
+ const testBuffer = createMockBuffer(
+ 'first line word\nsecond line word',
+ [0, 11],
+ );
+ const { result } = renderVimHook(testBuffer);
+
+ expect(result.current.vimModeEnabled).toBe(true);
+ expect(result.current.mode).toBe('NORMAL');
+ expect(result.current.handleInput).toBeDefined();
+ expect(testBuffer.replaceRangeByOffset).toBeDefined();
+ expect(testBuffer.moveToOffset).toBeDefined();
+ });
+
+ it('should handle w (next word)', () => {
+ const testBuffer = createMockBuffer('hello world test');
+ const { result } = renderVimHook(testBuffer);
+
+ act(() => {
+ result.current.handleInput({ sequence: 'w' });
+ });
+
+ expect(testBuffer.vimMoveWordForward).toHaveBeenCalledWith(1);
+ });
+
+ it('should handle b (previous word)', () => {
+ const testBuffer = createMockBuffer('hello world test', [0, 6]);
+ const { result } = renderVimHook(testBuffer);
+
+ act(() => {
+ result.current.handleInput({ sequence: 'b' });
+ });
+
+ expect(testBuffer.vimMoveWordBackward).toHaveBeenCalledWith(1);
+ });
+
+ it('should handle e (end of word)', () => {
+ const testBuffer = createMockBuffer('hello world test');
+ const { result } = renderVimHook(testBuffer);
+
+ act(() => {
+ result.current.handleInput({ sequence: 'e' });
+ });
+
+ expect(testBuffer.vimMoveWordEnd).toHaveBeenCalledWith(1);
+ });
+
+ it('should handle w when cursor is on the last word', () => {
+ const testBuffer = createMockBuffer('hello world', [0, 8]);
+ const { result } = renderVimHook(testBuffer);
+
+ act(() => {
+ result.current.handleInput({ sequence: 'w' });
+ });
+
+ expect(testBuffer.vimMoveWordForward).toHaveBeenCalledWith(1);
+ });
+
+ it('should handle first c key (sets pending change state)', () => {
+ const { result } = renderVimHook();
+
+ act(() => {
+ result.current.handleInput({ sequence: 'c' });
+ });
+
+ expect(result.current.mode).toBe('NORMAL');
+ expect(mockBuffer.del).not.toHaveBeenCalled();
+ });
+
+ it('should clear pending state on invalid command sequence (df)', () => {
+ const { result } = renderVimHook();
+
+ act(() => {
+ result.current.handleInput({ sequence: 'd' });
+ result.current.handleInput({ sequence: 'f' });
+ });
+
+ expect(mockBuffer.replaceRangeByOffset).not.toHaveBeenCalled();
+ expect(mockBuffer.del).not.toHaveBeenCalled();
+ });
+
+ it('should clear pending state with Escape in NORMAL mode', () => {
+ const { result } = renderVimHook();
+
+ act(() => {
+ result.current.handleInput({ sequence: 'd' });
+ });
+
+ exitInsertMode(result);
+
+ expect(mockBuffer.replaceRangeByOffset).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('Disabled vim mode', () => {
+ it('should not respond to vim commands when disabled', () => {
+ mockVimContext.vimEnabled = false;
+ const { result } = renderVimHook(mockBuffer);
+
+ act(() => {
+ result.current.handleInput({ sequence: 'h' });
+ });
+
+ expect(mockBuffer.move).not.toHaveBeenCalled();
+ });
+ });
+
+ // These tests are no longer applicable at the hook level
+
+ describe('Command repeat system', () => {
+ it('should repeat x command from current cursor position', () => {
+ const testBuffer = createMockBuffer('abcd\nefgh\nijkl', [0, 1]);
+ const { result } = renderVimHook(testBuffer);
+
+ act(() => {
+ result.current.handleInput({ sequence: 'x' });
+ });
+ expect(testBuffer.vimDeleteChar).toHaveBeenCalledWith(1);
+
+ testBuffer.cursor = [1, 2];
+
+ act(() => {
+ result.current.handleInput({ sequence: '.' });
+ });
+ expect(testBuffer.vimDeleteChar).toHaveBeenCalledWith(1);
+ });
+
+ it('should repeat dd command from current position', () => {
+ const testBuffer = createMockBuffer('line1\nline2\nline3', [1, 0]);
+ const { result } = renderVimHook(testBuffer);
+
+ act(() => {
+ result.current.handleInput({ sequence: 'd' });
+ });
+ act(() => {
+ result.current.handleInput({ sequence: 'd' });
+ });
+ expect(testBuffer.vimDeleteLine).toHaveBeenCalledTimes(1);
+
+ testBuffer.cursor = [0, 0];
+
+ act(() => {
+ result.current.handleInput({ sequence: '.' });
+ });
+
+ expect(testBuffer.vimDeleteLine).toHaveBeenCalledTimes(2);
+ });
+
+ it('should repeat ce command from current position', () => {
+ const testBuffer = createMockBuffer('word', [0, 0]);
+ const { result } = renderVimHook(testBuffer);
+
+ act(() => {
+ result.current.handleInput({ sequence: 'c' });
+ });
+ act(() => {
+ result.current.handleInput({ sequence: 'e' });
+ });
+ expect(testBuffer.vimChangeWordEnd).toHaveBeenCalledTimes(1);
+
+ // Exit INSERT mode to complete the command
+ exitInsertMode(result);
+
+ testBuffer.cursor = [0, 2];
+
+ act(() => {
+ result.current.handleInput({ sequence: '.' });
+ });
+
+ expect(testBuffer.vimChangeWordEnd).toHaveBeenCalledTimes(2);
+ });
+
+ it('should repeat cc command from current position', () => {
+ const testBuffer = createMockBuffer('line1\nline2\nline3', [1, 2]);
+ const { result } = renderVimHook(testBuffer);
+
+ act(() => {
+ result.current.handleInput({ sequence: 'c' });
+ });
+ act(() => {
+ result.current.handleInput({ sequence: 'c' });
+ });
+ expect(testBuffer.vimChangeLine).toHaveBeenCalledTimes(1);
+
+ // Exit INSERT mode to complete the command
+ exitInsertMode(result);
+
+ testBuffer.cursor = [0, 1];
+
+ act(() => {
+ result.current.handleInput({ sequence: '.' });
+ });
+
+ expect(testBuffer.vimChangeLine).toHaveBeenCalledTimes(2);
+ });
+
+ it('should repeat cw command from current position', () => {
+ const testBuffer = createMockBuffer('hello world test', [0, 6]);
+ const { result } = renderVimHook(testBuffer);
+
+ act(() => {
+ result.current.handleInput({ sequence: 'c' });
+ });
+ act(() => {
+ result.current.handleInput({ sequence: 'w' });
+ });
+ expect(testBuffer.vimChangeWordForward).toHaveBeenCalledTimes(1);
+
+ // Exit INSERT mode to complete the command
+ exitInsertMode(result);
+
+ testBuffer.cursor = [0, 0];
+
+ act(() => {
+ result.current.handleInput({ sequence: '.' });
+ });
+
+ expect(testBuffer.vimChangeWordForward).toHaveBeenCalledTimes(2);
+ });
+
+ it('should repeat D command from current position', () => {
+ const testBuffer = createMockBuffer('hello world test', [0, 6]);
+ const { result } = renderVimHook(testBuffer);
+
+ act(() => {
+ result.current.handleInput({ sequence: 'D' });
+ });
+ expect(testBuffer.vimDeleteToEndOfLine).toHaveBeenCalledTimes(1);
+
+ testBuffer.cursor = [0, 2];
+ vi.clearAllMocks(); // Clear all mocks instead of just one method
+
+ act(() => {
+ result.current.handleInput({ sequence: '.' });
+ });
+
+ expect(testBuffer.vimDeleteToEndOfLine).toHaveBeenCalledTimes(1);
+ });
+
+ it('should repeat C command from current position', () => {
+ const testBuffer = createMockBuffer('hello world test', [0, 6]);
+ const { result } = renderVimHook(testBuffer);
+
+ act(() => {
+ result.current.handleInput({ sequence: 'C' });
+ });
+ expect(testBuffer.vimChangeToEndOfLine).toHaveBeenCalledTimes(1);
+
+ // Exit INSERT mode to complete the command
+ exitInsertMode(result);
+
+ testBuffer.cursor = [0, 2];
+
+ act(() => {
+ result.current.handleInput({ sequence: '.' });
+ });
+
+ expect(testBuffer.vimChangeToEndOfLine).toHaveBeenCalledTimes(2);
+ });
+
+ it('should repeat command after cursor movement', () => {
+ const testBuffer = createMockBuffer('test text', [0, 0]);
+ const { result } = renderVimHook(testBuffer);
+
+ act(() => {
+ result.current.handleInput({ sequence: 'x' });
+ });
+ expect(testBuffer.vimDeleteChar).toHaveBeenCalledWith(1);
+
+ testBuffer.cursor = [0, 2];
+
+ act(() => {
+ result.current.handleInput({ sequence: '.' });
+ });
+ expect(testBuffer.vimDeleteChar).toHaveBeenCalledWith(1);
+ });
+
+ it('should move cursor to the correct position after exiting INSERT mode with "a"', () => {
+ const testBuffer = createMockBuffer('hello world', [0, 10]);
+ const { result } = renderVimHook(testBuffer);
+
+ act(() => {
+ result.current.handleInput({ sequence: 'a' });
+ });
+ expect(result.current.mode).toBe('INSERT');
+ expect(testBuffer.cursor).toEqual([0, 11]);
+
+ exitInsertMode(result);
+ expect(result.current.mode).toBe('NORMAL');
+ expect(testBuffer.cursor).toEqual([0, 10]);
+ });
+ });
+
+ describe('Special characters and edge cases', () => {
+ it('should handle ^ (move to first non-whitespace character)', () => {
+ const testBuffer = createMockBuffer(' hello world', [0, 5]);
+ const { result } = renderVimHook(testBuffer);
+
+ act(() => {
+ result.current.handleInput({ sequence: '^' });
+ });
+
+ expect(testBuffer.vimMoveToFirstNonWhitespace).toHaveBeenCalled();
+ });
+
+ it('should handle G without count (go to last line)', () => {
+ const testBuffer = createMockBuffer('line1\nline2\nline3', [0, 0]);
+ const { result } = renderVimHook(testBuffer);
+
+ act(() => {
+ result.current.handleInput({ sequence: 'G' });
+ });
+
+ expect(testBuffer.vimMoveToLastLine).toHaveBeenCalled();
+ });
+
+ it('should handle gg (go to first line)', () => {
+ const testBuffer = createMockBuffer('line1\nline2\nline3', [2, 0]);
+ const { result } = renderVimHook(testBuffer);
+
+ // First 'g' sets pending state
+ act(() => {
+ result.current.handleInput({ sequence: 'g' });
+ });
+
+ // Second 'g' executes the command
+ act(() => {
+ result.current.handleInput({ sequence: 'g' });
+ });
+
+ expect(testBuffer.vimMoveToFirstLine).toHaveBeenCalled();
+ });
+
+ it('should handle count with movement commands', () => {
+ const testBuffer = createMockBuffer('hello world test', [0, 0]);
+ const { result } = renderVimHook(testBuffer);
+
+ act(() => {
+ result.current.handleInput({ sequence: '3' });
+ });
+
+ act(() => {
+ result.current.handleInput(TEST_SEQUENCES.WORD_FORWARD);
+ });
+
+ expect(testBuffer.vimMoveWordForward).toHaveBeenCalledWith(3);
+ });
+ });
+
+ describe('Vim word operations', () => {
+ describe('dw (delete word forward)', () => {
+ it('should delete from cursor to start of next word', () => {
+ const testBuffer = createMockBuffer('hello world test', [0, 0]);
+ const { result } = renderVimHook(testBuffer);
+
+ act(() => {
+ result.current.handleInput({ sequence: 'd' });
+ });
+ act(() => {
+ result.current.handleInput({ sequence: 'w' });
+ });
+
+ expect(testBuffer.vimDeleteWordForward).toHaveBeenCalledWith(1);
+ });
+
+ it('should actually delete the complete word including trailing space', () => {
+ // This test uses the real text-buffer reducer instead of mocks
+ const initialState = {
+ lines: ['hello world test'],
+ cursorRow: 0,
+ cursorCol: 0,
+ preferredCol: null,
+ undoStack: [],
+ redoStack: [],
+ clipboard: null,
+ selectionAnchor: null,
+ };
+
+ const result = textBufferReducer(initialState, {
+ type: 'vim_delete_word_forward',
+ payload: { count: 1 },
+ });
+
+ // Should delete "hello " (word + space), leaving "world test"
+ expect(result.lines).toEqual(['world test']);
+ expect(result.cursorRow).toBe(0);
+ expect(result.cursorCol).toBe(0);
+ });
+
+ it('should delete word from middle of word correctly', () => {
+ const initialState = {
+ lines: ['hello world test'],
+ cursorRow: 0,
+ cursorCol: 2, // cursor on 'l' in "hello"
+ preferredCol: null,
+ undoStack: [],
+ redoStack: [],
+ clipboard: null,
+ selectionAnchor: null,
+ };
+
+ const result = textBufferReducer(initialState, {
+ type: 'vim_delete_word_forward',
+ payload: { count: 1 },
+ });
+
+ // Should delete "llo " (rest of word + space), leaving "he world test"
+ expect(result.lines).toEqual(['heworld test']);
+ expect(result.cursorRow).toBe(0);
+ expect(result.cursorCol).toBe(2);
+ });
+
+ it('should handle dw at end of line', () => {
+ const initialState = {
+ lines: ['hello world'],
+ cursorRow: 0,
+ cursorCol: 6, // cursor on 'w' in "world"
+ preferredCol: null,
+ undoStack: [],
+ redoStack: [],
+ clipboard: null,
+ selectionAnchor: null,
+ };
+
+ const result = textBufferReducer(initialState, {
+ type: 'vim_delete_word_forward',
+ payload: { count: 1 },
+ });
+
+ // Should delete "world" (no trailing space at end), leaving "hello "
+ expect(result.lines).toEqual(['hello ']);
+ expect(result.cursorRow).toBe(0);
+ expect(result.cursorCol).toBe(6);
+ });
+
+ it('should delete multiple words with count', () => {
+ const testBuffer = createMockBuffer('one two three four', [0, 0]);
+ const { result } = renderVimHook(testBuffer);
+
+ act(() => {
+ result.current.handleInput({ sequence: '2' });
+ });
+ act(() => {
+ result.current.handleInput({ sequence: 'd' });
+ });
+ act(() => {
+ result.current.handleInput({ sequence: 'w' });
+ });
+
+ expect(testBuffer.vimDeleteWordForward).toHaveBeenCalledWith(2);
+ });
+
+ it('should record command for repeat with dot', () => {
+ const testBuffer = createMockBuffer('hello world test', [0, 0]);
+ const { result } = renderVimHook(testBuffer);
+
+ // Execute dw
+ act(() => {
+ result.current.handleInput({ sequence: 'd' });
+ });
+ act(() => {
+ result.current.handleInput({ sequence: 'w' });
+ });
+
+ vi.clearAllMocks();
+
+ // Execute dot repeat
+ act(() => {
+ result.current.handleInput({ sequence: '.' });
+ });
+
+ expect(testBuffer.vimDeleteWordForward).toHaveBeenCalledWith(1);
+ });
+ });
+
+ describe('de (delete word end)', () => {
+ it('should delete from cursor to end of current word', () => {
+ const testBuffer = createMockBuffer('hello world test', [0, 1]);
+ const { result } = renderVimHook(testBuffer);
+
+ act(() => {
+ result.current.handleInput({ sequence: 'd' });
+ });
+ act(() => {
+ result.current.handleInput({ sequence: 'e' });
+ });
+
+ expect(testBuffer.vimDeleteWordEnd).toHaveBeenCalledWith(1);
+ });
+
+ it('should handle count with de', () => {
+ const testBuffer = createMockBuffer('one two three four', [0, 0]);
+ const { result } = renderVimHook(testBuffer);
+
+ act(() => {
+ result.current.handleInput({ sequence: '3' });
+ });
+ act(() => {
+ result.current.handleInput({ sequence: 'd' });
+ });
+ act(() => {
+ result.current.handleInput({ sequence: 'e' });
+ });
+
+ expect(testBuffer.vimDeleteWordEnd).toHaveBeenCalledWith(3);
+ });
+ });
+
+ describe('cw (change word forward)', () => {
+ it('should change from cursor to start of next word and enter INSERT mode', () => {
+ const testBuffer = createMockBuffer('hello world test', [0, 0]);
+ const { result } = renderVimHook(testBuffer);
+
+ act(() => {
+ result.current.handleInput({ sequence: 'c' });
+ });
+ act(() => {
+ result.current.handleInput({ sequence: 'w' });
+ });
+
+ expect(testBuffer.vimChangeWordForward).toHaveBeenCalledWith(1);
+ expect(result.current.mode).toBe('INSERT');
+ expect(mockVimContext.setVimMode).toHaveBeenCalledWith('INSERT');
+ });
+
+ it('should handle count with cw', () => {
+ const testBuffer = createMockBuffer('one two three four', [0, 0]);
+ const { result } = renderVimHook(testBuffer);
+
+ act(() => {
+ result.current.handleInput({ sequence: '2' });
+ });
+ act(() => {
+ result.current.handleInput({ sequence: 'c' });
+ });
+ act(() => {
+ result.current.handleInput({ sequence: 'w' });
+ });
+
+ expect(testBuffer.vimChangeWordForward).toHaveBeenCalledWith(2);
+ expect(result.current.mode).toBe('INSERT');
+ });
+
+ it('should be repeatable with dot', () => {
+ const testBuffer = createMockBuffer('hello world test more', [0, 0]);
+ const { result } = renderVimHook(testBuffer);
+
+ // Execute cw
+ act(() => {
+ result.current.handleInput({ sequence: 'c' });
+ });
+ act(() => {
+ result.current.handleInput({ sequence: 'w' });
+ });
+
+ // Exit INSERT mode
+ exitInsertMode(result);
+
+ vi.clearAllMocks();
+ mockVimContext.setVimMode.mockClear();
+
+ // Execute dot repeat
+ act(() => {
+ result.current.handleInput({ sequence: '.' });
+ });
+
+ expect(testBuffer.vimChangeWordForward).toHaveBeenCalledWith(1);
+ expect(result.current.mode).toBe('INSERT');
+ });
+ });
+
+ describe('ce (change word end)', () => {
+ it('should change from cursor to end of word and enter INSERT mode', () => {
+ const testBuffer = createMockBuffer('hello world test', [0, 1]);
+ const { result } = renderVimHook(testBuffer);
+
+ act(() => {
+ result.current.handleInput({ sequence: 'c' });
+ });
+ act(() => {
+ result.current.handleInput({ sequence: 'e' });
+ });
+
+ expect(testBuffer.vimChangeWordEnd).toHaveBeenCalledWith(1);
+ expect(result.current.mode).toBe('INSERT');
+ });
+
+ it('should handle count with ce', () => {
+ const testBuffer = createMockBuffer('one two three four', [0, 0]);
+ const { result } = renderVimHook(testBuffer);
+
+ act(() => {
+ result.current.handleInput({ sequence: '2' });
+ });
+ act(() => {
+ result.current.handleInput({ sequence: 'c' });
+ });
+ act(() => {
+ result.current.handleInput({ sequence: 'e' });
+ });
+
+ expect(testBuffer.vimChangeWordEnd).toHaveBeenCalledWith(2);
+ expect(result.current.mode).toBe('INSERT');
+ });
+ });
+
+ describe('cc (change line)', () => {
+ it('should change entire line and enter INSERT mode', () => {
+ const testBuffer = createMockBuffer('hello world\nsecond line', [0, 5]);
+ const { result } = renderVimHook(testBuffer);
+
+ act(() => {
+ result.current.handleInput({ sequence: 'c' });
+ });
+ act(() => {
+ result.current.handleInput({ sequence: 'c' });
+ });
+
+ expect(testBuffer.vimChangeLine).toHaveBeenCalledWith(1);
+ expect(result.current.mode).toBe('INSERT');
+ });
+
+ it('should change multiple lines with count', () => {
+ const testBuffer = createMockBuffer(
+ 'line1\nline2\nline3\nline4',
+ [1, 0],
+ );
+ const { result } = renderVimHook(testBuffer);
+
+ act(() => {
+ result.current.handleInput({ sequence: '3' });
+ });
+ act(() => {
+ result.current.handleInput({ sequence: 'c' });
+ });
+ act(() => {
+ result.current.handleInput({ sequence: 'c' });
+ });
+
+ expect(testBuffer.vimChangeLine).toHaveBeenCalledWith(3);
+ expect(result.current.mode).toBe('INSERT');
+ });
+
+ it('should be repeatable with dot', () => {
+ const testBuffer = createMockBuffer('line1\nline2\nline3', [0, 0]);
+ const { result } = renderVimHook(testBuffer);
+
+ // Execute cc
+ act(() => {
+ result.current.handleInput({ sequence: 'c' });
+ });
+ act(() => {
+ result.current.handleInput({ sequence: 'c' });
+ });
+
+ // Exit INSERT mode
+ exitInsertMode(result);
+
+ vi.clearAllMocks();
+ mockVimContext.setVimMode.mockClear();
+
+ // Execute dot repeat
+ act(() => {
+ result.current.handleInput({ sequence: '.' });
+ });
+
+ expect(testBuffer.vimChangeLine).toHaveBeenCalledWith(1);
+ expect(result.current.mode).toBe('INSERT');
+ });
+ });
+
+ describe('db (delete word backward)', () => {
+ it('should delete from cursor to start of previous word', () => {
+ const testBuffer = createMockBuffer('hello world test', [0, 11]);
+ const { result } = renderVimHook(testBuffer);
+
+ act(() => {
+ result.current.handleInput({ sequence: 'd' });
+ });
+ act(() => {
+ result.current.handleInput({ sequence: 'b' });
+ });
+
+ expect(testBuffer.vimDeleteWordBackward).toHaveBeenCalledWith(1);
+ });
+
+ it('should handle count with db', () => {
+ const testBuffer = createMockBuffer('one two three four', [0, 18]);
+ const { result } = renderVimHook(testBuffer);
+
+ act(() => {
+ result.current.handleInput({ sequence: '2' });
+ });
+ act(() => {
+ result.current.handleInput({ sequence: 'd' });
+ });
+ act(() => {
+ result.current.handleInput({ sequence: 'b' });
+ });
+
+ expect(testBuffer.vimDeleteWordBackward).toHaveBeenCalledWith(2);
+ });
+ });
+
+ describe('cb (change word backward)', () => {
+ it('should change from cursor to start of previous word and enter INSERT mode', () => {
+ const testBuffer = createMockBuffer('hello world test', [0, 11]);
+ const { result } = renderVimHook(testBuffer);
+
+ act(() => {
+ result.current.handleInput({ sequence: 'c' });
+ });
+ act(() => {
+ result.current.handleInput({ sequence: 'b' });
+ });
+
+ expect(testBuffer.vimChangeWordBackward).toHaveBeenCalledWith(1);
+ expect(result.current.mode).toBe('INSERT');
+ });
+
+ it('should handle count with cb', () => {
+ const testBuffer = createMockBuffer('one two three four', [0, 18]);
+ const { result } = renderVimHook(testBuffer);
+
+ act(() => {
+ result.current.handleInput({ sequence: '3' });
+ });
+ act(() => {
+ result.current.handleInput({ sequence: 'c' });
+ });
+ act(() => {
+ result.current.handleInput({ sequence: 'b' });
+ });
+
+ expect(testBuffer.vimChangeWordBackward).toHaveBeenCalledWith(3);
+ expect(result.current.mode).toBe('INSERT');
+ });
+ });
+
+ describe('Pending state handling', () => {
+ it('should clear pending delete state after dw', () => {
+ const testBuffer = createMockBuffer('hello world', [0, 0]);
+ const { result } = renderVimHook(testBuffer);
+
+ // Press 'd' to enter pending delete state
+ act(() => {
+ result.current.handleInput({ sequence: 'd' });
+ });
+
+ // Complete with 'w'
+ act(() => {
+ result.current.handleInput({ sequence: 'w' });
+ });
+
+ // Next 'd' should start a new pending state, not continue the previous one
+ act(() => {
+ result.current.handleInput({ sequence: 'd' });
+ });
+
+ // This should trigger dd (delete line), not an error
+ act(() => {
+ result.current.handleInput({ sequence: 'd' });
+ });
+
+ expect(testBuffer.vimDeleteLine).toHaveBeenCalledWith(1);
+ });
+
+ it('should clear pending change state after cw', () => {
+ const testBuffer = createMockBuffer('hello world', [0, 0]);
+ const { result } = renderVimHook(testBuffer);
+
+ // Execute cw
+ act(() => {
+ result.current.handleInput({ sequence: 'c' });
+ });
+ act(() => {
+ result.current.handleInput({ sequence: 'w' });
+ });
+
+ // Exit INSERT mode
+ exitInsertMode(result);
+
+ // Next 'c' should start a new pending state
+ act(() => {
+ result.current.handleInput({ sequence: 'c' });
+ });
+ act(() => {
+ result.current.handleInput({ sequence: 'c' });
+ });
+
+ expect(testBuffer.vimChangeLine).toHaveBeenCalledWith(1);
+ });
+
+ it('should clear pending state with escape', () => {
+ const testBuffer = createMockBuffer('hello world', [0, 0]);
+ const { result } = renderVimHook(testBuffer);
+
+ // Enter pending delete state
+ act(() => {
+ result.current.handleInput({ sequence: 'd' });
+ });
+
+ // Press escape to clear pending state
+ exitInsertMode(result);
+
+ // Now 'w' should just move cursor, not delete
+ act(() => {
+ result.current.handleInput({ sequence: 'w' });
+ });
+
+ expect(testBuffer.vimDeleteWordForward).not.toHaveBeenCalled();
+ // w should move to next word after clearing pending state
+ expect(testBuffer.vimMoveWordForward).toHaveBeenCalledWith(1);
+ });
+ });
+ });
+
+ // Line operations (dd, cc) are tested in text-buffer.test.ts
+
+ describe('Reducer-based integration tests', () => {
+ describe('de (delete word end)', () => {
+ it('should delete from cursor to end of current word', () => {
+ const initialState = {
+ lines: ['hello world test'],
+ cursorRow: 0,
+ cursorCol: 1, // cursor on 'e' in "hello"
+ preferredCol: null,
+ undoStack: [],
+ redoStack: [],
+ clipboard: null,
+ selectionAnchor: null,
+ };
+
+ const result = textBufferReducer(initialState, {
+ type: 'vim_delete_word_end',
+ payload: { count: 1 },
+ });
+
+ // Should delete "ello" (from cursor to end of word), leaving "h world test"
+ expect(result.lines).toEqual(['h world test']);
+ expect(result.cursorRow).toBe(0);
+ expect(result.cursorCol).toBe(1);
+ });
+
+ it('should delete multiple word ends with count', () => {
+ const initialState = {
+ lines: ['hello world test more'],
+ cursorRow: 0,
+ cursorCol: 1, // cursor on 'e' in "hello"
+ preferredCol: null,
+ undoStack: [],
+ redoStack: [],
+ clipboard: null,
+ selectionAnchor: null,
+ };
+
+ const result = textBufferReducer(initialState, {
+ type: 'vim_delete_word_end',
+ payload: { count: 2 },
+ });
+
+ // Should delete "ello world" (to end of second word), leaving "h test more"
+ expect(result.lines).toEqual(['h test more']);
+ expect(result.cursorRow).toBe(0);
+ expect(result.cursorCol).toBe(1);
+ });
+ });
+
+ describe('db (delete word backward)', () => {
+ it('should delete from cursor to start of previous word', () => {
+ const initialState = {
+ lines: ['hello world test'],
+ cursorRow: 0,
+ cursorCol: 11, // cursor on 't' in "test"
+ preferredCol: null,
+ undoStack: [],
+ redoStack: [],
+ clipboard: null,
+ selectionAnchor: null,
+ };
+
+ const result = textBufferReducer(initialState, {
+ type: 'vim_delete_word_backward',
+ payload: { count: 1 },
+ });
+
+ // Should delete "world" (previous word only), leaving "hello test"
+ expect(result.lines).toEqual(['hello test']);
+ expect(result.cursorRow).toBe(0);
+ expect(result.cursorCol).toBe(6);
+ });
+
+ it('should delete multiple words backward with count', () => {
+ const initialState = {
+ lines: ['hello world test more'],
+ cursorRow: 0,
+ cursorCol: 17, // cursor on 'm' in "more"
+ preferredCol: null,
+ undoStack: [],
+ redoStack: [],
+ clipboard: null,
+ selectionAnchor: null,
+ };
+
+ const result = textBufferReducer(initialState, {
+ type: 'vim_delete_word_backward',
+ payload: { count: 2 },
+ });
+
+ // Should delete "world test " (two words backward), leaving "hello more"
+ expect(result.lines).toEqual(['hello more']);
+ expect(result.cursorRow).toBe(0);
+ expect(result.cursorCol).toBe(6);
+ });
+ });
+
+ describe('cw (change word forward)', () => {
+ it('should delete from cursor to start of next word', () => {
+ const initialState = {
+ lines: ['hello world test'],
+ cursorRow: 0,
+ cursorCol: 0, // cursor on 'h' in "hello"
+ preferredCol: null,
+ undoStack: [],
+ redoStack: [],
+ clipboard: null,
+ selectionAnchor: null,
+ };
+
+ const result = textBufferReducer(initialState, {
+ type: 'vim_change_word_forward',
+ payload: { count: 1 },
+ });
+
+ // Should delete "hello " (word + space), leaving "world test"
+ expect(result.lines).toEqual(['world test']);
+ expect(result.cursorRow).toBe(0);
+ expect(result.cursorCol).toBe(0);
+ });
+
+ it('should change multiple words with count', () => {
+ const initialState = {
+ lines: ['hello world test more'],
+ cursorRow: 0,
+ cursorCol: 0,
+ preferredCol: null,
+ undoStack: [],
+ redoStack: [],
+ clipboard: null,
+ selectionAnchor: null,
+ };
+
+ const result = textBufferReducer(initialState, {
+ type: 'vim_change_word_forward',
+ payload: { count: 2 },
+ });
+
+ // Should delete "hello world " (two words), leaving "test more"
+ expect(result.lines).toEqual(['test more']);
+ expect(result.cursorRow).toBe(0);
+ expect(result.cursorCol).toBe(0);
+ });
+ });
+
+ describe('ce (change word end)', () => {
+ it('should change from cursor to end of current word', () => {
+ const initialState = {
+ lines: ['hello world test'],
+ cursorRow: 0,
+ cursorCol: 1, // cursor on 'e' in "hello"
+ preferredCol: null,
+ undoStack: [],
+ redoStack: [],
+ clipboard: null,
+ selectionAnchor: null,
+ };
+
+ const result = textBufferReducer(initialState, {
+ type: 'vim_change_word_end',
+ payload: { count: 1 },
+ });
+
+ // Should delete "ello" (from cursor to end of word), leaving "h world test"
+ expect(result.lines).toEqual(['h world test']);
+ expect(result.cursorRow).toBe(0);
+ expect(result.cursorCol).toBe(1);
+ });
+
+ it('should change multiple word ends with count', () => {
+ const initialState = {
+ lines: ['hello world test'],
+ cursorRow: 0,
+ cursorCol: 1, // cursor on 'e' in "hello"
+ preferredCol: null,
+ undoStack: [],
+ redoStack: [],
+ clipboard: null,
+ selectionAnchor: null,
+ };
+
+ const result = textBufferReducer(initialState, {
+ type: 'vim_change_word_end',
+ payload: { count: 2 },
+ });
+
+ // Should delete "ello world" (to end of second word), leaving "h test"
+ expect(result.lines).toEqual(['h test']);
+ expect(result.cursorRow).toBe(0);
+ expect(result.cursorCol).toBe(1);
+ });
+ });
+
+ describe('cb (change word backward)', () => {
+ it('should change from cursor to start of previous word', () => {
+ const initialState = {
+ lines: ['hello world test'],
+ cursorRow: 0,
+ cursorCol: 11, // cursor on 't' in "test"
+ preferredCol: null,
+ undoStack: [],
+ redoStack: [],
+ clipboard: null,
+ selectionAnchor: null,
+ };
+
+ const result = textBufferReducer(initialState, {
+ type: 'vim_change_word_backward',
+ payload: { count: 1 },
+ });
+
+ // Should delete "world" (previous word only), leaving "hello test"
+ expect(result.lines).toEqual(['hello test']);
+ expect(result.cursorRow).toBe(0);
+ expect(result.cursorCol).toBe(6);
+ });
+ });
+
+ describe('cc (change line)', () => {
+ it('should clear the line and place cursor at the start', () => {
+ const initialState = {
+ lines: [' hello world'],
+ cursorRow: 0,
+ cursorCol: 5, // cursor on 'o'
+ preferredCol: null,
+ undoStack: [],
+ redoStack: [],
+ clipboard: null,
+ selectionAnchor: null,
+ };
+
+ const result = textBufferReducer(initialState, {
+ type: 'vim_change_line',
+ payload: { count: 1 },
+ });
+
+ expect(result.lines).toEqual(['']);
+ expect(result.cursorRow).toBe(0);
+ expect(result.cursorCol).toBe(0);
+ });
+ });
+
+ describe('dd (delete line)', () => {
+ it('should delete the current line', () => {
+ const initialState = {
+ lines: ['line1', 'line2', 'line3'],
+ cursorRow: 1,
+ cursorCol: 2,
+ preferredCol: null,
+ undoStack: [],
+ redoStack: [],
+ clipboard: null,
+ selectionAnchor: null,
+ };
+
+ const result = textBufferReducer(initialState, {
+ type: 'vim_delete_line',
+ payload: { count: 1 },
+ });
+
+ expect(result.lines).toEqual(['line1', 'line3']);
+ expect(result.cursorRow).toBe(1);
+ expect(result.cursorCol).toBe(0);
+ });
+
+ it('should delete multiple lines with count', () => {
+ const initialState = {
+ lines: ['line1', 'line2', 'line3', 'line4'],
+ cursorRow: 1,
+ cursorCol: 2,
+ preferredCol: null,
+ undoStack: [],
+ redoStack: [],
+ clipboard: null,
+ selectionAnchor: null,
+ };
+
+ const result = textBufferReducer(initialState, {
+ type: 'vim_delete_line',
+ payload: { count: 2 },
+ });
+
+ // Should delete lines 1 and 2
+ expect(result.lines).toEqual(['line1', 'line4']);
+ expect(result.cursorRow).toBe(1);
+ expect(result.cursorCol).toBe(0);
+ });
+
+ it('should handle deleting last line', () => {
+ const initialState = {
+ lines: ['only line'],
+ cursorRow: 0,
+ cursorCol: 3,
+ preferredCol: null,
+ undoStack: [],
+ redoStack: [],
+ clipboard: null,
+ selectionAnchor: null,
+ };
+
+ const result = textBufferReducer(initialState, {
+ type: 'vim_delete_line',
+ payload: { count: 1 },
+ });
+
+ // Should leave an empty line when deleting the only line
+ expect(result.lines).toEqual(['']);
+ expect(result.cursorRow).toBe(0);
+ expect(result.cursorCol).toBe(0);
+ });
+ });
+
+ describe('D (delete to end of line)', () => {
+ it('should delete from cursor to end of line', () => {
+ const initialState = {
+ lines: ['hello world test'],
+ cursorRow: 0,
+ cursorCol: 6, // cursor on 'w' in "world"
+ preferredCol: null,
+ undoStack: [],
+ redoStack: [],
+ clipboard: null,
+ selectionAnchor: null,
+ };
+
+ const result = textBufferReducer(initialState, {
+ type: 'vim_delete_to_end_of_line',
+ });
+
+ // Should delete "world test", leaving "hello "
+ expect(result.lines).toEqual(['hello ']);
+ expect(result.cursorRow).toBe(0);
+ expect(result.cursorCol).toBe(6);
+ });
+
+ it('should handle D at end of line', () => {
+ const initialState = {
+ lines: ['hello world'],
+ cursorRow: 0,
+ cursorCol: 11, // cursor at end
+ preferredCol: null,
+ undoStack: [],
+ redoStack: [],
+ clipboard: null,
+ selectionAnchor: null,
+ };
+
+ const result = textBufferReducer(initialState, {
+ type: 'vim_delete_to_end_of_line',
+ });
+
+ // Should not change anything when at end of line
+ expect(result.lines).toEqual(['hello world']);
+ expect(result.cursorRow).toBe(0);
+ expect(result.cursorCol).toBe(11);
+ });
+ });
+
+ describe('C (change to end of line)', () => {
+ it('should change from cursor to end of line', () => {
+ const initialState = {
+ lines: ['hello world test'],
+ cursorRow: 0,
+ cursorCol: 6, // cursor on 'w' in "world"
+ preferredCol: null,
+ undoStack: [],
+ redoStack: [],
+ clipboard: null,
+ selectionAnchor: null,
+ };
+
+ const result = textBufferReducer(initialState, {
+ type: 'vim_change_to_end_of_line',
+ });
+
+ // Should delete "world test", leaving "hello "
+ expect(result.lines).toEqual(['hello ']);
+ expect(result.cursorRow).toBe(0);
+ expect(result.cursorCol).toBe(6);
+ });
+
+ it('should handle C at beginning of line', () => {
+ const initialState = {
+ lines: ['hello world'],
+ cursorRow: 0,
+ cursorCol: 0,
+ preferredCol: null,
+ undoStack: [],
+ redoStack: [],
+ clipboard: null,
+ selectionAnchor: null,
+ };
+
+ const result = textBufferReducer(initialState, {
+ type: 'vim_change_to_end_of_line',
+ });
+
+ // Should delete entire line content
+ expect(result.lines).toEqual(['']);
+ expect(result.cursorRow).toBe(0);
+ expect(result.cursorCol).toBe(0);
+ });
+ });
+ });
+});
diff --git a/packages/cli/src/ui/hooks/vim.ts b/packages/cli/src/ui/hooks/vim.ts
new file mode 100644
index 00000000..cb65e1ee
--- /dev/null
+++ b/packages/cli/src/ui/hooks/vim.ts
@@ -0,0 +1,774 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { useCallback, useReducer, useEffect } from 'react';
+import type { Key } from './useKeypress.js';
+import type { TextBuffer } from '../components/shared/text-buffer.js';
+import { useVimMode } from '../contexts/VimModeContext.js';
+
+export type VimMode = 'NORMAL' | 'INSERT';
+
+// Constants
+const DIGIT_MULTIPLIER = 10;
+const DEFAULT_COUNT = 1;
+const DIGIT_1_TO_9 = /^[1-9]$/;
+
+// Command types
+const CMD_TYPES = {
+ DELETE_WORD_FORWARD: 'dw',
+ DELETE_WORD_BACKWARD: 'db',
+ DELETE_WORD_END: 'de',
+ CHANGE_WORD_FORWARD: 'cw',
+ CHANGE_WORD_BACKWARD: 'cb',
+ CHANGE_WORD_END: 'ce',
+ DELETE_CHAR: 'x',
+ DELETE_LINE: 'dd',
+ CHANGE_LINE: 'cc',
+ DELETE_TO_EOL: 'D',
+ CHANGE_TO_EOL: 'C',
+ CHANGE_MOVEMENT: {
+ LEFT: 'ch',
+ DOWN: 'cj',
+ UP: 'ck',
+ RIGHT: 'cl',
+ },
+} as const;
+
+// Helper function to clear pending state
+const createClearPendingState = () => ({
+ count: 0,
+ pendingOperator: null as 'g' | 'd' | 'c' | null,
+});
+
+// State and action types for useReducer
+type VimState = {
+ mode: VimMode;
+ count: number;
+ pendingOperator: 'g' | 'd' | 'c' | null;
+ lastCommand: { type: string; count: number } | null;
+};
+
+type VimAction =
+ | { type: 'SET_MODE'; mode: VimMode }
+ | { type: 'SET_COUNT'; count: number }
+ | { type: 'INCREMENT_COUNT'; digit: number }
+ | { type: 'CLEAR_COUNT' }
+ | { type: 'SET_PENDING_OPERATOR'; operator: 'g' | 'd' | 'c' | null }
+ | {
+ type: 'SET_LAST_COMMAND';
+ command: { type: string; count: number } | null;
+ }
+ | { type: 'CLEAR_PENDING_STATES' }
+ | { type: 'ESCAPE_TO_NORMAL' };
+
+const initialVimState: VimState = {
+ mode: 'NORMAL',
+ count: 0,
+ pendingOperator: null,
+ lastCommand: null,
+};
+
+// Reducer function
+const vimReducer = (state: VimState, action: VimAction): VimState => {
+ switch (action.type) {
+ case 'SET_MODE':
+ return { ...state, mode: action.mode };
+
+ case 'SET_COUNT':
+ return { ...state, count: action.count };
+
+ case 'INCREMENT_COUNT':
+ return { ...state, count: state.count * DIGIT_MULTIPLIER + action.digit };
+
+ case 'CLEAR_COUNT':
+ return { ...state, count: 0 };
+
+ case 'SET_PENDING_OPERATOR':
+ return { ...state, pendingOperator: action.operator };
+
+ case 'SET_LAST_COMMAND':
+ return { ...state, lastCommand: action.command };
+
+ case 'CLEAR_PENDING_STATES':
+ return {
+ ...state,
+ ...createClearPendingState(),
+ };
+
+ case 'ESCAPE_TO_NORMAL':
+ // Handle escape - clear all pending states (mode is updated via context)
+ return {
+ ...state,
+ ...createClearPendingState(),
+ };
+
+ default:
+ return state;
+ }
+};
+
+/**
+ * React hook that provides vim-style editing functionality for text input.
+ *
+ * Features:
+ * - Modal editing (INSERT/NORMAL modes)
+ * - Navigation: h,j,k,l,w,b,e,0,$,^,gg,G with count prefixes
+ * - Editing: x,a,i,o,O,A,I,d,c,D,C with count prefixes
+ * - Complex operations: dd,cc,dw,cw,db,cb,de,ce
+ * - Command repetition (.)
+ * - Settings persistence
+ *
+ * @param buffer - TextBuffer instance for text manipulation
+ * @param onSubmit - Optional callback for command submission
+ * @returns Object with vim state and input handler
+ */
+export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
+ const { vimEnabled, vimMode, setVimMode } = useVimMode();
+ const [state, dispatch] = useReducer(vimReducer, initialVimState);
+
+ // Sync vim mode from context to local state
+ useEffect(() => {
+ dispatch({ type: 'SET_MODE', mode: vimMode });
+ }, [vimMode]);
+
+ // Helper to update mode in both reducer and context
+ const updateMode = useCallback(
+ (mode: VimMode) => {
+ setVimMode(mode);
+ dispatch({ type: 'SET_MODE', mode });
+ },
+ [setVimMode],
+ );
+
+ // Helper functions using the reducer state
+ const getCurrentCount = useCallback(
+ () => state.count || DEFAULT_COUNT,
+ [state.count],
+ );
+
+ /** Executes common commands to eliminate duplication in dot (.) repeat command */
+ const executeCommand = useCallback(
+ (cmdType: string, count: number) => {
+ switch (cmdType) {
+ case CMD_TYPES.DELETE_WORD_FORWARD: {
+ buffer.vimDeleteWordForward(count);
+ break;
+ }
+
+ case CMD_TYPES.DELETE_WORD_BACKWARD: {
+ buffer.vimDeleteWordBackward(count);
+ break;
+ }
+
+ case CMD_TYPES.DELETE_WORD_END: {
+ buffer.vimDeleteWordEnd(count);
+ break;
+ }
+
+ case CMD_TYPES.CHANGE_WORD_FORWARD: {
+ buffer.vimChangeWordForward(count);
+ updateMode('INSERT');
+ break;
+ }
+
+ case CMD_TYPES.CHANGE_WORD_BACKWARD: {
+ buffer.vimChangeWordBackward(count);
+ updateMode('INSERT');
+ break;
+ }
+
+ case CMD_TYPES.CHANGE_WORD_END: {
+ buffer.vimChangeWordEnd(count);
+ updateMode('INSERT');
+ break;
+ }
+
+ case CMD_TYPES.DELETE_CHAR: {
+ buffer.vimDeleteChar(count);
+ break;
+ }
+
+ case CMD_TYPES.DELETE_LINE: {
+ buffer.vimDeleteLine(count);
+ break;
+ }
+
+ case CMD_TYPES.CHANGE_LINE: {
+ buffer.vimChangeLine(count);
+ updateMode('INSERT');
+ break;
+ }
+
+ case CMD_TYPES.CHANGE_MOVEMENT.LEFT:
+ case CMD_TYPES.CHANGE_MOVEMENT.DOWN:
+ case CMD_TYPES.CHANGE_MOVEMENT.UP:
+ case CMD_TYPES.CHANGE_MOVEMENT.RIGHT: {
+ const movementMap: Record<string, 'h' | 'j' | 'k' | 'l'> = {
+ [CMD_TYPES.CHANGE_MOVEMENT.LEFT]: 'h',
+ [CMD_TYPES.CHANGE_MOVEMENT.DOWN]: 'j',
+ [CMD_TYPES.CHANGE_MOVEMENT.UP]: 'k',
+ [CMD_TYPES.CHANGE_MOVEMENT.RIGHT]: 'l',
+ };
+ const movementType = movementMap[cmdType];
+ if (movementType) {
+ buffer.vimChangeMovement(movementType, count);
+ updateMode('INSERT');
+ }
+ break;
+ }
+
+ case CMD_TYPES.DELETE_TO_EOL: {
+ buffer.vimDeleteToEndOfLine();
+ break;
+ }
+
+ case CMD_TYPES.CHANGE_TO_EOL: {
+ buffer.vimChangeToEndOfLine();
+ updateMode('INSERT');
+ break;
+ }
+
+ default:
+ return false;
+ }
+ return true;
+ },
+ [buffer, updateMode],
+ );
+
+ /**
+ * Handles key input in INSERT mode
+ * @param normalizedKey - The normalized key input
+ * @returns boolean indicating if the key was handled
+ */
+ const handleInsertModeInput = useCallback(
+ (normalizedKey: Key): boolean => {
+ // Handle escape key immediately - switch to NORMAL mode on any escape
+ if (normalizedKey.name === 'escape') {
+ // Vim behavior: move cursor left when exiting insert mode (unless at beginning of line)
+ buffer.vimEscapeInsertMode();
+ dispatch({ type: 'ESCAPE_TO_NORMAL' });
+ updateMode('NORMAL');
+ return true;
+ }
+
+ // In INSERT mode, let InputPrompt handle completion keys and special commands
+ if (
+ normalizedKey.name === 'tab' ||
+ (normalizedKey.name === 'return' && !normalizedKey.ctrl) ||
+ normalizedKey.name === 'up' ||
+ normalizedKey.name === 'down'
+ ) {
+ return false; // Let InputPrompt handle completion
+ }
+
+ // Let InputPrompt handle Ctrl+V for clipboard image pasting
+ if (normalizedKey.ctrl && normalizedKey.name === 'v') {
+ return false; // Let InputPrompt handle clipboard functionality
+ }
+
+ // Special handling for Enter key to allow command submission (lower priority than completion)
+ if (
+ normalizedKey.name === 'return' &&
+ !normalizedKey.ctrl &&
+ !normalizedKey.meta
+ ) {
+ if (buffer.text.trim() && onSubmit) {
+ // Handle command submission directly
+ const submittedValue = buffer.text;
+ buffer.setText('');
+ onSubmit(submittedValue);
+ return true;
+ }
+ return true; // Handled by vim (even if no onSubmit callback)
+ }
+
+ // useKeypress already provides the correct format for TextBuffer
+ buffer.handleInput(normalizedKey);
+ return true; // Handled by vim
+ },
+ [buffer, dispatch, updateMode, onSubmit],
+ );
+
+ /**
+ * Normalizes key input to ensure all required properties are present
+ * @param key - Raw key input
+ * @returns Normalized key with all properties
+ */
+ const normalizeKey = useCallback(
+ (key: Key): Key => ({
+ name: key.name || '',
+ sequence: key.sequence || '',
+ ctrl: key.ctrl || false,
+ meta: key.meta || false,
+ shift: key.shift || false,
+ paste: key.paste || false,
+ }),
+ [],
+ );
+
+ /**
+ * Handles change movement commands (ch, cj, ck, cl)
+ * @param movement - The movement direction
+ * @returns boolean indicating if command was handled
+ */
+ const handleChangeMovement = useCallback(
+ (movement: 'h' | 'j' | 'k' | 'l'): boolean => {
+ const count = getCurrentCount();
+ dispatch({ type: 'CLEAR_COUNT' });
+ buffer.vimChangeMovement(movement, count);
+ updateMode('INSERT');
+
+ const cmdTypeMap = {
+ h: CMD_TYPES.CHANGE_MOVEMENT.LEFT,
+ j: CMD_TYPES.CHANGE_MOVEMENT.DOWN,
+ k: CMD_TYPES.CHANGE_MOVEMENT.UP,
+ l: CMD_TYPES.CHANGE_MOVEMENT.RIGHT,
+ };
+
+ dispatch({
+ type: 'SET_LAST_COMMAND',
+ command: { type: cmdTypeMap[movement], count },
+ });
+ dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
+ return true;
+ },
+ [getCurrentCount, dispatch, buffer, updateMode],
+ );
+
+ /**
+ * Handles operator-motion commands (dw/cw, db/cb, de/ce)
+ * @param operator - The operator type ('d' for delete, 'c' for change)
+ * @param motion - The motion type ('w', 'b', 'e')
+ * @returns boolean indicating if command was handled
+ */
+ const handleOperatorMotion = useCallback(
+ (operator: 'd' | 'c', motion: 'w' | 'b' | 'e'): boolean => {
+ const count = getCurrentCount();
+
+ const commandMap = {
+ d: {
+ w: CMD_TYPES.DELETE_WORD_FORWARD,
+ b: CMD_TYPES.DELETE_WORD_BACKWARD,
+ e: CMD_TYPES.DELETE_WORD_END,
+ },
+ c: {
+ w: CMD_TYPES.CHANGE_WORD_FORWARD,
+ b: CMD_TYPES.CHANGE_WORD_BACKWARD,
+ e: CMD_TYPES.CHANGE_WORD_END,
+ },
+ };
+
+ const cmdType = commandMap[operator][motion];
+ executeCommand(cmdType, count);
+
+ dispatch({
+ type: 'SET_LAST_COMMAND',
+ command: { type: cmdType, count },
+ });
+ dispatch({ type: 'CLEAR_COUNT' });
+ dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
+
+ return true;
+ },
+ [getCurrentCount, executeCommand, dispatch],
+ );
+
+ const handleInput = useCallback(
+ (key: Key): boolean => {
+ if (!vimEnabled) {
+ return false; // Let InputPrompt handle it
+ }
+
+ let normalizedKey: Key;
+ try {
+ normalizedKey = normalizeKey(key);
+ } catch (error) {
+ // Handle malformed key inputs gracefully
+ console.warn('Malformed key input in vim mode:', key, error);
+ return false;
+ }
+
+ // Handle INSERT mode
+ if (state.mode === 'INSERT') {
+ return handleInsertModeInput(normalizedKey);
+ }
+
+ // Handle NORMAL mode
+ if (state.mode === 'NORMAL') {
+ // Handle Escape key in NORMAL mode - clear all pending states
+ if (normalizedKey.name === 'escape') {
+ dispatch({ type: 'CLEAR_PENDING_STATES' });
+ return true; // Handled by vim
+ }
+
+ // Handle count input (numbers 1-9, and 0 if count > 0)
+ if (
+ DIGIT_1_TO_9.test(normalizedKey.sequence) ||
+ (normalizedKey.sequence === '0' && state.count > 0)
+ ) {
+ dispatch({
+ type: 'INCREMENT_COUNT',
+ digit: parseInt(normalizedKey.sequence, 10),
+ });
+ return true; // Handled by vim
+ }
+
+ const repeatCount = getCurrentCount();
+
+ switch (normalizedKey.sequence) {
+ case 'h': {
+ // Check if this is part of a change command (ch)
+ if (state.pendingOperator === 'c') {
+ return handleChangeMovement('h');
+ }
+
+ // Normal left movement
+ buffer.vimMoveLeft(repeatCount);
+ dispatch({ type: 'CLEAR_COUNT' });
+ return true;
+ }
+
+ case 'j': {
+ // Check if this is part of a change command (cj)
+ if (state.pendingOperator === 'c') {
+ return handleChangeMovement('j');
+ }
+
+ // Normal down movement
+ buffer.vimMoveDown(repeatCount);
+ dispatch({ type: 'CLEAR_COUNT' });
+ return true;
+ }
+
+ case 'k': {
+ // Check if this is part of a change command (ck)
+ if (state.pendingOperator === 'c') {
+ return handleChangeMovement('k');
+ }
+
+ // Normal up movement
+ buffer.vimMoveUp(repeatCount);
+ dispatch({ type: 'CLEAR_COUNT' });
+ return true;
+ }
+
+ case 'l': {
+ // Check if this is part of a change command (cl)
+ if (state.pendingOperator === 'c') {
+ return handleChangeMovement('l');
+ }
+
+ // Normal right movement
+ buffer.vimMoveRight(repeatCount);
+ dispatch({ type: 'CLEAR_COUNT' });
+ return true;
+ }
+
+ case 'w': {
+ // Check if this is part of a delete or change command (dw/cw)
+ if (state.pendingOperator === 'd') {
+ return handleOperatorMotion('d', 'w');
+ }
+ if (state.pendingOperator === 'c') {
+ return handleOperatorMotion('c', 'w');
+ }
+
+ // Normal word movement
+ buffer.vimMoveWordForward(repeatCount);
+ dispatch({ type: 'CLEAR_COUNT' });
+ return true;
+ }
+
+ case 'b': {
+ // Check if this is part of a delete or change command (db/cb)
+ if (state.pendingOperator === 'd') {
+ return handleOperatorMotion('d', 'b');
+ }
+ if (state.pendingOperator === 'c') {
+ return handleOperatorMotion('c', 'b');
+ }
+
+ // Normal backward word movement
+ buffer.vimMoveWordBackward(repeatCount);
+ dispatch({ type: 'CLEAR_COUNT' });
+ return true;
+ }
+
+ case 'e': {
+ // Check if this is part of a delete or change command (de/ce)
+ if (state.pendingOperator === 'd') {
+ return handleOperatorMotion('d', 'e');
+ }
+ if (state.pendingOperator === 'c') {
+ return handleOperatorMotion('c', 'e');
+ }
+
+ // Normal word end movement
+ buffer.vimMoveWordEnd(repeatCount);
+ dispatch({ type: 'CLEAR_COUNT' });
+ return true;
+ }
+
+ case 'x': {
+ // Delete character under cursor
+ buffer.vimDeleteChar(repeatCount);
+ dispatch({
+ type: 'SET_LAST_COMMAND',
+ command: { type: CMD_TYPES.DELETE_CHAR, count: repeatCount },
+ });
+ dispatch({ type: 'CLEAR_COUNT' });
+ return true;
+ }
+
+ case 'i': {
+ // Enter INSERT mode at current position
+ buffer.vimInsertAtCursor();
+ updateMode('INSERT');
+ dispatch({ type: 'CLEAR_COUNT' });
+ return true;
+ }
+
+ case 'a': {
+ // Enter INSERT mode after current position
+ buffer.vimAppendAtCursor();
+ updateMode('INSERT');
+ dispatch({ type: 'CLEAR_COUNT' });
+ return true;
+ }
+
+ case 'o': {
+ // Insert new line after current line and enter INSERT mode
+ buffer.vimOpenLineBelow();
+ updateMode('INSERT');
+ dispatch({ type: 'CLEAR_COUNT' });
+ return true;
+ }
+
+ case 'O': {
+ // Insert new line before current line and enter INSERT mode
+ buffer.vimOpenLineAbove();
+ updateMode('INSERT');
+ dispatch({ type: 'CLEAR_COUNT' });
+ return true;
+ }
+
+ case '0': {
+ // Move to start of line
+ buffer.vimMoveToLineStart();
+ dispatch({ type: 'CLEAR_COUNT' });
+ return true;
+ }
+
+ case '$': {
+ // Move to end of line
+ buffer.vimMoveToLineEnd();
+ dispatch({ type: 'CLEAR_COUNT' });
+ return true;
+ }
+
+ case '^': {
+ // Move to first non-whitespace character
+ buffer.vimMoveToFirstNonWhitespace();
+ dispatch({ type: 'CLEAR_COUNT' });
+ return true;
+ }
+
+ case 'g': {
+ if (state.pendingOperator === 'g') {
+ // Second 'g' - go to first line (gg command)
+ buffer.vimMoveToFirstLine();
+ dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
+ } else {
+ // First 'g' - wait for second g
+ dispatch({ type: 'SET_PENDING_OPERATOR', operator: 'g' });
+ }
+ dispatch({ type: 'CLEAR_COUNT' });
+ return true;
+ }
+
+ case 'G': {
+ if (state.count > 0) {
+ // Go to specific line number (1-based) when a count was provided
+ buffer.vimMoveToLine(state.count);
+ } else {
+ // Go to last line when no count was provided
+ buffer.vimMoveToLastLine();
+ }
+ dispatch({ type: 'CLEAR_COUNT' });
+ return true;
+ }
+
+ case 'I': {
+ // Enter INSERT mode at start of line (first non-whitespace)
+ buffer.vimInsertAtLineStart();
+ updateMode('INSERT');
+ dispatch({ type: 'CLEAR_COUNT' });
+ return true;
+ }
+
+ case 'A': {
+ // Enter INSERT mode at end of line
+ buffer.vimAppendAtLineEnd();
+ updateMode('INSERT');
+ dispatch({ type: 'CLEAR_COUNT' });
+ return true;
+ }
+
+ case 'd': {
+ if (state.pendingOperator === 'd') {
+ // Second 'd' - delete N lines (dd command)
+ const repeatCount = getCurrentCount();
+ executeCommand(CMD_TYPES.DELETE_LINE, repeatCount);
+ dispatch({
+ type: 'SET_LAST_COMMAND',
+ command: { type: CMD_TYPES.DELETE_LINE, count: repeatCount },
+ });
+ dispatch({ type: 'CLEAR_COUNT' });
+ dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
+ } else {
+ // First 'd' - wait for movement command
+ dispatch({ type: 'SET_PENDING_OPERATOR', operator: 'd' });
+ }
+ return true;
+ }
+
+ case 'c': {
+ if (state.pendingOperator === 'c') {
+ // Second 'c' - change N entire lines (cc command)
+ const repeatCount = getCurrentCount();
+ executeCommand(CMD_TYPES.CHANGE_LINE, repeatCount);
+ dispatch({
+ type: 'SET_LAST_COMMAND',
+ command: { type: CMD_TYPES.CHANGE_LINE, count: repeatCount },
+ });
+ dispatch({ type: 'CLEAR_COUNT' });
+ dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
+ } else {
+ // First 'c' - wait for movement command
+ dispatch({ type: 'SET_PENDING_OPERATOR', operator: 'c' });
+ }
+ return true;
+ }
+
+ case 'D': {
+ // Delete from cursor to end of line (equivalent to d$)
+ executeCommand(CMD_TYPES.DELETE_TO_EOL, 1);
+ dispatch({
+ type: 'SET_LAST_COMMAND',
+ command: { type: CMD_TYPES.DELETE_TO_EOL, count: 1 },
+ });
+ dispatch({ type: 'CLEAR_COUNT' });
+ return true;
+ }
+
+ case 'C': {
+ // Change from cursor to end of line (equivalent to c$)
+ executeCommand(CMD_TYPES.CHANGE_TO_EOL, 1);
+ dispatch({
+ type: 'SET_LAST_COMMAND',
+ command: { type: CMD_TYPES.CHANGE_TO_EOL, count: 1 },
+ });
+ dispatch({ type: 'CLEAR_COUNT' });
+ return true;
+ }
+
+ case '.': {
+ // Repeat last command
+ if (state.lastCommand) {
+ const cmdData = state.lastCommand;
+
+ // All repeatable commands are now handled by executeCommand
+ executeCommand(cmdData.type, cmdData.count);
+ }
+
+ dispatch({ type: 'CLEAR_COUNT' });
+ return true;
+ }
+
+ default: {
+ // Check for arrow keys (they have different sequences but known names)
+ if (normalizedKey.name === 'left') {
+ // Left arrow - same as 'h'
+ if (state.pendingOperator === 'c') {
+ return handleChangeMovement('h');
+ }
+
+ // Normal left movement (same as 'h')
+ buffer.vimMoveLeft(repeatCount);
+ dispatch({ type: 'CLEAR_COUNT' });
+ return true;
+ }
+
+ if (normalizedKey.name === 'down') {
+ // Down arrow - same as 'j'
+ if (state.pendingOperator === 'c') {
+ return handleChangeMovement('j');
+ }
+
+ // Normal down movement (same as 'j')
+ buffer.vimMoveDown(repeatCount);
+ dispatch({ type: 'CLEAR_COUNT' });
+ return true;
+ }
+
+ if (normalizedKey.name === 'up') {
+ // Up arrow - same as 'k'
+ if (state.pendingOperator === 'c') {
+ return handleChangeMovement('k');
+ }
+
+ // Normal up movement (same as 'k')
+ buffer.vimMoveUp(repeatCount);
+ dispatch({ type: 'CLEAR_COUNT' });
+ return true;
+ }
+
+ if (normalizedKey.name === 'right') {
+ // Right arrow - same as 'l'
+ if (state.pendingOperator === 'c') {
+ return handleChangeMovement('l');
+ }
+
+ // Normal right movement (same as 'l')
+ buffer.vimMoveRight(repeatCount);
+ dispatch({ type: 'CLEAR_COUNT' });
+ return true;
+ }
+
+ // Unknown command, clear count and pending states
+ dispatch({ type: 'CLEAR_PENDING_STATES' });
+ return true; // Still handled by vim to prevent other handlers
+ }
+ }
+ }
+
+ return false; // Not handled by vim
+ },
+ [
+ vimEnabled,
+ normalizeKey,
+ handleInsertModeInput,
+ state.mode,
+ state.count,
+ state.pendingOperator,
+ state.lastCommand,
+ dispatch,
+ getCurrentCount,
+ handleChangeMovement,
+ handleOperatorMotion,
+ buffer,
+ executeCommand,
+ updateMode,
+ ],
+ );
+
+ return {
+ mode: state.mode,
+ vimModeEnabled: vimEnabled,
+ handleInput, // Expose the input handler for InputPrompt to use
+ };
+}