diff --git a/packages/cli/src/ui/hooks/vim.test.ts b/packages/cli/src/ui/hooks/vim.test.ts index f939982f..0139119e 100644 --- a/packages/cli/src/ui/hooks/vim.test.ts +++ b/packages/cli/src/ui/hooks/vim.test.ts @@ -1203,7 +1203,9 @@ describe('useVim hook', () => { }); // Press escape to clear pending state - exitInsertMode(result); + act(() => { + result.current.handleInput({ name: 'escape' }); + }); // Now 'w' should just move cursor, not delete act(() => { @@ -1215,6 +1217,69 @@ describe('useVim hook', () => { expect(testBuffer.vimMoveWordForward).toHaveBeenCalledWith(1); }); }); + + describe('NORMAL mode escape behavior', () => { + it('should pass escape through when no pending operator is active', () => { + mockVimContext.vimMode = 'NORMAL'; + const { result } = renderVimHook(); + + const handled = result.current.handleInput({ name: 'escape' }); + + expect(handled).toBe(false); + }); + + it('should handle escape and clear pending operator', () => { + mockVimContext.vimMode = 'NORMAL'; + const { result } = renderVimHook(); + + act(() => { + result.current.handleInput({ sequence: 'd' }); + }); + + let handled: boolean | undefined; + act(() => { + handled = result.current.handleInput({ name: 'escape' }); + }); + + expect(handled).toBe(true); + }); + }); + }); + + describe('Shell command pass-through', () => { + it('should pass through ctrl+r in INSERT mode', () => { + mockVimContext.vimMode = 'INSERT'; + const { result } = renderVimHook(); + + const handled = result.current.handleInput({ name: 'r', ctrl: true }); + + expect(handled).toBe(false); + }); + + it('should pass through ! in INSERT mode when buffer is empty', () => { + mockVimContext.vimMode = 'INSERT'; + const emptyBuffer = createMockBuffer(''); + const { result } = renderVimHook(emptyBuffer); + + const handled = result.current.handleInput({ sequence: '!' }); + + expect(handled).toBe(false); + }); + + it('should handle ! as input in INSERT mode when buffer is not empty', () => { + mockVimContext.vimMode = 'INSERT'; + const nonEmptyBuffer = createMockBuffer('not empty'); + const { result } = renderVimHook(nonEmptyBuffer); + const key = { sequence: '!', name: '!' }; + + act(() => { + result.current.handleInput(key); + }); + + expect(nonEmptyBuffer.handleInput).toHaveBeenCalledWith( + expect.objectContaining(key), + ); + }); }); // Line operations (dd, cc) are tested in text-buffer.test.ts diff --git a/packages/cli/src/ui/hooks/vim.ts b/packages/cli/src/ui/hooks/vim.ts index cb65e1ee..97b73121 100644 --- a/packages/cli/src/ui/hooks/vim.ts +++ b/packages/cli/src/ui/hooks/vim.ts @@ -260,7 +260,8 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { normalizedKey.name === 'tab' || (normalizedKey.name === 'return' && !normalizedKey.ctrl) || normalizedKey.name === 'up' || - normalizedKey.name === 'down' + normalizedKey.name === 'down' || + (normalizedKey.ctrl && normalizedKey.name === 'r') ) { return false; // Let InputPrompt handle completion } @@ -270,6 +271,11 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { return false; // Let InputPrompt handle clipboard functionality } + // Let InputPrompt handle shell commands + if (normalizedKey.sequence === '!' && buffer.text.length === 0) { + return false; + } + // Special handling for Enter key to allow command submission (lower priority than completion) if ( normalizedKey.name === 'return' && @@ -399,10 +405,14 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { // Handle NORMAL mode if (state.mode === 'NORMAL') { - // Handle Escape key in NORMAL mode - clear all pending states + // If in NORMAL mode, allow escape to pass through to other handlers + // if there's no pending operation. if (normalizedKey.name === 'escape') { - dispatch({ type: 'CLEAR_PENDING_STATES' }); - return true; // Handled by vim + if (state.pendingOperator) { + dispatch({ type: 'CLEAR_PENDING_STATES' }); + return true; // Handled by vim + } + return false; // Pass through to other handlers } // Handle count input (numbers 1-9, and 0 if count > 0)