diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 99a0237ac2..fcff3e305d 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -19,7 +19,7 @@ import { SettingsContext } from '../contexts/SettingsContext.js'; vi.mock('../contexts/VimModeContext.js', () => ({ useVimMode: vi.fn(() => ({ vimEnabled: false, - vimMode: 'NORMAL', + vimMode: 'INSERT', })), })); import { ApprovalMode } from '@google/gemini-cli-core'; @@ -54,7 +54,9 @@ vi.mock('./DetailedMessagesDisplay.js', () => ({ })); vi.mock('./InputPrompt.js', () => ({ - InputPrompt: () => InputPrompt, + InputPrompt: ({ placeholder }: { placeholder?: string }) => ( + InputPrompt: {placeholder} + ), calculatePromptWidths: vi.fn(() => ({ inputWidth: 80, suggestionsWidth: 40, @@ -487,4 +489,40 @@ describe('Composer', () => { expect(lastFrame()).not.toContain('DetailedMessagesDisplay'); }); }); + + describe('Vim Mode Placeholders', () => { + it('shows correct placeholder in INSERT mode', async () => { + const uiState = createMockUIState({ isInputActive: true }); + const { useVimMode } = await import('../contexts/VimModeContext.js'); + vi.mocked(useVimMode).mockReturnValue({ + vimEnabled: true, + vimMode: 'INSERT', + toggleVimEnabled: vi.fn(), + setVimMode: vi.fn(), + }); + + const { lastFrame } = renderComposer(uiState); + + expect(lastFrame()).toContain( + "InputPrompt: Press 'Esc' for NORMAL mode.", + ); + }); + + it('shows correct placeholder in NORMAL mode', async () => { + const uiState = createMockUIState({ isInputActive: true }); + const { useVimMode } = await import('../contexts/VimModeContext.js'); + vi.mocked(useVimMode).mockReturnValue({ + vimEnabled: true, + vimMode: 'NORMAL', + toggleVimEnabled: vi.fn(), + setVimMode: vi.fn(), + }); + + const { lastFrame } = renderComposer(uiState); + + expect(lastFrame()).toContain( + "InputPrompt: Press 'i' for INSERT mode.", + ); + }); + }); }); diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 8f6c807de7..12a899b7b9 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -35,7 +35,7 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { const isScreenReaderEnabled = useIsScreenReaderEnabled(); const uiState = useUIState(); const uiActions = useUIActions(); - const { vimEnabled } = useVimMode(); + const { vimEnabled, vimMode } = useVimMode(); const terminalWidth = process.stdout.columns; const isNarrow = isNarrowWidth(terminalWidth); const debugConsoleMaxHeight = Math.floor(Math.max(terminalWidth * 0.2, 5)); @@ -143,7 +143,9 @@ export const Composer = ({ isFocused = true }: { isFocused?: boolean }) => { popAllMessages={uiActions.popAllMessages} placeholder={ vimEnabled - ? " Press 'i' for INSERT mode and 'Esc' for NORMAL mode." + ? vimMode === 'INSERT' + ? " Press 'Esc' for NORMAL mode." + : " Press 'i' for INSERT mode." : uiState.shellModeActive ? ' Type your shell command' : ' Type your message or @path/to/file' diff --git a/packages/cli/src/ui/components/shared/text-buffer.test.ts b/packages/cli/src/ui/components/shared/text-buffer.test.ts index d8352c662e..16e8c10ee2 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.test.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.test.ts @@ -1418,7 +1418,7 @@ describe('useTextBuffer', () => { const { result } = renderHook(() => useTextBuffer({ viewport, isValidPath: () => false }), ); - act(() => + act(() => { result.current.handleInput({ name: 'h', shift: false, @@ -1427,9 +1427,9 @@ describe('useTextBuffer', () => { cmd: false, insertable: true, sequence: 'h', - }), - ); - act(() => + }); + }); + void act(() => result.current.handleInput({ name: 'i', shift: false, @@ -1447,7 +1447,7 @@ describe('useTextBuffer', () => { const { result } = renderHook(() => useTextBuffer({ viewport, isValidPath: () => false }), ); - act(() => + act(() => { result.current.handleInput({ name: 'return', shift: false, @@ -1456,8 +1456,8 @@ describe('useTextBuffer', () => { cmd: false, insertable: true, sequence: '\r', - }), - ); + }); + }); expect(getBufferState(result).lines).toEqual(['', '']); }); @@ -1465,7 +1465,7 @@ describe('useTextBuffer', () => { const { result } = renderHook(() => useTextBuffer({ viewport, isValidPath: () => false }), ); - act(() => + act(() => { result.current.handleInput({ name: 'j', shift: false, @@ -1474,8 +1474,8 @@ describe('useTextBuffer', () => { cmd: false, insertable: false, sequence: '\n', - }), - ); + }); + }); expect(getBufferState(result).lines).toEqual(['', '']); }); @@ -1483,7 +1483,7 @@ describe('useTextBuffer', () => { const { result } = renderHook(() => useTextBuffer({ viewport, isValidPath: () => false }), ); - act(() => + act(() => { result.current.handleInput({ name: 'tab', shift: false, @@ -1492,8 +1492,8 @@ describe('useTextBuffer', () => { cmd: false, insertable: false, sequence: '\t', - }), - ); + }); + }); expect(getBufferState(result).text).toBe(''); }); @@ -1501,7 +1501,7 @@ describe('useTextBuffer', () => { const { result } = renderHook(() => useTextBuffer({ viewport, isValidPath: () => false }), ); - act(() => + act(() => { result.current.handleInput({ name: 'tab', shift: true, @@ -1510,8 +1510,8 @@ describe('useTextBuffer', () => { cmd: false, insertable: false, sequence: '\u001b[9;2u', - }), - ); + }); + }); expect(getBufferState(result).text).toBe(''); }); @@ -1524,7 +1524,7 @@ describe('useTextBuffer', () => { }), ); act(() => result.current.move('end')); - act(() => + act(() => { result.current.handleInput({ name: 'backspace', shift: false, @@ -1533,8 +1533,8 @@ describe('useTextBuffer', () => { cmd: false, insertable: false, sequence: '\x7f', - }), - ); + }); + }); expect(getBufferState(result).text).toBe(''); }); @@ -1627,7 +1627,7 @@ describe('useTextBuffer', () => { }), ); act(() => result.current.move('end')); // cursor [0,2] - act(() => + act(() => { result.current.handleInput({ name: 'left', shift: false, @@ -1636,10 +1636,10 @@ describe('useTextBuffer', () => { cmd: false, insertable: false, sequence: '\x1b[D', - }), - ); + }); + }); expect(getBufferState(result).cursor).toEqual([0, 1]); - act(() => + act(() => { result.current.handleInput({ name: 'right', shift: false, @@ -1648,8 +1648,8 @@ describe('useTextBuffer', () => { cmd: false, insertable: false, sequence: '\x1b[C', - }), - ); + }); + }); expect(getBufferState(result).cursor).toEqual([0, 2]); }); @@ -1659,7 +1659,7 @@ describe('useTextBuffer', () => { ); const textWithAnsi = '\x1B[31mHello\x1B[0m \x1B[32mWorld\x1B[0m'; // Simulate pasting by calling handleInput with a string longer than 1 char - act(() => + act(() => { result.current.handleInput({ name: '', shift: false, @@ -1668,8 +1668,8 @@ describe('useTextBuffer', () => { cmd: false, insertable: true, sequence: textWithAnsi, - }), - ); + }); + }); expect(getBufferState(result).text).toBe('Hello World'); }); @@ -1677,7 +1677,7 @@ describe('useTextBuffer', () => { const { result } = renderHook(() => useTextBuffer({ viewport, isValidPath: () => false }), ); - act(() => + act(() => { result.current.handleInput({ name: 'return', shift: true, @@ -1686,8 +1686,8 @@ describe('useTextBuffer', () => { cmd: false, insertable: true, sequence: '\r', - }), - ); // Simulates Shift+Enter in VSCode terminal + }); + }); // Simulates Shift+Enter in VSCode terminal expect(getBufferState(result).lines).toEqual(['', '']); }); @@ -1927,7 +1927,9 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots const { result } = renderHook(() => useTextBuffer({ viewport, isValidPath: () => false }), ); - act(() => result.current.handleInput(createInput(input))); + act(() => { + result.current.handleInput(createInput(input)); + }); expect(getBufferState(result).text).toBe(expected); }); @@ -1936,7 +1938,9 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots useTextBuffer({ viewport, isValidPath: () => false }), ); const validText = 'Hello World\nThis is a test.'; - act(() => result.current.handleInput(createInput(validText))); + act(() => { + result.current.handleInput(createInput(validText)); + }); expect(getBufferState(result).text).toBe(validText); }); @@ -1950,7 +1954,7 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots expect(largeTextWithUnsafe.length).toBeGreaterThan(5000); - act(() => + act(() => { result.current.handleInput({ name: '', shift: false, @@ -1959,8 +1963,8 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots cmd: false, insertable: true, sequence: largeTextWithUnsafe, - }), - ); + }); + }); const resultText = getBufferState(result).text; expect(resultText).not.toContain('\x07'); @@ -1985,7 +1989,7 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots expect(largeTextWithAnsi.length).toBeGreaterThan(5000); - act(() => + act(() => { result.current.handleInput({ name: '', shift: false, @@ -1994,8 +1998,8 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots cmd: false, insertable: true, sequence: largeTextWithAnsi, - }), - ); + }); + }); const resultText = getBufferState(result).text; expect(resultText).not.toContain('\x1B[31m'); @@ -2010,7 +2014,7 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots useTextBuffer({ viewport, isValidPath: () => false }), ); const emojis = '🐍🐳🦀🦄'; - act(() => + act(() => { result.current.handleInput({ name: '', shift: false, @@ -2019,8 +2023,8 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots cmd: false, insertable: true, sequence: emojis, - }), - ); + }); + }); expect(getBufferState(result).text).toBe(emojis); }); }); @@ -2202,7 +2206,7 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots singleLine: true, }), ); - act(() => + act(() => { result.current.handleInput({ name: 'return', shift: false, @@ -2211,8 +2215,8 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots cmd: false, insertable: true, sequence: '\r', - }), - ); + }); + }); expect(getBufferState(result).lines).toEqual(['']); }); @@ -2224,7 +2228,7 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots singleLine: true, }), ); - act(() => + act(() => { result.current.handleInput({ name: 'f1', shift: false, @@ -2233,8 +2237,8 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots cmd: false, insertable: false, sequence: '\u001bOP', - }), - ); + }); + }); expect(getBufferState(result).lines).toEqual(['']); }); diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index 5188612585..e40dcdf362 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -3419,7 +3419,7 @@ export interface TextBuffer { /** * High level "handleInput" – receives what Ink gives us. */ - handleInput: (key: Key) => void; + handleInput: (key: Key) => boolean; /** * Opens the current buffer contents in the user's preferred terminal text * editor ($VISUAL or $EDITOR, falling back to "vi"). The method blocks diff --git a/packages/cli/src/ui/contexts/VimModeContext.tsx b/packages/cli/src/ui/contexts/VimModeContext.tsx index 6d53767312..d4495846d2 100644 --- a/packages/cli/src/ui/contexts/VimModeContext.tsx +++ b/packages/cli/src/ui/contexts/VimModeContext.tsx @@ -34,26 +34,24 @@ export const VimModeProvider = ({ }) => { const initialVimEnabled = settings.merged.general.vimMode; const [vimEnabled, setVimEnabled] = useState(initialVimEnabled); - const [vimMode, setVimMode] = useState( - initialVimEnabled ? 'NORMAL' : 'INSERT', - ); + const [vimMode, setVimMode] = useState('INSERT'); useEffect(() => { // Initialize vimEnabled from settings on mount const enabled = settings.merged.general.vimMode; setVimEnabled(enabled); - // When vim mode is enabled, always start in NORMAL mode + // When vim mode is enabled, start in INSERT mode if (enabled) { - setVimMode('NORMAL'); + setVimMode('INSERT'); } }, [settings.merged.general.vimMode]); const toggleVimEnabled = useCallback(async () => { const newValue = !vimEnabled; setVimEnabled(newValue); - // When enabling vim mode, start in NORMAL mode + // When enabling vim mode, start in INSERT mode if (newValue) { - setVimMode('NORMAL'); + setVimMode('INSERT'); } settings.setValue(SettingScope.User, 'general.vimMode', newValue); return newValue; diff --git a/packages/cli/src/ui/hooks/vim-passthrough.test.tsx b/packages/cli/src/ui/hooks/vim-passthrough.test.tsx new file mode 100644 index 0000000000..3b11bc7ce3 --- /dev/null +++ b/packages/cli/src/ui/hooks/vim-passthrough.test.tsx @@ -0,0 +1,85 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook } from '../../test-utils/render.js'; +import { act } from 'react'; +import { useVim } from './vim.js'; +import type { VimMode } from './vim.js'; +import type { TextBuffer } from '../components/shared/text-buffer.js'; +import type { Key } from './useKeypress.js'; + +// Mock the VimModeContext +const mockVimContext = { + vimEnabled: true, + vimMode: 'INSERT' as VimMode, + toggleVimEnabled: vi.fn(), + setVimMode: vi.fn(), +}; + +vi.mock('../contexts/VimModeContext.js', () => ({ + useVimMode: () => mockVimContext, + VimModeProvider: ({ children }: { children: React.ReactNode }) => children, +})); + +const createKey = (partial: Partial): Key => ({ + name: partial.name || '', + sequence: partial.sequence || '', + shift: partial.shift || false, + alt: partial.alt || false, + ctrl: partial.ctrl || false, + cmd: partial.cmd || false, + insertable: partial.insertable || false, + ...partial, +}); + +describe('useVim passthrough', () => { + let mockBuffer: Partial; + + beforeEach(() => { + vi.clearAllMocks(); + mockBuffer = { + text: 'hello', + handleInput: vi.fn().mockReturnValue(false), + vimEscapeInsertMode: vi.fn(), + setText: vi.fn(), + }; + mockVimContext.vimEnabled = true; + }); + + it.each([ + { + mode: 'INSERT' as VimMode, + name: 'F12', + key: createKey({ name: 'f12', sequence: '\u001b[24~' }), + }, + { + mode: 'INSERT' as VimMode, + name: 'Ctrl-X', + key: createKey({ name: 'x', ctrl: true, sequence: '\x18' }), + }, + { + mode: 'NORMAL' as VimMode, + name: 'F12', + key: createKey({ name: 'f12', sequence: '\u001b[24~' }), + }, + { + mode: 'NORMAL' as VimMode, + name: 'Ctrl-X', + key: createKey({ name: 'x', ctrl: true, sequence: '\x18' }), + }, + ])('should pass through $name in $mode mode', ({ mode, key }) => { + mockVimContext.vimMode = mode; + const { result } = renderHook(() => useVim(mockBuffer as TextBuffer)); + + let handled = true; + act(() => { + handled = result.current.handleInput(key); + }); + + expect(handled).toBe(false); + }); +}); diff --git a/packages/cli/src/ui/hooks/vim.test.tsx b/packages/cli/src/ui/hooks/vim.test.tsx index 739e58280d..f238c013f9 100644 --- a/packages/cli/src/ui/hooks/vim.test.tsx +++ b/packages/cli/src/ui/hooks/vim.test.tsx @@ -22,7 +22,7 @@ import { textBufferReducer } from '../components/shared/text-buffer.js'; // Mock the VimModeContext const mockVimContext = { vimEnabled: true, - vimMode: 'NORMAL' as VimMode, + vimMode: 'INSERT' as VimMode, toggleVimEnabled: vi.fn(), setVimMode: vi.fn(), }; @@ -91,6 +91,8 @@ const TEST_SEQUENCES = { LINE_END: createKey({ sequence: '$' }), REPEAT: createKey({ sequence: '.' }), CTRL_C: createKey({ sequence: '\x03', name: 'c', ctrl: true }), + CTRL_X: createKey({ sequence: '\x18', name: 'x', ctrl: true }), + F12: createKey({ sequence: '\u001b[24~', name: 'f12' }), } as const; describe('useVim hook', () => { @@ -134,6 +136,7 @@ describe('useVim hook', () => { replaceRangeByOffset: vi.fn(), handleInput: vi.fn(), setText: vi.fn(), + openInExternalEditor: vi.fn(), // Vim-specific methods vimDeleteWordForward: vi.fn(), vimDeleteWordBackward: vi.fn(), @@ -207,20 +210,23 @@ describe('useVim hook', () => { mockBuffer = createMockBuffer(); // Reset mock context to default state mockVimContext.vimEnabled = true; - mockVimContext.vimMode = 'NORMAL'; + mockVimContext.vimMode = 'INSERT'; mockVimContext.toggleVimEnabled.mockClear(); mockVimContext.setVimMode.mockClear(); }); describe('Mode switching', () => { - it('should start in NORMAL mode', () => { + it('should start in INSERT mode', () => { const { result } = renderVimHook(); - expect(result.current.mode).toBe('NORMAL'); + expect(result.current.mode).toBe('INSERT'); }); it('should switch to INSERT mode with i command', () => { const { result } = renderVimHook(); + exitInsertMode(result); + expect(result.current.mode).toBe('NORMAL'); + act(() => { result.current.handleInput(TEST_SEQUENCES.INSERT); }); @@ -266,6 +272,7 @@ describe('useVim hook', () => { describe('Navigation commands', () => { it('should handle h (left movement)', () => { const { result } = renderVimHook(); + exitInsertMode(result); act(() => { result.current.handleInput(createKey({ sequence: 'h' })); @@ -276,6 +283,7 @@ describe('useVim hook', () => { it('should handle l (right movement)', () => { const { result } = renderVimHook(); + exitInsertMode(result); act(() => { result.current.handleInput(createKey({ sequence: 'l' })); @@ -287,6 +295,7 @@ describe('useVim hook', () => { it('should handle j (down movement)', () => { const testBuffer = createMockBuffer('first line\nsecond line'); const { result } = renderVimHook(testBuffer); + exitInsertMode(result); act(() => { result.current.handleInput(createKey({ sequence: 'j' })); @@ -298,6 +307,7 @@ describe('useVim hook', () => { it('should handle k (up movement)', () => { const testBuffer = createMockBuffer('first line\nsecond line'); const { result } = renderVimHook(testBuffer); + exitInsertMode(result); act(() => { result.current.handleInput(createKey({ sequence: 'k' })); @@ -308,6 +318,7 @@ describe('useVim hook', () => { it('should handle 0 (move to start of line)', () => { const { result } = renderVimHook(); + exitInsertMode(result); act(() => { result.current.handleInput(createKey({ sequence: '0' })); @@ -318,6 +329,7 @@ describe('useVim hook', () => { it('should handle $ (move to end of line)', () => { const { result } = renderVimHook(); + exitInsertMode(result); act(() => { result.current.handleInput(createKey({ sequence: '$' })); @@ -330,6 +342,7 @@ describe('useVim hook', () => { describe('Mode switching commands', () => { it('should handle a (append after cursor)', () => { const { result } = renderVimHook(); + exitInsertMode(result); act(() => { result.current.handleInput(createKey({ sequence: 'a' })); @@ -341,6 +354,7 @@ describe('useVim hook', () => { it('should handle A (append at end of line)', () => { const { result } = renderVimHook(); + exitInsertMode(result); act(() => { result.current.handleInput(createKey({ sequence: 'A' })); @@ -352,6 +366,7 @@ describe('useVim hook', () => { it('should handle o (open line below)', () => { const { result } = renderVimHook(); + exitInsertMode(result); act(() => { result.current.handleInput(createKey({ sequence: 'o' })); @@ -363,6 +378,7 @@ describe('useVim hook', () => { it('should handle O (open line above)', () => { const { result } = renderVimHook(); + exitInsertMode(result); act(() => { result.current.handleInput(createKey({ sequence: 'O' })); @@ -376,6 +392,7 @@ describe('useVim hook', () => { describe('Edit commands', () => { it('should handle x (delete character)', () => { const { result } = renderVimHook(); + exitInsertMode(result); vi.clearAllMocks(); act(() => { @@ -388,6 +405,7 @@ describe('useVim hook', () => { it('should move cursor left when deleting last character on line (vim behavior)', () => { const testBuffer = createMockBuffer('hello', [0, 4]); const { result } = renderVimHook(testBuffer); + exitInsertMode(result); act(() => { result.current.handleInput(createKey({ sequence: 'x' })); @@ -398,6 +416,7 @@ describe('useVim hook', () => { it('should handle first d key (sets pending state)', () => { const { result } = renderVimHook(); + exitInsertMode(result); act(() => { result.current.handleInput(createKey({ sequence: 'd' })); @@ -410,6 +429,7 @@ describe('useVim hook', () => { describe('Count handling', () => { it('should handle count input and return to count 0 after command', () => { const { result } = renderVimHook(); + exitInsertMode(result); act(() => { const handled = result.current.handleInput( @@ -431,6 +451,7 @@ describe('useVim hook', () => { it('should only delete 1 character with x command when no count is specified', () => { const testBuffer = createMockBuffer(); const { result } = renderVimHook(testBuffer); + exitInsertMode(result); act(() => { result.current.handleInput(createKey({ sequence: 'x' })); @@ -446,7 +467,7 @@ describe('useVim hook', () => { const { result } = renderVimHook(testBuffer); expect(result.current.vimModeEnabled).toBe(true); - expect(result.current.mode).toBe('NORMAL'); + expect(result.current.mode).toBe('INSERT'); expect(result.current.handleInput).toBeDefined(); }); @@ -458,7 +479,7 @@ describe('useVim hook', () => { const { result } = renderVimHook(testBuffer); expect(result.current.vimModeEnabled).toBe(true); - expect(result.current.mode).toBe('NORMAL'); + expect(result.current.mode).toBe('INSERT'); expect(result.current.handleInput).toBeDefined(); expect(testBuffer.replaceRangeByOffset).toBeDefined(); expect(testBuffer.moveToOffset).toBeDefined(); @@ -467,6 +488,7 @@ describe('useVim hook', () => { it('should handle w (next word)', () => { const testBuffer = createMockBuffer('hello world test'); const { result } = renderVimHook(testBuffer); + exitInsertMode(result); act(() => { result.current.handleInput(createKey({ sequence: 'w' })); @@ -478,6 +500,7 @@ describe('useVim hook', () => { it('should handle b (previous word)', () => { const testBuffer = createMockBuffer('hello world test', [0, 6]); const { result } = renderVimHook(testBuffer); + exitInsertMode(result); act(() => { result.current.handleInput(createKey({ sequence: 'b' })); @@ -489,6 +512,7 @@ describe('useVim hook', () => { it('should handle e (end of word)', () => { const testBuffer = createMockBuffer('hello world test'); const { result } = renderVimHook(testBuffer); + exitInsertMode(result); act(() => { result.current.handleInput(createKey({ sequence: 'e' })); @@ -500,6 +524,7 @@ describe('useVim hook', () => { it('should handle w when cursor is on the last word', () => { const testBuffer = createMockBuffer('hello world', [0, 8]); const { result } = renderVimHook(testBuffer); + exitInsertMode(result); act(() => { result.current.handleInput(createKey({ sequence: 'w' })); @@ -510,6 +535,7 @@ describe('useVim hook', () => { it('should handle first c key (sets pending change state)', () => { const { result } = renderVimHook(); + exitInsertMode(result); act(() => { result.current.handleInput(createKey({ sequence: 'c' })); @@ -563,6 +589,7 @@ describe('useVim hook', () => { it('should repeat x command from current cursor position', () => { const testBuffer = createMockBuffer('abcd\nefgh\nijkl', [0, 1]); const { result } = renderVimHook(testBuffer); + exitInsertMode(result); act(() => { result.current.handleInput(createKey({ sequence: 'x' })); @@ -580,6 +607,7 @@ describe('useVim hook', () => { it('should repeat dd command from current position', () => { const testBuffer = createMockBuffer('line1\nline2\nline3', [1, 0]); const { result } = renderVimHook(testBuffer); + exitInsertMode(result); act(() => { result.current.handleInput(createKey({ sequence: 'd' })); @@ -601,6 +629,7 @@ describe('useVim hook', () => { it('should repeat ce command from current position', () => { const testBuffer = createMockBuffer('word', [0, 0]); const { result } = renderVimHook(testBuffer); + exitInsertMode(result); act(() => { result.current.handleInput(createKey({ sequence: 'c' })); @@ -625,6 +654,7 @@ describe('useVim hook', () => { it('should repeat cc command from current position', () => { const testBuffer = createMockBuffer('line1\nline2\nline3', [1, 2]); const { result } = renderVimHook(testBuffer); + exitInsertMode(result); act(() => { result.current.handleInput(createKey({ sequence: 'c' })); @@ -649,6 +679,7 @@ describe('useVim hook', () => { it('should repeat cw command from current position', () => { const testBuffer = createMockBuffer('hello world test', [0, 6]); const { result } = renderVimHook(testBuffer); + exitInsertMode(result); act(() => { result.current.handleInput(createKey({ sequence: 'c' })); @@ -673,6 +704,7 @@ describe('useVim hook', () => { it('should repeat D command from current position', () => { const testBuffer = createMockBuffer('hello world test', [0, 6]); const { result } = renderVimHook(testBuffer); + exitInsertMode(result); act(() => { result.current.handleInput(createKey({ sequence: 'D' })); @@ -692,6 +724,7 @@ describe('useVim hook', () => { it('should repeat C command from current position', () => { const testBuffer = createMockBuffer('hello world test', [0, 6]); const { result } = renderVimHook(testBuffer); + exitInsertMode(result); act(() => { result.current.handleInput(createKey({ sequence: 'C' })); @@ -713,6 +746,7 @@ describe('useVim hook', () => { it('should repeat command after cursor movement', () => { const testBuffer = createMockBuffer('test text', [0, 0]); const { result } = renderVimHook(testBuffer); + exitInsertMode(result); act(() => { result.current.handleInput(createKey({ sequence: 'x' })); @@ -728,8 +762,10 @@ describe('useVim hook', () => { }); it('should move cursor to the correct position after exiting INSERT mode with "a"', () => { - const testBuffer = createMockBuffer('hello world', [0, 10]); + const testBuffer = createMockBuffer('hello world', [0, 11]); const { result } = renderVimHook(testBuffer); + exitInsertMode(result); + expect(testBuffer.cursor).toEqual([0, 10]); act(() => { result.current.handleInput(createKey({ sequence: 'a' })); @@ -747,6 +783,7 @@ describe('useVim hook', () => { it('should handle ^ (move to first non-whitespace character)', () => { const testBuffer = createMockBuffer(' hello world', [0, 5]); const { result } = renderVimHook(testBuffer); + exitInsertMode(result); act(() => { result.current.handleInput(createKey({ sequence: '^' })); @@ -758,6 +795,7 @@ describe('useVim hook', () => { it('should handle G without count (go to last line)', () => { const testBuffer = createMockBuffer('line1\nline2\nline3', [0, 0]); const { result } = renderVimHook(testBuffer); + exitInsertMode(result); act(() => { result.current.handleInput(createKey({ sequence: 'G' })); @@ -769,6 +807,7 @@ describe('useVim hook', () => { it('should handle gg (go to first line)', () => { const testBuffer = createMockBuffer('line1\nline2\nline3', [2, 0]); const { result } = renderVimHook(testBuffer); + exitInsertMode(result); // First 'g' sets pending state act(() => { @@ -786,6 +825,7 @@ describe('useVim hook', () => { it('should handle count with movement commands', () => { const testBuffer = createMockBuffer('hello world test', [0, 0]); const { result } = renderVimHook(testBuffer); + exitInsertMode(result); act(() => { result.current.handleInput(createKey({ sequence: '3' })); @@ -804,6 +844,7 @@ describe('useVim hook', () => { it('should delete from cursor to start of next word', () => { const testBuffer = createMockBuffer('hello world test', [0, 0]); const { result } = renderVimHook(testBuffer); + exitInsertMode(result); act(() => { result.current.handleInput(createKey({ sequence: 'd' })); @@ -888,6 +929,7 @@ describe('useVim hook', () => { it('should delete multiple words with count', () => { const testBuffer = createMockBuffer('one two three four', [0, 0]); const { result } = renderVimHook(testBuffer); + exitInsertMode(result); act(() => { result.current.handleInput(createKey({ sequence: '2' })); @@ -905,6 +947,7 @@ describe('useVim hook', () => { it('should record command for repeat with dot', () => { const testBuffer = createMockBuffer('hello world test', [0, 0]); const { result } = renderVimHook(testBuffer); + exitInsertMode(result); // Execute dw act(() => { @@ -929,6 +972,7 @@ describe('useVim hook', () => { it('should delete from cursor to end of current word', () => { const testBuffer = createMockBuffer('hello world test', [0, 1]); const { result } = renderVimHook(testBuffer); + exitInsertMode(result); act(() => { result.current.handleInput(createKey({ sequence: 'd' })); @@ -943,6 +987,7 @@ describe('useVim hook', () => { it('should handle count with de', () => { const testBuffer = createMockBuffer('one two three four', [0, 0]); const { result } = renderVimHook(testBuffer); + exitInsertMode(result); act(() => { result.current.handleInput(createKey({ sequence: '3' })); @@ -962,6 +1007,7 @@ describe('useVim hook', () => { 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); + exitInsertMode(result); act(() => { result.current.handleInput(createKey({ sequence: 'c' })); @@ -978,6 +1024,7 @@ describe('useVim hook', () => { it('should handle count with cw', () => { const testBuffer = createMockBuffer('one two three four', [0, 0]); const { result } = renderVimHook(testBuffer); + exitInsertMode(result); act(() => { result.current.handleInput(createKey({ sequence: '2' })); @@ -996,6 +1043,7 @@ describe('useVim hook', () => { it('should be repeatable with dot', () => { const testBuffer = createMockBuffer('hello world test more', [0, 0]); const { result } = renderVimHook(testBuffer); + exitInsertMode(result); // Execute cw act(() => { @@ -1025,6 +1073,7 @@ describe('useVim hook', () => { 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); + exitInsertMode(result); act(() => { result.current.handleInput(createKey({ sequence: 'c' })); @@ -1040,6 +1089,7 @@ describe('useVim hook', () => { it('should handle count with ce', () => { const testBuffer = createMockBuffer('one two three four', [0, 0]); const { result } = renderVimHook(testBuffer); + exitInsertMode(result); act(() => { result.current.handleInput(createKey({ sequence: '2' })); @@ -1060,6 +1110,7 @@ describe('useVim hook', () => { it('should change entire line and enter INSERT mode', () => { const testBuffer = createMockBuffer('hello world\nsecond line', [0, 5]); const { result } = renderVimHook(testBuffer); + exitInsertMode(result); act(() => { result.current.handleInput(createKey({ sequence: 'c' })); @@ -1078,6 +1129,7 @@ describe('useVim hook', () => { [1, 0], ); const { result } = renderVimHook(testBuffer); + exitInsertMode(result); act(() => { result.current.handleInput(createKey({ sequence: '3' })); @@ -1096,6 +1148,7 @@ describe('useVim hook', () => { it('should be repeatable with dot', () => { const testBuffer = createMockBuffer('line1\nline2\nline3', [0, 0]); const { result } = renderVimHook(testBuffer); + exitInsertMode(result); // Execute cc act(() => { @@ -1125,6 +1178,7 @@ describe('useVim hook', () => { it('should delete from cursor to start of previous word', () => { const testBuffer = createMockBuffer('hello world test', [0, 11]); const { result } = renderVimHook(testBuffer); + exitInsertMode(result); act(() => { result.current.handleInput(createKey({ sequence: 'd' })); @@ -1139,6 +1193,7 @@ describe('useVim hook', () => { it('should handle count with db', () => { const testBuffer = createMockBuffer('one two three four', [0, 18]); const { result } = renderVimHook(testBuffer); + exitInsertMode(result); act(() => { result.current.handleInput(createKey({ sequence: '2' })); @@ -1158,6 +1213,7 @@ describe('useVim hook', () => { 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); + exitInsertMode(result); act(() => { result.current.handleInput(createKey({ sequence: 'c' })); @@ -1173,6 +1229,7 @@ describe('useVim hook', () => { it('should handle count with cb', () => { const testBuffer = createMockBuffer('one two three four', [0, 18]); const { result } = renderVimHook(testBuffer); + exitInsertMode(result); act(() => { result.current.handleInput(createKey({ sequence: '3' })); @@ -1193,6 +1250,7 @@ describe('useVim hook', () => { it('should clear pending delete state after dw', () => { const testBuffer = createMockBuffer('hello world', [0, 0]); const { result } = renderVimHook(testBuffer); + exitInsertMode(result); // Press 'd' to enter pending delete state act(() => { @@ -1220,6 +1278,7 @@ describe('useVim hook', () => { it('should clear pending change state after cw', () => { const testBuffer = createMockBuffer('hello world', [0, 0]); const { result } = renderVimHook(testBuffer); + exitInsertMode(result); // Execute cw act(() => { @@ -1246,6 +1305,7 @@ describe('useVim hook', () => { it('should clear pending state with escape', () => { const testBuffer = createMockBuffer('hello world', [0, 0]); const { result } = renderVimHook(testBuffer); + exitInsertMode(result); // Enter pending delete state act(() => { @@ -1621,7 +1681,7 @@ describe('useVim hook', () => { beforeEach(() => { mockBuffer = createMockBuffer('hello world'); mockVimContext.vimEnabled = true; - mockVimContext.vimMode = 'NORMAL'; + mockVimContext.vimMode = 'INSERT'; mockHandleFinalSubmit = vi.fn(); vi.useFakeTimers(); }); @@ -1634,6 +1694,11 @@ describe('useVim hook', () => { const { result } = renderHook(() => useVim(mockBuffer as TextBuffer, mockHandleFinalSubmit), ); + exitInsertMode(result); + // Wait to clear escape history + await act(async () => { + vi.advanceTimersByTime(600); + }); // First escape - should pass through (return false) let handled: boolean; @@ -1651,7 +1716,6 @@ describe('useVim hook', () => { }); it('should clear buffer on double-escape in INSERT mode', async () => { - mockVimContext.vimMode = 'INSERT'; const { result } = renderHook(() => useVim(mockBuffer as TextBuffer, mockHandleFinalSubmit), ); @@ -1676,6 +1740,11 @@ describe('useVim hook', () => { const { result } = renderHook(() => useVim(mockBuffer as TextBuffer, mockHandleFinalSubmit), ); + exitInsertMode(result); + // Wait to clear escape history + await act(async () => { + vi.advanceTimersByTime(600); + }); // First escape await act(async () => { @@ -1701,6 +1770,11 @@ describe('useVim hook', () => { const { result } = renderHook(() => useVim(mockBuffer as TextBuffer, mockHandleFinalSubmit), ); + exitInsertMode(result); + // Wait to clear escape history + await act(async () => { + vi.advanceTimersByTime(600); + }); // First escape await act(async () => { @@ -1730,6 +1804,7 @@ describe('useVim hook', () => { const { result } = renderHook(() => useVim(mockBuffer as TextBuffer, mockHandleFinalSubmit), ); + exitInsertMode(result); let handled: boolean; await act(async () => { @@ -1740,7 +1815,6 @@ describe('useVim hook', () => { }); it('should pass Ctrl+C through to InputPrompt in INSERT mode', async () => { - mockVimContext.vimMode = 'INSERT'; const { result } = renderHook(() => useVim(mockBuffer as TextBuffer, mockHandleFinalSubmit), ); diff --git a/packages/cli/src/ui/hooks/vim.ts b/packages/cli/src/ui/hooks/vim.ts index 2762c54de7..eae1a38d51 100644 --- a/packages/cli/src/ui/hooks/vim.ts +++ b/packages/cli/src/ui/hooks/vim.ts @@ -68,7 +68,7 @@ type VimAction = | { type: 'ESCAPE_TO_NORMAL' }; const initialVimState: VimState = { - mode: 'NORMAL', + mode: 'INSERT', count: 0, pendingOperator: null, lastCommand: null, @@ -312,9 +312,7 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { 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 + return buffer.handleInput(normalizedKey); }, [buffer, dispatch, updateMode, onSubmit, checkDoubleEscape], ); @@ -784,7 +782,9 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { // Unknown command, clear count and pending states dispatch({ type: 'CLEAR_PENDING_STATES' }); - return true; // Still handled by vim to prevent other handlers + + // Not handled by vim so allow other handlers to process it. + return false; } } }