diff options
Diffstat (limited to 'packages/cli/src/ui/hooks')
| -rw-r--r-- | packages/cli/src/ui/hooks/slashCommandProcessor.test.ts | 1 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/slashCommandProcessor.ts | 3 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/useKeypress.ts | 7 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/vim.test.ts | 1626 | ||||
| -rw-r--r-- | packages/cli/src/ui/hooks/vim.ts | 774 |
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 + }; +} |
