diff --git a/docs/cli/keyboard-shortcuts.md b/docs/cli/keyboard-shortcuts.md index d377cfd3e2b..b43919cd060 100644 --- a/docs/cli/keyboard-shortcuts.md +++ b/docs/cli/keyboard-shortcuts.md @@ -8,12 +8,12 @@ available combinations. #### Basic Controls -| Action | Keys | -| --------------------------------------------------------------- | ---------- | -| Confirm the current selection or choice. | `Enter` | -| Dismiss dialogs or cancel the current focus. | `Esc` | -| Cancel the current request or quit the CLI when input is empty. | `Ctrl + C` | -| Exit the CLI when the input buffer is empty. | `Ctrl + D` | +| Action | Keys | +| --------------------------------------------------------------- | --------------------- | +| Confirm the current selection or choice. | `Enter` | +| Dismiss dialogs or cancel the current focus. | `Esc`
`Ctrl + [` | +| Cancel the current request or quit the CLI when input is empty. | `Ctrl + C` | +| Exit the CLI when the input buffer is empty. | `Ctrl + D` | #### Cursor Movement diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/config/keyBindings.ts index 96e50f36d67..4495e4a4291 100644 --- a/packages/cli/src/config/keyBindings.ts +++ b/packages/cli/src/config/keyBindings.ts @@ -129,7 +129,7 @@ export type KeyBindingConfig = { export const defaultKeyBindings: KeyBindingConfig = { // Basic Controls [Command.RETURN]: [{ key: 'return' }], - [Command.ESCAPE]: [{ key: 'escape' }], + [Command.ESCAPE]: [{ key: 'escape' }, { key: '[', ctrl: true }], [Command.QUIT]: [{ key: 'c', ctrl: true }], [Command.EXIT]: [{ key: 'd', ctrl: true }], diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index 77edace6c9e..d0f425129b1 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -1657,8 +1657,9 @@ export type TextBufferAction = | { type: 'vim_change_big_word_end'; payload: { count: number } } | { type: 'vim_delete_line'; payload: { count: number } } | { type: 'vim_change_line'; payload: { count: number } } - | { type: 'vim_delete_to_end_of_line' } - | { type: 'vim_change_to_end_of_line' } + | { type: 'vim_delete_to_end_of_line'; payload: { count: number } } + | { type: 'vim_delete_to_start_of_line' } + | { type: 'vim_change_to_end_of_line'; payload: { count: number } } | { type: 'vim_change_movement'; payload: { movement: 'h' | 'j' | 'k' | 'l'; count: number }; @@ -1688,6 +1689,11 @@ export type TextBufferAction = | { type: 'vim_move_to_last_line' } | { type: 'vim_move_to_line'; payload: { lineNumber: number } } | { type: 'vim_escape_insert_mode' } + | { type: 'vim_delete_to_first_nonwhitespace' } + | { type: 'vim_change_to_start_of_line' } + | { type: 'vim_change_to_first_nonwhitespace' } + | { type: 'vim_delete_to_first_line'; payload: { count: number } } + | { type: 'vim_delete_to_last_line'; payload: { count: number } } | { type: 'toggle_paste_expansion'; payload: { id: string; row: number; col: number }; @@ -2437,6 +2443,7 @@ function textBufferReducerLogic( case 'vim_delete_line': case 'vim_change_line': case 'vim_delete_to_end_of_line': + case 'vim_delete_to_start_of_line': case 'vim_change_to_end_of_line': case 'vim_change_movement': case 'vim_move_left': @@ -2463,6 +2470,11 @@ function textBufferReducerLogic( case 'vim_move_to_last_line': case 'vim_move_to_line': case 'vim_escape_insert_mode': + case 'vim_delete_to_first_nonwhitespace': + case 'vim_change_to_start_of_line': + case 'vim_change_to_first_nonwhitespace': + case 'vim_delete_to_first_line': + case 'vim_delete_to_last_line': return handleVimAction(state, action as VimAction); case 'toggle_paste_expansion': { @@ -2945,12 +2957,36 @@ export function useTextBuffer({ dispatch({ type: 'vim_change_line', payload: { count } }); }, []); - const vimDeleteToEndOfLine = useCallback((): void => { - dispatch({ type: 'vim_delete_to_end_of_line' }); + const vimDeleteToEndOfLine = useCallback((count: number = 1): void => { + dispatch({ type: 'vim_delete_to_end_of_line', payload: { count } }); }, []); - const vimChangeToEndOfLine = useCallback((): void => { - dispatch({ type: 'vim_change_to_end_of_line' }); + const vimDeleteToStartOfLine = useCallback((): void => { + dispatch({ type: 'vim_delete_to_start_of_line' }); + }, []); + + const vimChangeToEndOfLine = useCallback((count: number = 1): void => { + dispatch({ type: 'vim_change_to_end_of_line', payload: { count } }); + }, []); + + const vimDeleteToFirstNonWhitespace = useCallback((): void => { + dispatch({ type: 'vim_delete_to_first_nonwhitespace' }); + }, []); + + const vimChangeToStartOfLine = useCallback((): void => { + dispatch({ type: 'vim_change_to_start_of_line' }); + }, []); + + const vimChangeToFirstNonWhitespace = useCallback((): void => { + dispatch({ type: 'vim_change_to_first_nonwhitespace' }); + }, []); + + const vimDeleteToFirstLine = useCallback((count: number): void => { + dispatch({ type: 'vim_delete_to_first_line', payload: { count } }); + }, []); + + const vimDeleteToLastLine = useCallback((count: number): void => { + dispatch({ type: 'vim_delete_to_last_line', payload: { count } }); }, []); const vimChangeMovement = useCallback( @@ -3510,7 +3546,13 @@ export function useTextBuffer({ vimDeleteLine, vimChangeLine, vimDeleteToEndOfLine, + vimDeleteToStartOfLine, vimChangeToEndOfLine, + vimDeleteToFirstNonWhitespace, + vimChangeToStartOfLine, + vimChangeToFirstNonWhitespace, + vimDeleteToFirstLine, + vimDeleteToLastLine, vimChangeMovement, vimMoveLeft, vimMoveRight, @@ -3592,7 +3634,13 @@ export function useTextBuffer({ vimDeleteLine, vimChangeLine, vimDeleteToEndOfLine, + vimDeleteToStartOfLine, vimChangeToEndOfLine, + vimDeleteToFirstNonWhitespace, + vimChangeToStartOfLine, + vimChangeToFirstNonWhitespace, + vimDeleteToFirstLine, + vimDeleteToLastLine, vimChangeMovement, vimMoveLeft, vimMoveRight, @@ -3832,12 +3880,38 @@ export interface TextBuffer { vimChangeLine: (count: number) => void; /** * Delete from cursor to end of line (vim 'D' command) + * With count > 1, deletes to end of current line plus (count-1) additional lines */ - vimDeleteToEndOfLine: () => void; + vimDeleteToEndOfLine: (count?: number) => void; + /** + * Delete from start of line to cursor (vim 'd0' command) + */ + vimDeleteToStartOfLine: () => void; /** * Change from cursor to end of line (vim 'C' command) + * With count > 1, changes to end of current line plus (count-1) additional lines + */ + vimChangeToEndOfLine: (count?: number) => void; + /** + * Delete from cursor to first non-whitespace character (vim 'd^' command) + */ + vimDeleteToFirstNonWhitespace: () => void; + /** + * Change from cursor to start of line (vim 'c0' command) + */ + vimChangeToStartOfLine: () => void; + /** + * Change from cursor to first non-whitespace character (vim 'c^' command) + */ + vimChangeToFirstNonWhitespace: () => void; + /** + * Delete from current line to first line (vim 'dgg' command) + */ + vimDeleteToFirstLine: (count: number) => void; + /** + * Delete from current line to last line (vim 'dG' command) */ - vimChangeToEndOfLine: () => void; + vimDeleteToLastLine: (count: number) => void; /** * Change movement operations (vim 'ch', 'cj', 'ck', 'cl' commands) */ diff --git a/packages/cli/src/ui/components/shared/vim-buffer-actions.test.ts b/packages/cli/src/ui/components/shared/vim-buffer-actions.test.ts index 925a3511e0a..9cbfd9457b5 100644 --- a/packages/cli/src/ui/components/shared/vim-buffer-actions.test.ts +++ b/packages/cli/src/ui/components/shared/vim-buffer-actions.test.ts @@ -469,6 +469,24 @@ describe('vim-buffer-actions', () => { expect(result.cursorCol).toBe(3); // Position of 'h' }); + it('vim_move_to_first_nonwhitespace should go to column 0 on whitespace-only line', () => { + const state = createTestState([' '], 0, 3); + const action = { type: 'vim_move_to_first_nonwhitespace' as const }; + + const result = handleVimAction(state, action); + expect(result).toHaveOnlyValidCharacters(); + expect(result.cursorCol).toBe(0); + }); + + it('vim_move_to_first_nonwhitespace should go to column 0 on empty line', () => { + const state = createTestState([''], 0, 0); + const action = { type: 'vim_move_to_first_nonwhitespace' as const }; + + const result = handleVimAction(state, action); + expect(result).toHaveOnlyValidCharacters(); + expect(result.cursorCol).toBe(0); + }); + it('vim_move_to_first_line should move to row 0', () => { const state = createTestState(['line1', 'line2', 'line3'], 2, 5); const action = { type: 'vim_move_to_first_line' as const }; @@ -725,7 +743,10 @@ describe('vim-buffer-actions', () => { describe('vim_delete_to_end_of_line', () => { it('should delete from cursor to end of line', () => { const state = createTestState(['hello world'], 0, 5); - const action = { type: 'vim_delete_to_end_of_line' as const }; + const action = { + type: 'vim_delete_to_end_of_line' as const, + payload: { count: 1 }, + }; const result = handleVimAction(state, action); expect(result).toHaveOnlyValidCharacters(); @@ -735,13 +756,402 @@ describe('vim-buffer-actions', () => { it('should do nothing at end of line', () => { const state = createTestState(['hello'], 0, 5); - const action = { type: 'vim_delete_to_end_of_line' as const }; + const action = { + type: 'vim_delete_to_end_of_line' as const, + payload: { count: 1 }, + }; + + const result = handleVimAction(state, action); + expect(result).toHaveOnlyValidCharacters(); + expect(result.lines[0]).toBe('hello'); + }); + + it('should delete to end of line plus additional lines with count > 1', () => { + const state = createTestState( + ['line one', 'line two', 'line three'], + 0, + 5, + ); + const action = { + type: 'vim_delete_to_end_of_line' as const, + payload: { count: 2 }, + }; + + const result = handleVimAction(state, action); + expect(result).toHaveOnlyValidCharacters(); + // 2D at position 5 on "line one" should delete "one" + entire "line two" + expect(result.lines).toEqual(['line ', 'line three']); + expect(result.cursorCol).toBe(5); + }); + + it('should handle count exceeding available lines', () => { + const state = createTestState(['line one', 'line two'], 0, 5); + const action = { + type: 'vim_delete_to_end_of_line' as const, + payload: { count: 5 }, + }; + + const result = handleVimAction(state, action); + expect(result).toHaveOnlyValidCharacters(); + // Should delete to end of available lines + expect(result.lines).toEqual(['line ']); + }); + }); + + describe('vim_delete_to_first_nonwhitespace', () => { + it('should delete from cursor backwards to first non-whitespace', () => { + const state = createTestState([' hello world'], 0, 10); + const action = { type: 'vim_delete_to_first_nonwhitespace' as const }; + + const result = handleVimAction(state, action); + expect(result).toHaveOnlyValidCharacters(); + // Delete from 'h' (col 4) to cursor (col 10), leaving " world" + expect(result.lines[0]).toBe(' world'); + expect(result.cursorCol).toBe(4); + }); + + it('should delete from cursor forwards when cursor is in whitespace', () => { + const state = createTestState([' hello'], 0, 2); + const action = { type: 'vim_delete_to_first_nonwhitespace' as const }; + + const result = handleVimAction(state, action); + expect(result).toHaveOnlyValidCharacters(); + // Delete from cursor (col 2) to first non-ws (col 4), leaving " hello" + expect(result.lines[0]).toBe(' hello'); + expect(result.cursorCol).toBe(2); + }); + + it('should do nothing when cursor is at first non-whitespace', () => { + const state = createTestState([' hello'], 0, 4); + const action = { type: 'vim_delete_to_first_nonwhitespace' as const }; + + const result = handleVimAction(state, action); + expect(result).toHaveOnlyValidCharacters(); + expect(result.lines[0]).toBe(' hello'); + }); + + it('should delete to column 0 on whitespace-only line', () => { + const state = createTestState([' '], 0, 2); + const action = { type: 'vim_delete_to_first_nonwhitespace' as const }; + + const result = handleVimAction(state, action); + expect(result).toHaveOnlyValidCharacters(); + // On whitespace-only line, ^ goes to col 0, so d^ deletes cols 0-2 + expect(result.lines[0]).toBe(' '); + expect(result.cursorCol).toBe(0); + }); + }); + + describe('vim_delete_to_first_line', () => { + it('should delete from current line to first line (dgg)', () => { + const state = createTestState( + ['line1', 'line2', 'line3', 'line4'], + 2, + 0, + ); + const action = { + type: 'vim_delete_to_first_line' as const, + payload: { count: 0 }, + }; + + const result = handleVimAction(state, action); + expect(result).toHaveOnlyValidCharacters(); + // Delete lines 0, 1, 2 (current), leaving line4 + expect(result.lines).toEqual(['line4']); + expect(result.cursorRow).toBe(0); + }); + + it('should delete from current line to specified line (d5gg)', () => { + const state = createTestState( + ['line1', 'line2', 'line3', 'line4', 'line5'], + 4, + 0, + ); + const action = { + type: 'vim_delete_to_first_line' as const, + payload: { count: 2 }, // Delete to line 2 (1-based) + }; + + const result = handleVimAction(state, action); + expect(result).toHaveOnlyValidCharacters(); + // Delete lines 1-4 (line2 to line5), leaving line1 + expect(result.lines).toEqual(['line1']); + expect(result.cursorRow).toBe(0); + }); + + it('should keep one empty line when deleting all lines', () => { + const state = createTestState(['line1', 'line2'], 1, 0); + const action = { + type: 'vim_delete_to_first_line' as const, + payload: { count: 0 }, + }; + + const result = handleVimAction(state, action); + expect(result).toHaveOnlyValidCharacters(); + expect(result.lines).toEqual(['']); + }); + }); + + describe('vim_delete_to_last_line', () => { + it('should delete from current line to last line (dG)', () => { + const state = createTestState( + ['line1', 'line2', 'line3', 'line4'], + 1, + 0, + ); + const action = { + type: 'vim_delete_to_last_line' as const, + payload: { count: 0 }, + }; + + const result = handleVimAction(state, action); + expect(result).toHaveOnlyValidCharacters(); + // Delete lines 1, 2, 3 (from current to last), leaving line1 + expect(result.lines).toEqual(['line1']); + expect(result.cursorRow).toBe(0); + }); + + it('should delete from current line to specified line (d3G)', () => { + const state = createTestState( + ['line1', 'line2', 'line3', 'line4', 'line5'], + 0, + 0, + ); + const action = { + type: 'vim_delete_to_last_line' as const, + payload: { count: 3 }, // Delete to line 3 (1-based) + }; + + const result = handleVimAction(state, action); + expect(result).toHaveOnlyValidCharacters(); + // Delete lines 0-2 (line1 to line3), leaving line4 and line5 + expect(result.lines).toEqual(['line4', 'line5']); + expect(result.cursorRow).toBe(0); + }); + + it('should keep one empty line when deleting all lines', () => { + const state = createTestState(['line1', 'line2'], 0, 0); + const action = { + type: 'vim_delete_to_last_line' as const, + payload: { count: 0 }, + }; + + const result = handleVimAction(state, action); + expect(result).toHaveOnlyValidCharacters(); + expect(result.lines).toEqual(['']); + }); + }); + + describe('vim_change_to_start_of_line', () => { + it('should delete from start of line to cursor (c0)', () => { + const state = createTestState(['hello world'], 0, 6); + const action = { type: 'vim_change_to_start_of_line' as const }; + + const result = handleVimAction(state, action); + expect(result).toHaveOnlyValidCharacters(); + expect(result.lines[0]).toBe('world'); + expect(result.cursorCol).toBe(0); + }); + + it('should do nothing at start of line', () => { + const state = createTestState(['hello'], 0, 0); + const action = { type: 'vim_change_to_start_of_line' as const }; const result = handleVimAction(state, action); expect(result).toHaveOnlyValidCharacters(); expect(result.lines[0]).toBe('hello'); }); }); + + describe('vim_change_to_first_nonwhitespace', () => { + it('should delete from first non-whitespace to cursor (c^)', () => { + const state = createTestState([' hello world'], 0, 10); + const action = { type: 'vim_change_to_first_nonwhitespace' as const }; + + const result = handleVimAction(state, action); + expect(result).toHaveOnlyValidCharacters(); + expect(result.lines[0]).toBe(' world'); + expect(result.cursorCol).toBe(4); + }); + + it('should delete backwards when cursor before first non-whitespace', () => { + const state = createTestState([' hello'], 0, 2); + const action = { type: 'vim_change_to_first_nonwhitespace' as const }; + + const result = handleVimAction(state, action); + expect(result).toHaveOnlyValidCharacters(); + expect(result.lines[0]).toBe(' hello'); + expect(result.cursorCol).toBe(2); + }); + + it('should handle whitespace-only line', () => { + const state = createTestState([' '], 0, 3); + const action = { type: 'vim_change_to_first_nonwhitespace' as const }; + + const result = handleVimAction(state, action); + expect(result).toHaveOnlyValidCharacters(); + expect(result.lines[0]).toBe(' '); + expect(result.cursorCol).toBe(0); + }); + }); + + describe('vim_change_to_end_of_line', () => { + it('should delete from cursor to end of line (C)', () => { + const state = createTestState(['hello world'], 0, 6); + const action = { + type: 'vim_change_to_end_of_line' as const, + payload: { count: 1 }, + }; + + const result = handleVimAction(state, action); + expect(result).toHaveOnlyValidCharacters(); + expect(result.lines[0]).toBe('hello '); + expect(result.cursorCol).toBe(6); + }); + + it('should delete multiple lines with count (2C)', () => { + const state = createTestState(['line1 hello', 'line2', 'line3'], 0, 6); + const action = { + type: 'vim_change_to_end_of_line' as const, + payload: { count: 2 }, + }; + + const result = handleVimAction(state, action); + expect(result).toHaveOnlyValidCharacters(); + expect(result.lines).toEqual(['line1 ', 'line3']); + expect(result.cursorRow).toBe(0); + expect(result.cursorCol).toBe(6); + }); + + it('should delete remaining lines when count exceeds available (3C on 2 lines)', () => { + const state = createTestState(['hello world', 'end'], 0, 6); + const action = { + type: 'vim_change_to_end_of_line' as const, + payload: { count: 3 }, + }; + + const result = handleVimAction(state, action); + expect(result).toHaveOnlyValidCharacters(); + expect(result.lines).toEqual(['hello ']); + expect(result.cursorCol).toBe(6); + }); + + it('should handle count at last line', () => { + const state = createTestState(['first', 'last line'], 1, 5); + const action = { + type: 'vim_change_to_end_of_line' as const, + payload: { count: 2 }, + }; + + const result = handleVimAction(state, action); + expect(result).toHaveOnlyValidCharacters(); + expect(result.lines).toEqual(['first', 'last ']); + expect(result.cursorRow).toBe(1); + expect(result.cursorCol).toBe(5); + }); + }); + + describe('vim_change_to_first_line', () => { + it('should delete from first line to current line (cgg)', () => { + const state = createTestState(['line1', 'line2', 'line3'], 2, 3); + const action = { + type: 'vim_delete_to_first_line' as const, + payload: { count: 0 }, + }; + + const result = handleVimAction(state, action); + expect(result).toHaveOnlyValidCharacters(); + expect(result.lines).toEqual(['']); + expect(result.cursorRow).toBe(0); + }); + + it('should delete from line 1 to target line (c3gg)', () => { + const state = createTestState( + ['line1', 'line2', 'line3', 'line4', 'line5'], + 0, + 0, + ); + const action = { + type: 'vim_delete_to_first_line' as const, + payload: { count: 3 }, + }; + + const result = handleVimAction(state, action); + expect(result).toHaveOnlyValidCharacters(); + expect(result.lines).toEqual(['line4', 'line5']); + expect(result.cursorRow).toBe(0); + }); + + it('should handle cursor below target line', () => { + // Cursor on line 4 (index 3), target line 2 (index 1) + // Should delete lines 2-4 (indices 1-3), leaving line1 and line5 + const state = createTestState( + ['line1', 'line2', 'line3', 'line4', 'line5'], + 3, + 0, + ); + const action = { + type: 'vim_delete_to_first_line' as const, + payload: { count: 2 }, + }; + + const result = handleVimAction(state, action); + expect(result).toHaveOnlyValidCharacters(); + expect(result.lines).toEqual(['line1', 'line5']); + expect(result.cursorRow).toBe(1); + }); + }); + + describe('vim_change_to_last_line', () => { + it('should delete from current line to last line (cG)', () => { + const state = createTestState(['line1', 'line2', 'line3'], 0, 3); + const action = { + type: 'vim_delete_to_last_line' as const, + payload: { count: 0 }, + }; + + const result = handleVimAction(state, action); + expect(result).toHaveOnlyValidCharacters(); + expect(result.lines).toEqual(['']); + expect(result.cursorRow).toBe(0); + }); + + it('should delete from cursor to target line (c2G)', () => { + const state = createTestState( + ['line1', 'line2', 'line3', 'line4'], + 0, + 0, + ); + const action = { + type: 'vim_delete_to_last_line' as const, + payload: { count: 2 }, + }; + + const result = handleVimAction(state, action); + expect(result).toHaveOnlyValidCharacters(); + expect(result.lines).toEqual(['line3', 'line4']); + expect(result.cursorRow).toBe(0); + }); + + it('should handle cursor above target', () => { + // Cursor on line 2 (index 1), target line 3 (index 2) + // Should delete lines 2-3 (indices 1-2), leaving line1 and line4 + const state = createTestState( + ['line1', 'line2', 'line3', 'line4'], + 1, + 0, + ); + const action = { + type: 'vim_delete_to_last_line' as const, + payload: { count: 3 }, + }; + + const result = handleVimAction(state, action); + expect(result).toHaveOnlyValidCharacters(); + expect(result.lines).toEqual(['line1', 'line4']); + expect(result.cursorRow).toBe(1); + }); + }); }); describe('Insert mode commands', () => { @@ -922,11 +1332,127 @@ describe('vim-buffer-actions', () => { const result = handleVimAction(state, action); expect(result).toHaveOnlyValidCharacters(); - // The movement 'j' with count 2 changes 2 lines starting from cursor row - // Since we're at cursor position 2, it changes lines starting from current row - expect(result.lines).toEqual(['line1', 'line2', 'line3']); // No change because count > available lines + // In VIM, 2cj deletes current line + 2 lines below = 3 lines total + // Since there are exactly 3 lines, all are deleted + expect(result.lines).toEqual(['']); expect(result.cursorRow).toBe(0); - expect(result.cursorCol).toBe(2); + expect(result.cursorCol).toBe(0); + }); + + it('should handle Unicode characters in cj (down)', () => { + const state = createTestState( + ['hello 🎉 world', 'line2 émoji', 'line3'], + 0, + 0, + ); + const action = { + type: 'vim_change_movement' as const, + payload: { movement: 'j' as const, count: 1 }, + }; + + const result = handleVimAction(state, action); + expect(result).toHaveOnlyValidCharacters(); + expect(result.lines).toEqual(['line3']); + expect(result.cursorRow).toBe(0); + expect(result.cursorCol).toBe(0); + }); + + it('should handle Unicode characters in ck (up)', () => { + const state = createTestState( + ['line1', 'hello 🎉 world', 'line3 émoji'], + 2, + 0, + ); + const action = { + type: 'vim_change_movement' as const, + payload: { movement: 'k' as const, count: 1 }, + }; + + const result = handleVimAction(state, action); + expect(result).toHaveOnlyValidCharacters(); + expect(result.lines).toEqual(['line1']); + expect(result.cursorRow).toBe(0); + expect(result.cursorCol).toBe(0); + }); + + it('should handle cj on first line of 2 lines (delete all)', () => { + const state = createTestState(['line1', 'line2'], 0, 0); + const action = { + type: 'vim_change_movement' as const, + payload: { movement: 'j' as const, count: 1 }, + }; + + const result = handleVimAction(state, action); + expect(result).toHaveOnlyValidCharacters(); + expect(result.lines).toEqual(['']); + expect(result.cursorRow).toBe(0); + expect(result.cursorCol).toBe(0); + }); + + it('should handle cj on last line (delete only current line)', () => { + const state = createTestState(['line1', 'line2', 'line3'], 2, 0); + const action = { + type: 'vim_change_movement' as const, + payload: { movement: 'j' as const, count: 1 }, + }; + + const result = handleVimAction(state, action); + expect(result).toHaveOnlyValidCharacters(); + expect(result.lines).toEqual(['line1', 'line2']); + expect(result.cursorRow).toBe(1); + expect(result.cursorCol).toBe(0); + }); + + it('should handle ck on first line (delete only current line)', () => { + const state = createTestState(['line1', 'line2', 'line3'], 0, 0); + const action = { + type: 'vim_change_movement' as const, + payload: { movement: 'k' as const, count: 1 }, + }; + + const result = handleVimAction(state, action); + expect(result).toHaveOnlyValidCharacters(); + expect(result.lines).toEqual(['line2', 'line3']); + expect(result.cursorRow).toBe(0); + expect(result.cursorCol).toBe(0); + }); + + it('should handle 2cj from middle line', () => { + const state = createTestState( + ['line1', 'line2', 'line3', 'line4', 'line5'], + 1, + 0, + ); + const action = { + type: 'vim_change_movement' as const, + payload: { movement: 'j' as const, count: 2 }, + }; + + const result = handleVimAction(state, action); + expect(result).toHaveOnlyValidCharacters(); + // 2cj from line 1: delete lines 1, 2, 3 (current + 2 below) + expect(result.lines).toEqual(['line1', 'line5']); + expect(result.cursorRow).toBe(1); + expect(result.cursorCol).toBe(0); + }); + + it('should handle 2ck from middle line', () => { + const state = createTestState( + ['line1', 'line2', 'line3', 'line4', 'line5'], + 3, + 0, + ); + const action = { + type: 'vim_change_movement' as const, + payload: { movement: 'k' as const, count: 2 }, + }; + + const result = handleVimAction(state, action); + expect(result).toHaveOnlyValidCharacters(); + // 2ck from line 3: delete lines 1, 2, 3 (current + 2 above) + expect(result.lines).toEqual(['line1', 'line5']); + expect(result.cursorRow).toBe(1); + expect(result.cursorCol).toBe(0); }); }); }); diff --git a/packages/cli/src/ui/components/shared/vim-buffer-actions.ts b/packages/cli/src/ui/components/shared/vim-buffer-actions.ts index 10181994749..1479f6c3c3c 100644 --- a/packages/cli/src/ui/components/shared/vim-buffer-actions.ts +++ b/packages/cli/src/ui/components/shared/vim-buffer-actions.ts @@ -39,7 +39,13 @@ export type VimAction = Extract< | { type: 'vim_delete_line' } | { type: 'vim_change_line' } | { type: 'vim_delete_to_end_of_line' } + | { type: 'vim_delete_to_start_of_line' } + | { type: 'vim_delete_to_first_nonwhitespace' } | { type: 'vim_change_to_end_of_line' } + | { type: 'vim_change_to_start_of_line' } + | { type: 'vim_change_to_first_nonwhitespace' } + | { type: 'vim_delete_to_first_line' } + | { type: 'vim_delete_to_last_line' } | { type: 'vim_change_movement' } | { type: 'vim_move_left' } | { type: 'vim_move_right' } @@ -387,21 +393,253 @@ export function handleVimAction( case 'vim_delete_to_end_of_line': case 'vim_change_to_end_of_line': { + const { count } = action.payload; const currentLine = lines[cursorRow] || ''; - if (cursorCol < cpLen(currentLine)) { + const totalLines = lines.length; + + if (count === 1) { + // Single line: delete from cursor to end of current line + if (cursorCol < cpLen(currentLine)) { + const nextState = detachExpandedPaste(pushUndo(state)); + return replaceRangeInternal( + nextState, + cursorRow, + cursorCol, + cursorRow, + cpLen(currentLine), + '', + ); + } + return state; + } else { + // Multi-line: delete from cursor to end of current line, plus (count-1) entire lines below + // For example, 2D = delete to EOL + delete next line entirely + const linesToDelete = Math.min(count - 1, totalLines - cursorRow - 1); + const endRow = cursorRow + linesToDelete; + + if (endRow === cursorRow) { + // No additional lines to delete, just delete to EOL + if (cursorCol < cpLen(currentLine)) { + const nextState = detachExpandedPaste(pushUndo(state)); + return replaceRangeInternal( + nextState, + cursorRow, + cursorCol, + cursorRow, + cpLen(currentLine), + '', + ); + } + return state; + } + + // Delete from cursor position to end of endRow (including newlines) const nextState = detachExpandedPaste(pushUndo(state)); + const endLine = lines[endRow] || ''; return replaceRangeInternal( nextState, cursorRow, cursorCol, + endRow, + cpLen(endLine), + '', + ); + } + } + + case 'vim_delete_to_start_of_line': { + if (cursorCol > 0) { + const nextState = detachExpandedPaste(pushUndo(state)); + return replaceRangeInternal( + nextState, + cursorRow, + 0, cursorRow, - cpLen(currentLine), + cursorCol, '', ); } return state; } + case 'vim_delete_to_first_nonwhitespace': { + // Delete from cursor to first non-whitespace character (vim 'd^') + const currentLine = lines[cursorRow] || ''; + const lineCodePoints = toCodePoints(currentLine); + let firstNonWs = 0; + while ( + firstNonWs < lineCodePoints.length && + /\s/.test(lineCodePoints[firstNonWs]) + ) { + firstNonWs++; + } + // If line is all whitespace, firstNonWs would be lineCodePoints.length + // In VIM, ^ on whitespace-only line goes to column 0 + if (firstNonWs >= lineCodePoints.length) { + firstNonWs = 0; + } + // Delete between cursor and first non-whitespace (whichever direction) + if (cursorCol !== firstNonWs) { + const startCol = Math.min(cursorCol, firstNonWs); + const endCol = Math.max(cursorCol, firstNonWs); + const nextState = detachExpandedPaste(pushUndo(state)); + return replaceRangeInternal( + nextState, + cursorRow, + startCol, + cursorRow, + endCol, + '', + ); + } + return state; + } + + case 'vim_change_to_start_of_line': { + // Change from cursor to start of line (vim 'c0') + if (cursorCol > 0) { + const nextState = detachExpandedPaste(pushUndo(state)); + return replaceRangeInternal( + nextState, + cursorRow, + 0, + cursorRow, + cursorCol, + '', + ); + } + return state; + } + + case 'vim_change_to_first_nonwhitespace': { + // Change from cursor to first non-whitespace character (vim 'c^') + const currentLine = lines[cursorRow] || ''; + const lineCodePoints = toCodePoints(currentLine); + let firstNonWs = 0; + while ( + firstNonWs < lineCodePoints.length && + /\s/.test(lineCodePoints[firstNonWs]) + ) { + firstNonWs++; + } + // If line is all whitespace, firstNonWs would be lineCodePoints.length + // In VIM, ^ on whitespace-only line goes to column 0 + if (firstNonWs >= lineCodePoints.length) { + firstNonWs = 0; + } + // Change between cursor and first non-whitespace (whichever direction) + if (cursorCol !== firstNonWs) { + const startCol = Math.min(cursorCol, firstNonWs); + const endCol = Math.max(cursorCol, firstNonWs); + const nextState = detachExpandedPaste(pushUndo(state)); + return replaceRangeInternal( + nextState, + cursorRow, + startCol, + cursorRow, + endCol, + '', + ); + } + return state; + } + + case 'vim_delete_to_first_line': { + // Delete from first line (or line N if count given) to current line (vim 'dgg' or 'd5gg') + // count is the target line number (1-based), or 0 for first line + const { count } = action.payload; + const totalLines = lines.length; + + // Determine target row (0-based) + // count=0 means go to first line, count=N means go to line N (1-based) + let targetRow: number; + if (count > 0) { + targetRow = Math.min(count - 1, totalLines - 1); + } else { + targetRow = 0; + } + + // Determine the range to delete (from min to max row, inclusive) + const startRow = Math.min(cursorRow, targetRow); + const endRow = Math.max(cursorRow, targetRow); + const linesToDelete = endRow - startRow + 1; + + if (linesToDelete >= totalLines) { + // Deleting all lines - keep one empty line + const nextState = detachExpandedPaste(pushUndo(state)); + return { + ...nextState, + lines: [''], + cursorRow: 0, + cursorCol: 0, + preferredCol: null, + }; + } + + const nextState = detachExpandedPaste(pushUndo(state)); + const newLines = [...nextState.lines]; + newLines.splice(startRow, linesToDelete); + + // Cursor goes to start of the deleted range, clamped to valid bounds + const newCursorRow = Math.min(startRow, newLines.length - 1); + + return { + ...nextState, + lines: newLines, + cursorRow: newCursorRow, + cursorCol: 0, + preferredCol: null, + }; + } + + case 'vim_delete_to_last_line': { + // Delete from current line to last line (vim 'dG') or to line N (vim 'd5G') + // count is the target line number (1-based), or 0 for last line + const { count } = action.payload; + const totalLines = lines.length; + + // Determine target row (0-based) + // count=0 means go to last line, count=N means go to line N (1-based) + let targetRow: number; + if (count > 0) { + targetRow = Math.min(count - 1, totalLines - 1); + } else { + targetRow = totalLines - 1; + } + + // Determine the range to delete (from min to max row, inclusive) + const startRow = Math.min(cursorRow, targetRow); + const endRow = Math.max(cursorRow, targetRow); + const linesToDelete = endRow - startRow + 1; + + if (linesToDelete >= totalLines) { + // Deleting all lines - keep one empty line + const nextState = detachExpandedPaste(pushUndo(state)); + return { + ...nextState, + lines: [''], + cursorRow: 0, + cursorCol: 0, + preferredCol: null, + }; + } + + const nextState = detachExpandedPaste(pushUndo(state)); + const newLines = [...nextState.lines]; + newLines.splice(startRow, linesToDelete); + + // Move cursor to the start of the deleted range (or last line if needed) + const newCursorRow = Math.min(startRow, newLines.length - 1); + + return { + ...nextState, + lines: newLines, + cursorRow: newCursorRow, + cursorCol: 0, + preferredCol: null, + }; + } + case 'vim_change_movement': { const { movement, count } = action.payload; const totalLines = lines.length; @@ -422,88 +660,65 @@ export function handleVimAction( } case 'j': { - // Down - const linesToChange = Math.min(count, totalLines - cursorRow); + // Down - delete/change current line + count lines below + const linesToChange = Math.min(count + 1, totalLines - cursorRow); if (linesToChange > 0) { - if (totalLines === 1) { - const currentLine = state.lines[0] || ''; - return replaceRangeInternal( - detachExpandedPaste(pushUndo(state)), - 0, - 0, - 0, - cpLen(currentLine), - '', - ); - } else { + if (linesToChange >= totalLines) { + // Deleting all lines - keep one empty line const nextState = detachExpandedPaste(pushUndo(state)); - const { startOffset, endOffset } = getLineRangeOffsets( - cursorRow, - linesToChange, - nextState.lines, - ); - const { startRow, startCol, endRow, endCol } = - getPositionFromOffsets(startOffset, endOffset, nextState.lines); - return replaceRangeInternal( - nextState, - startRow, - startCol, - endRow, - endCol, - '', - ); + return { + ...nextState, + lines: [''], + cursorRow: 0, + cursorCol: 0, + preferredCol: null, + }; } + + const nextState = detachExpandedPaste(pushUndo(state)); + const newLines = [...nextState.lines]; + newLines.splice(cursorRow, linesToChange); + + return { + ...nextState, + lines: newLines, + cursorRow: Math.min(cursorRow, newLines.length - 1), + cursorCol: 0, + preferredCol: null, + }; } return state; } case 'k': { - // Up - const upLines = Math.min(count, cursorRow + 1); - if (upLines > 0) { - if (state.lines.length === 1) { - const currentLine = state.lines[0] || ''; - return replaceRangeInternal( - detachExpandedPaste(pushUndo(state)), - 0, - 0, - 0, - cpLen(currentLine), - '', - ); - } else { - const startRow = Math.max(0, cursorRow - count + 1); - const linesToChange = cursorRow - startRow + 1; + // Up - delete/change current line + count lines above + const startRow = Math.max(0, cursorRow - count); + const linesToChange = cursorRow - startRow + 1; + + if (linesToChange > 0) { + if (linesToChange >= totalLines) { + // Deleting all lines - keep one empty line const nextState = detachExpandedPaste(pushUndo(state)); - const { startOffset, endOffset } = getLineRangeOffsets( - startRow, - linesToChange, - nextState.lines, - ); - const { - startRow: newStartRow, - startCol, - endRow, - endCol, - } = getPositionFromOffsets( - startOffset, - endOffset, - nextState.lines, - ); - const resultState = replaceRangeInternal( - nextState, - newStartRow, - startCol, - endRow, - endCol, - '', - ); return { - ...resultState, - cursorRow: startRow, + ...nextState, + lines: [''], + cursorRow: 0, cursorCol: 0, + preferredCol: null, }; } + + const nextState = detachExpandedPaste(pushUndo(state)); + const newLines = [...nextState.lines]; + newLines.splice(startRow, linesToChange); + + return { + ...nextState, + lines: newLines, + cursorRow: Math.min(startRow, newLines.length - 1), + cursorCol: 0, + preferredCol: null, + }; } return state; } @@ -910,6 +1125,11 @@ export function handleVimAction( col++; } + // If line is all whitespace or empty, ^ goes to column 0 (standard Vim behavior) + if (col >= lineCodePoints.length) { + col = 0; + } + return { ...state, cursorCol: col, diff --git a/packages/cli/src/ui/hooks/vim.test.tsx b/packages/cli/src/ui/hooks/vim.test.tsx index 5a5ca6a8584..7b03354eae3 100644 --- a/packages/cli/src/ui/hooks/vim.test.tsx +++ b/packages/cli/src/ui/hooks/vim.test.tsx @@ -1708,6 +1708,7 @@ describe('useVim hook', () => { cursorRow: 0, cursorCol: 6, actionType: 'vim_delete_to_end_of_line' as const, + count: 1, expectedLines: ['hello '], expectedCursorRow: 0, expectedCursorCol: 6, @@ -1719,6 +1720,7 @@ describe('useVim hook', () => { cursorRow: 0, cursorCol: 11, actionType: 'vim_delete_to_end_of_line' as const, + count: 1, expectedLines: ['hello world'], expectedCursorRow: 0, expectedCursorCol: 11, @@ -1730,6 +1732,7 @@ describe('useVim hook', () => { cursorRow: 0, cursorCol: 6, actionType: 'vim_change_to_end_of_line' as const, + count: 1, expectedLines: ['hello '], expectedCursorRow: 0, expectedCursorCol: 6, @@ -1741,6 +1744,7 @@ describe('useVim hook', () => { cursorRow: 0, cursorCol: 0, actionType: 'vim_change_to_end_of_line' as const, + count: 1, expectedLines: [''], expectedCursorRow: 0, expectedCursorCol: 0, diff --git a/packages/cli/src/ui/hooks/vim.ts b/packages/cli/src/ui/hooks/vim.ts index bf91ba062b5..9de771564cf 100644 --- a/packages/cli/src/ui/hooks/vim.ts +++ b/packages/cli/src/ui/hooks/vim.ts @@ -44,19 +44,33 @@ const CMD_TYPES = { UP: 'ck', RIGHT: 'cl', }, + DELETE_MOVEMENT: { + LEFT: 'dh', + DOWN: 'dj', + UP: 'dk', + RIGHT: 'dl', + }, + DELETE_TO_SOL: 'd0', + DELETE_TO_FIRST_NONWS: 'd^', + CHANGE_TO_SOL: 'c0', + CHANGE_TO_FIRST_NONWS: 'c^', + DELETE_TO_FIRST_LINE: 'dgg', + DELETE_TO_LAST_LINE: 'dG', + CHANGE_TO_FIRST_LINE: 'cgg', + CHANGE_TO_LAST_LINE: 'cG', } as const; // Helper function to clear pending state const createClearPendingState = () => ({ count: 0, - pendingOperator: null as 'g' | 'd' | 'c' | null, + pendingOperator: null as 'g' | 'd' | 'c' | 'dg' | 'cg' | null, }); // State and action types for useReducer type VimState = { mode: VimMode; count: number; - pendingOperator: 'g' | 'd' | 'c' | null; + pendingOperator: 'g' | 'd' | 'c' | 'dg' | 'cg' | null; lastCommand: { type: string; count: number } | null; }; @@ -65,7 +79,10 @@ type VimAction = | { type: 'SET_COUNT'; count: number } | { type: 'INCREMENT_COUNT'; digit: number } | { type: 'CLEAR_COUNT' } - | { type: 'SET_PENDING_OPERATOR'; operator: 'g' | 'd' | 'c' | null } + | { + type: 'SET_PENDING_OPERATOR'; + operator: 'g' | 'd' | 'c' | 'dg' | 'cg' | null; + } | { type: 'SET_LAST_COMMAND'; command: { type: string; count: number } | null; @@ -279,12 +296,73 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { } case CMD_TYPES.DELETE_TO_EOL: { - buffer.vimDeleteToEndOfLine(); + buffer.vimDeleteToEndOfLine(count); + break; + } + + case CMD_TYPES.DELETE_TO_SOL: { + buffer.vimDeleteToStartOfLine(); + break; + } + + case CMD_TYPES.DELETE_MOVEMENT.LEFT: + case CMD_TYPES.DELETE_MOVEMENT.DOWN: + case CMD_TYPES.DELETE_MOVEMENT.UP: + case CMD_TYPES.DELETE_MOVEMENT.RIGHT: { + const movementMap: Record = { + [CMD_TYPES.DELETE_MOVEMENT.LEFT]: 'h', + [CMD_TYPES.DELETE_MOVEMENT.DOWN]: 'j', + [CMD_TYPES.DELETE_MOVEMENT.UP]: 'k', + [CMD_TYPES.DELETE_MOVEMENT.RIGHT]: 'l', + }; + const movementType = movementMap[cmdType]; + if (movementType) { + buffer.vimChangeMovement(movementType, count); + } break; } case CMD_TYPES.CHANGE_TO_EOL: { - buffer.vimChangeToEndOfLine(); + buffer.vimChangeToEndOfLine(count); + updateMode('INSERT'); + break; + } + + case CMD_TYPES.DELETE_TO_FIRST_NONWS: { + buffer.vimDeleteToFirstNonWhitespace(); + break; + } + + case CMD_TYPES.CHANGE_TO_SOL: { + buffer.vimChangeToStartOfLine(); + updateMode('INSERT'); + break; + } + + case CMD_TYPES.CHANGE_TO_FIRST_NONWS: { + buffer.vimChangeToFirstNonWhitespace(); + updateMode('INSERT'); + break; + } + + case CMD_TYPES.DELETE_TO_FIRST_LINE: { + buffer.vimDeleteToFirstLine(count); + break; + } + + case CMD_TYPES.DELETE_TO_LAST_LINE: { + buffer.vimDeleteToLastLine(count); + break; + } + + case CMD_TYPES.CHANGE_TO_FIRST_LINE: { + buffer.vimDeleteToFirstLine(count); + updateMode('INSERT'); + break; + } + + case CMD_TYPES.CHANGE_TO_LAST_LINE: { + buffer.vimDeleteToLastLine(count); updateMode('INSERT'); break; } @@ -324,6 +402,14 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { return false; // Let InputPrompt handle completion } + // Let InputPrompt handle Ctrl+U (kill line left) and Ctrl+K (kill line right) + if ( + normalizedKey.ctrl && + (normalizedKey.name === 'u' || normalizedKey.name === 'k') + ) { + return false; + } + // Let InputPrompt handle Ctrl+V for clipboard image pasting if (normalizedKey.ctrl && normalizedKey.name === 'v') { return false; // Let InputPrompt handle clipboard functionality @@ -403,6 +489,37 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { [getCurrentCount, dispatch, buffer, updateMode], ); + /** + * Handles delete movement commands (dh, dj, dk, dl) + * @param movement - The movement direction + * @returns boolean indicating if command was handled + */ + const handleDeleteMovement = useCallback( + (movement: 'h' | 'j' | 'k' | 'l'): boolean => { + const count = getCurrentCount(); + dispatch({ type: 'CLEAR_COUNT' }); + // Note: vimChangeMovement performs the same deletion operation as what we need. + // The only difference between 'change' and 'delete' is that 'change' enters + // INSERT mode after deletion, which is handled here (we simply don't call updateMode). + buffer.vimChangeMovement(movement, count); + + const cmdTypeMap = { + h: CMD_TYPES.DELETE_MOVEMENT.LEFT, + j: CMD_TYPES.DELETE_MOVEMENT.DOWN, + k: CMD_TYPES.DELETE_MOVEMENT.UP, + l: CMD_TYPES.DELETE_MOVEMENT.RIGHT, + }; + + dispatch({ + type: 'SET_LAST_COMMAND', + command: { type: cmdTypeMap[movement], count }, + }); + dispatch({ type: 'SET_PENDING_OPERATOR', operator: null }); + return true; + }, + [getCurrentCount, dispatch, buffer], + ); + /** * Handles operator-motion commands (dw/cw, db/cb, de/ce) * @param operator - The operator type ('d' for delete, 'c' for change) @@ -510,7 +627,10 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { switch (normalizedKey.sequence) { case 'h': { - // Check if this is part of a change command (ch) + // Check if this is part of a delete or change command (dh/ch) + if (state.pendingOperator === 'd') { + return handleDeleteMovement('h'); + } if (state.pendingOperator === 'c') { return handleChangeMovement('h'); } @@ -522,7 +642,10 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { } case 'j': { - // Check if this is part of a change command (cj) + // Check if this is part of a delete or change command (dj/cj) + if (state.pendingOperator === 'd') { + return handleDeleteMovement('j'); + } if (state.pendingOperator === 'c') { return handleChangeMovement('j'); } @@ -534,7 +657,10 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { } case 'k': { - // Check if this is part of a change command (ck) + // Check if this is part of a delete or change command (dk/ck) + if (state.pendingOperator === 'd') { + return handleDeleteMovement('k'); + } if (state.pendingOperator === 'c') { return handleChangeMovement('k'); } @@ -546,7 +672,10 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { } case 'l': { - // Check if this is part of a change command (cl) + // Check if this is part of a delete or change command (dl/cl) + if (state.pendingOperator === 'd') { + return handleDeleteMovement('l'); + } if (state.pendingOperator === 'c') { return handleChangeMovement('l'); } @@ -691,6 +820,30 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { } case '0': { + // Check if this is part of a delete command (d0) + if (state.pendingOperator === 'd') { + buffer.vimDeleteToStartOfLine(); + dispatch({ + type: 'SET_LAST_COMMAND', + command: { type: CMD_TYPES.DELETE_TO_SOL, count: 1 }, + }); + dispatch({ type: 'CLEAR_COUNT' }); + dispatch({ type: 'SET_PENDING_OPERATOR', operator: null }); + return true; + } + // Check if this is part of a change command (c0) + if (state.pendingOperator === 'c') { + buffer.vimChangeToStartOfLine(); + dispatch({ + type: 'SET_LAST_COMMAND', + command: { type: CMD_TYPES.CHANGE_TO_SOL, count: 1 }, + }); + dispatch({ type: 'CLEAR_COUNT' }); + dispatch({ type: 'SET_PENDING_OPERATOR', operator: null }); + updateMode('INSERT'); + return true; + } + // Move to start of line buffer.vimMoveToLineStart(); dispatch({ type: 'CLEAR_COUNT' }); @@ -698,13 +851,64 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { } case '$': { - // Move to end of line + // Check if this is part of a delete command (d$) + if (state.pendingOperator === 'd') { + buffer.vimDeleteToEndOfLine(repeatCount); + dispatch({ + type: 'SET_LAST_COMMAND', + command: { type: CMD_TYPES.DELETE_TO_EOL, count: repeatCount }, + }); + dispatch({ type: 'CLEAR_COUNT' }); + dispatch({ type: 'SET_PENDING_OPERATOR', operator: null }); + return true; + } + // Check if this is part of a change command (c$) + if (state.pendingOperator === 'c') { + buffer.vimChangeToEndOfLine(repeatCount); + dispatch({ + type: 'SET_LAST_COMMAND', + command: { type: CMD_TYPES.CHANGE_TO_EOL, count: repeatCount }, + }); + dispatch({ type: 'CLEAR_COUNT' }); + dispatch({ type: 'SET_PENDING_OPERATOR', operator: null }); + updateMode('INSERT'); + return true; + } + + // Move to end of line (with count, move down count-1 lines first) + if (repeatCount > 1) { + buffer.vimMoveDown(repeatCount - 1); + } buffer.vimMoveToLineEnd(); dispatch({ type: 'CLEAR_COUNT' }); return true; } case '^': { + // Check if this is part of a delete command (d^) + if (state.pendingOperator === 'd') { + buffer.vimDeleteToFirstNonWhitespace(); + dispatch({ + type: 'SET_LAST_COMMAND', + command: { type: CMD_TYPES.DELETE_TO_FIRST_NONWS, count: 1 }, + }); + dispatch({ type: 'CLEAR_COUNT' }); + dispatch({ type: 'SET_PENDING_OPERATOR', operator: null }); + return true; + } + // Check if this is part of a change command (c^) + if (state.pendingOperator === 'c') { + buffer.vimChangeToFirstNonWhitespace(); + dispatch({ + type: 'SET_LAST_COMMAND', + command: { type: CMD_TYPES.CHANGE_TO_FIRST_NONWS, count: 1 }, + }); + dispatch({ type: 'CLEAR_COUNT' }); + dispatch({ type: 'SET_PENDING_OPERATOR', operator: null }); + updateMode('INSERT'); + return true; + } + // Move to first non-whitespace character buffer.vimMoveToFirstNonWhitespace(); dispatch({ type: 'CLEAR_COUNT' }); @@ -712,19 +916,94 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { } case 'g': { + if (state.pendingOperator === 'd') { + // 'dg' - need another 'g' for 'dgg' command + dispatch({ type: 'SET_PENDING_OPERATOR', operator: 'dg' }); + return true; + } + if (state.pendingOperator === 'c') { + // 'cg' - need another 'g' for 'cgg' command + dispatch({ type: 'SET_PENDING_OPERATOR', operator: 'cg' }); + return true; + } + if (state.pendingOperator === 'dg') { + // 'dgg' command - delete from first line (or line N) to current line + // Pass state.count directly (0 means first line, N means line N) + buffer.vimDeleteToFirstLine(state.count); + dispatch({ + type: 'SET_LAST_COMMAND', + command: { + type: CMD_TYPES.DELETE_TO_FIRST_LINE, + count: state.count, + }, + }); + dispatch({ type: 'SET_PENDING_OPERATOR', operator: null }); + dispatch({ type: 'CLEAR_COUNT' }); + return true; + } + if (state.pendingOperator === 'cg') { + // 'cgg' command - change from first line (or line N) to current line + buffer.vimDeleteToFirstLine(state.count); + dispatch({ + type: 'SET_LAST_COMMAND', + command: { + type: CMD_TYPES.CHANGE_TO_FIRST_LINE, + count: state.count, + }, + }); + dispatch({ type: 'SET_PENDING_OPERATOR', operator: null }); + dispatch({ type: 'CLEAR_COUNT' }); + updateMode('INSERT'); + return true; + } if (state.pendingOperator === 'g') { - // Second 'g' - go to first line (gg command) - buffer.vimMoveToFirstLine(); + // Second 'g' - go to line N (gg command), or first line if no count + if (state.count > 0) { + buffer.vimMoveToLine(state.count); + } else { + buffer.vimMoveToFirstLine(); + } dispatch({ type: 'SET_PENDING_OPERATOR', operator: null }); + dispatch({ type: 'CLEAR_COUNT' }); } else { - // First 'g' - wait for second g + // First 'g' - wait for second g (don't clear count yet) dispatch({ type: 'SET_PENDING_OPERATOR', operator: 'g' }); } - dispatch({ type: 'CLEAR_COUNT' }); return true; } case 'G': { + // Check if this is part of a delete command (dG) + if (state.pendingOperator === 'd') { + // Pass state.count directly (0 means last line, N means line N) + buffer.vimDeleteToLastLine(state.count); + dispatch({ + type: 'SET_LAST_COMMAND', + command: { + type: CMD_TYPES.DELETE_TO_LAST_LINE, + count: state.count, + }, + }); + dispatch({ type: 'CLEAR_COUNT' }); + dispatch({ type: 'SET_PENDING_OPERATOR', operator: null }); + return true; + } + // Check if this is part of a change command (cG) + if (state.pendingOperator === 'c') { + buffer.vimDeleteToLastLine(state.count); + dispatch({ + type: 'SET_LAST_COMMAND', + command: { + type: CMD_TYPES.CHANGE_TO_LAST_LINE, + count: state.count, + }, + }); + dispatch({ type: 'CLEAR_COUNT' }); + dispatch({ type: 'SET_PENDING_OPERATOR', operator: null }); + updateMode('INSERT'); + return true; + } + if (state.count > 0) { // Go to specific line number (1-based) when a count was provided buffer.vimMoveToLine(state.count); @@ -789,34 +1068,44 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { } case 'D': { - // Delete from cursor to end of line (equivalent to d$) - executeCommand(CMD_TYPES.DELETE_TO_EOL, 1); + // Delete from cursor to end of line (with count, delete to end of N lines) + executeCommand(CMD_TYPES.DELETE_TO_EOL, repeatCount); dispatch({ type: 'SET_LAST_COMMAND', - command: { type: CMD_TYPES.DELETE_TO_EOL, count: 1 }, + command: { type: CMD_TYPES.DELETE_TO_EOL, count: repeatCount }, }); 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); + // Change from cursor to end of line (with count, change to end of N lines) + executeCommand(CMD_TYPES.CHANGE_TO_EOL, repeatCount); dispatch({ type: 'SET_LAST_COMMAND', - command: { type: CMD_TYPES.CHANGE_TO_EOL, count: 1 }, + command: { type: CMD_TYPES.CHANGE_TO_EOL, count: repeatCount }, }); dispatch({ type: 'CLEAR_COUNT' }); return true; } + case 'u': { + // Undo last change + for (let i = 0; i < repeatCount; i++) { + buffer.undo(); + } + dispatch({ type: 'CLEAR_COUNT' }); + return true; + } + case '.': { - // Repeat last command + // Repeat last command (use current count if provided, otherwise use original count) if (state.lastCommand) { const cmdData = state.lastCommand; + const count = state.count > 0 ? state.count : cmdData.count; // All repeatable commands are now handled by executeCommand - executeCommand(cmdData.type, cmdData.count); + executeCommand(cmdData.type, count); } dispatch({ type: 'CLEAR_COUNT' }); @@ -827,6 +1116,9 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { // Check for arrow keys (they have different sequences but known names) if (normalizedKey.name === 'left') { // Left arrow - same as 'h' + if (state.pendingOperator === 'd') { + return handleDeleteMovement('h'); + } if (state.pendingOperator === 'c') { return handleChangeMovement('h'); } @@ -839,6 +1131,9 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { if (normalizedKey.name === 'down') { // Down arrow - same as 'j' + if (state.pendingOperator === 'd') { + return handleDeleteMovement('j'); + } if (state.pendingOperator === 'c') { return handleChangeMovement('j'); } @@ -851,6 +1146,9 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { if (normalizedKey.name === 'up') { // Up arrow - same as 'k' + if (state.pendingOperator === 'd') { + return handleDeleteMovement('k'); + } if (state.pendingOperator === 'c') { return handleChangeMovement('k'); } @@ -863,6 +1161,9 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { if (normalizedKey.name === 'right') { // Right arrow - same as 'l' + if (state.pendingOperator === 'd') { + return handleDeleteMovement('l'); + } if (state.pendingOperator === 'c') { return handleChangeMovement('l'); } @@ -895,6 +1196,7 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { dispatch, getCurrentCount, handleChangeMovement, + handleDeleteMovement, handleOperatorMotion, buffer, executeCommand,