From 08e174a05c634726e32b4d61df7a77a91bf5bccf Mon Sep 17 00:00:00 2001 From: Ali Anari Date: Wed, 11 Mar 2026 11:43:42 -0700 Subject: [PATCH] feat(ui): add vim yank/paste (y/p/P) with unnamed register (#22026) Co-authored-by: Jacob Richman --- .../ui/components/shared/text-buffer.test.ts | 1 + .../src/ui/components/shared/text-buffer.ts | 82 +++ .../shared/vim-buffer-actions.test.ts | 439 +++++++++++++++ .../components/shared/vim-buffer-actions.ts | 503 ++++++++++++++++-- packages/cli/src/ui/hooks/vim.test.tsx | 210 ++++++++ packages/cli/src/ui/hooks/vim.ts | 160 +++++- 6 files changed, 1361 insertions(+), 34 deletions(-) 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 7ea88529ad..ff4f3495d7 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.test.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.test.ts @@ -66,6 +66,7 @@ const initialState: TextBufferState = { visualLayout: defaultVisualLayout, pastedContent: {}, expandedPaste: null, + yankRegister: null, }; /** diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index ea78908b98..ad04ff91fe 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -1568,6 +1568,7 @@ export interface TextBufferState { visualLayout: VisualLayout; pastedContent: Record; expandedPaste: ExpandedPasteInfo | null; + yankRegister: { text: string; linewise: boolean } | null; } const historyLimit = 100; @@ -1722,6 +1723,14 @@ export type TextBufferAction = type: 'vim_delete_to_char_backward'; payload: { char: string; count: number; till: boolean }; } + | { type: 'vim_yank_line'; payload: { count: number } } + | { type: 'vim_yank_word_forward'; payload: { count: number } } + | { type: 'vim_yank_big_word_forward'; payload: { count: number } } + | { type: 'vim_yank_word_end'; payload: { count: number } } + | { type: 'vim_yank_big_word_end'; payload: { count: number } } + | { type: 'vim_yank_to_end_of_line'; payload: { count: number } } + | { type: 'vim_paste_after'; payload: { count: number } } + | { type: 'vim_paste_before'; payload: { count: number } } | { type: 'toggle_paste_expansion'; payload: { id: string; row: number; col: number }; @@ -2510,6 +2519,14 @@ function textBufferReducerLogic( case 'vim_find_char_backward': case 'vim_delete_to_char_forward': case 'vim_delete_to_char_backward': + case 'vim_yank_line': + case 'vim_yank_word_forward': + case 'vim_yank_big_word_forward': + case 'vim_yank_word_end': + case 'vim_yank_big_word_end': + case 'vim_yank_to_end_of_line': + case 'vim_paste_after': + case 'vim_paste_before': return handleVimAction(state, action as VimAction); case 'toggle_paste_expansion': { @@ -2765,6 +2782,7 @@ export function useTextBuffer({ visualLayout, pastedContent: {}, expandedPaste: null, + yankRegister: null, }; }, [initialText, initialCursorOffset, viewport.width, viewport.height]); @@ -3173,6 +3191,38 @@ export function useTextBuffer({ dispatch({ type: 'vim_escape_insert_mode' }); }, []); + const vimYankLine = useCallback((count: number): void => { + dispatch({ type: 'vim_yank_line', payload: { count } }); + }, []); + + const vimYankWordForward = useCallback((count: number): void => { + dispatch({ type: 'vim_yank_word_forward', payload: { count } }); + }, []); + + const vimYankBigWordForward = useCallback((count: number): void => { + dispatch({ type: 'vim_yank_big_word_forward', payload: { count } }); + }, []); + + const vimYankWordEnd = useCallback((count: number): void => { + dispatch({ type: 'vim_yank_word_end', payload: { count } }); + }, []); + + const vimYankBigWordEnd = useCallback((count: number): void => { + dispatch({ type: 'vim_yank_big_word_end', payload: { count } }); + }, []); + + const vimYankToEndOfLine = useCallback((count: number): void => { + dispatch({ type: 'vim_yank_to_end_of_line', payload: { count } }); + }, []); + + const vimPasteAfter = useCallback((count: number): void => { + dispatch({ type: 'vim_paste_after', payload: { count } }); + }, []); + + const vimPasteBefore = useCallback((count: number): void => { + dispatch({ type: 'vim_paste_before', payload: { count } }); + }, []); + const openInExternalEditor = useCallback(async (): Promise => { const tmpDir = fs.mkdtempSync(pathMod.join(os.tmpdir(), 'gemini-edit-')); const filePath = pathMod.join(tmpDir, 'buffer.txt'); @@ -3640,6 +3690,14 @@ export function useTextBuffer({ vimMoveToLastLine, vimMoveToLine, vimEscapeInsertMode, + vimYankLine, + vimYankWordForward, + vimYankBigWordForward, + vimYankWordEnd, + vimYankBigWordEnd, + vimYankToEndOfLine, + vimPasteAfter, + vimPasteBefore, }), [ lines, @@ -3735,6 +3793,14 @@ export function useTextBuffer({ vimMoveToLastLine, vimMoveToLine, vimEscapeInsertMode, + vimYankLine, + vimYankWordForward, + vimYankBigWordForward, + vimYankWordEnd, + vimYankBigWordEnd, + vimYankToEndOfLine, + vimPasteAfter, + vimPasteBefore, ], ); return returnValue; @@ -4095,4 +4161,20 @@ export interface TextBuffer { * Handle escape from insert mode (moves cursor left if not at line start) */ vimEscapeInsertMode: () => void; + /** Yank N lines into the unnamed register (vim 'yy' / 'Nyy') */ + vimYankLine: (count: number) => void; + /** Yank forward N words into the unnamed register (vim 'yw') */ + vimYankWordForward: (count: number) => void; + /** Yank forward N big words into the unnamed register (vim 'yW') */ + vimYankBigWordForward: (count: number) => void; + /** Yank to end of N words into the unnamed register (vim 'ye') */ + vimYankWordEnd: (count: number) => void; + /** Yank to end of N big words into the unnamed register (vim 'yE') */ + vimYankBigWordEnd: (count: number) => void; + /** Yank from cursor to end of line into the unnamed register (vim 'y$') */ + vimYankToEndOfLine: (count: number) => void; + /** Paste the unnamed register after cursor (vim 'p') */ + vimPasteAfter: (count: number) => void; + /** Paste the unnamed register before cursor (vim 'P') */ + vimPasteBefore: (count: number) => void; } 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 2bf5cfed08..9f163f0c54 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 @@ -36,6 +36,7 @@ const createTestState = ( visualLayout: defaultVisualLayout, pastedContent: {}, expandedPaste: null, + yankRegister: null, }); describe('vim-buffer-actions', () => { @@ -2227,4 +2228,442 @@ describe('vim-buffer-actions', () => { expect(result.cursorCol).toBe(0); }); }); + + describe('vim yank and paste', () => { + describe('vim_yank_line (yy)', () => { + it('should yank current line into register as linewise', () => { + const state = createTestState(['hello world'], 0, 0); + const result = handleVimAction(state, { + type: 'vim_yank_line' as const, + payload: { count: 1 }, + }); + expect(result.yankRegister).toEqual({ + text: 'hello world', + linewise: true, + }); + }); + + it('should not modify the buffer or cursor position', () => { + const state = createTestState(['hello world'], 0, 3); + const result = handleVimAction(state, { + type: 'vim_yank_line' as const, + payload: { count: 1 }, + }); + expect(result.lines).toEqual(['hello world']); + expect(result.cursorRow).toBe(0); + expect(result.cursorCol).toBe(3); + }); + + it('should yank multiple lines with count', () => { + const state = createTestState(['line1', 'line2', 'line3'], 0, 0); + const result = handleVimAction(state, { + type: 'vim_yank_line' as const, + payload: { count: 2 }, + }); + expect(result.yankRegister).toEqual({ + text: 'line1\nline2', + linewise: true, + }); + expect(result.lines).toEqual(['line1', 'line2', 'line3']); + }); + + it('should clamp count to available lines', () => { + const state = createTestState(['only'], 0, 0); + const result = handleVimAction(state, { + type: 'vim_yank_line' as const, + payload: { count: 99 }, + }); + expect(result.yankRegister).toEqual({ text: 'only', linewise: true }); + }); + }); + + describe('vim_yank_word_forward (yw)', () => { + it('should yank from cursor to start of next word', () => { + const state = createTestState(['hello world'], 0, 0); + const result = handleVimAction(state, { + type: 'vim_yank_word_forward' as const, + payload: { count: 1 }, + }); + expect(result.yankRegister).toEqual({ + text: 'hello ', + linewise: false, + }); + expect(result.lines).toEqual(['hello world']); + }); + }); + + describe('vim_yank_big_word_forward (yW)', () => { + it('should yank from cursor to start of next big word', () => { + const state = createTestState(['hello world'], 0, 0); + const result = handleVimAction(state, { + type: 'vim_yank_big_word_forward' as const, + payload: { count: 1 }, + }); + expect(result.yankRegister).toEqual({ + text: 'hello ', + linewise: false, + }); + expect(result.lines).toEqual(['hello world']); + }); + }); + + describe('vim_yank_word_end (ye)', () => { + it('should yank from cursor to end of current word', () => { + const state = createTestState(['hello world'], 0, 0); + const result = handleVimAction(state, { + type: 'vim_yank_word_end' as const, + payload: { count: 1 }, + }); + expect(result.yankRegister).toEqual({ text: 'hello', linewise: false }); + expect(result.lines).toEqual(['hello world']); + }); + }); + + describe('vim_yank_big_word_end (yE)', () => { + it('should yank from cursor to end of current big word', () => { + const state = createTestState(['hello world'], 0, 0); + const result = handleVimAction(state, { + type: 'vim_yank_big_word_end' as const, + payload: { count: 1 }, + }); + expect(result.yankRegister).toEqual({ text: 'hello', linewise: false }); + expect(result.lines).toEqual(['hello world']); + }); + }); + + describe('vim_yank_to_end_of_line (y$)', () => { + it('should yank from cursor to end of line', () => { + const state = createTestState(['hello world'], 0, 6); + const result = handleVimAction(state, { + type: 'vim_yank_to_end_of_line' as const, + payload: { count: 1 }, + }); + expect(result.yankRegister).toEqual({ text: 'world', linewise: false }); + expect(result.lines).toEqual(['hello world']); + }); + + it('should do nothing when cursor is at end of line', () => { + const state = createTestState(['hello'], 0, 5); + const result = handleVimAction(state, { + type: 'vim_yank_to_end_of_line' as const, + payload: { count: 1 }, + }); + expect(result.yankRegister).toBeNull(); + }); + }); + + describe('delete operations populate yankRegister', () => { + it('should populate register on x (vim_delete_char)', () => { + const state = createTestState(['hello'], 0, 1); + const result = handleVimAction(state, { + type: 'vim_delete_char' as const, + payload: { count: 1 }, + }); + expect(result.yankRegister).toEqual({ text: 'e', linewise: false }); + expect(result.lines[0]).toBe('hllo'); + }); + + it('should populate register on X (vim_delete_char_before)', () => { + // cursor at col 2 ('l'); X deletes the char before = col 1 ('e') + const state = createTestState(['hello'], 0, 2); + const result = handleVimAction(state, { + type: 'vim_delete_char_before' as const, + payload: { count: 1 }, + }); + expect(result.yankRegister).toEqual({ text: 'e', linewise: false }); + expect(result.lines[0]).toBe('hllo'); + }); + + it('should populate register on dd (vim_delete_line) as linewise', () => { + const state = createTestState(['hello', 'world'], 0, 0); + const result = handleVimAction(state, { + type: 'vim_delete_line' as const, + payload: { count: 1 }, + }); + expect(result.yankRegister).toEqual({ text: 'hello', linewise: true }); + expect(result.lines).toEqual(['world']); + }); + + it('should populate register on 2dd with multiple lines', () => { + const state = createTestState(['one', 'two', 'three'], 0, 0); + const result = handleVimAction(state, { + type: 'vim_delete_line' as const, + payload: { count: 2 }, + }); + expect(result.yankRegister).toEqual({ + text: 'one\ntwo', + linewise: true, + }); + expect(result.lines).toEqual(['three']); + }); + + it('should populate register on dw (vim_delete_word_forward)', () => { + const state = createTestState(['hello world'], 0, 0); + const result = handleVimAction(state, { + type: 'vim_delete_word_forward' as const, + payload: { count: 1 }, + }); + expect(result.yankRegister).toEqual({ + text: 'hello ', + linewise: false, + }); + expect(result.lines[0]).toBe('world'); + }); + + it('should populate register on dW (vim_delete_big_word_forward)', () => { + const state = createTestState(['hello world'], 0, 0); + const result = handleVimAction(state, { + type: 'vim_delete_big_word_forward' as const, + payload: { count: 1 }, + }); + expect(result.yankRegister).toEqual({ + text: 'hello ', + linewise: false, + }); + }); + + it('should populate register on de (vim_delete_word_end)', () => { + const state = createTestState(['hello world'], 0, 0); + const result = handleVimAction(state, { + type: 'vim_delete_word_end' as const, + payload: { count: 1 }, + }); + expect(result.yankRegister).toEqual({ text: 'hello', linewise: false }); + }); + + it('should populate register on dE (vim_delete_big_word_end)', () => { + const state = createTestState(['hello world'], 0, 0); + const result = handleVimAction(state, { + type: 'vim_delete_big_word_end' as const, + payload: { count: 1 }, + }); + expect(result.yankRegister).toEqual({ text: 'hello', linewise: false }); + }); + + it('should populate register on D (vim_delete_to_end_of_line)', () => { + const state = createTestState(['hello world'], 0, 6); + const result = handleVimAction(state, { + type: 'vim_delete_to_end_of_line' as const, + payload: { count: 1 }, + }); + expect(result.yankRegister).toEqual({ text: 'world', linewise: false }); + expect(result.lines[0]).toBe('hello '); + }); + + it('should populate register on df (vim_delete_to_char_forward, inclusive)', () => { + const state = createTestState(['hello world'], 0, 0); + const result = handleVimAction(state, { + type: 'vim_delete_to_char_forward' as const, + payload: { char: 'o', count: 1, till: false }, + }); + expect(result.yankRegister).toEqual({ text: 'hello', linewise: false }); + }); + + it('should populate register on dt (vim_delete_to_char_forward, till)', () => { + const state = createTestState(['hello world'], 0, 0); + const result = handleVimAction(state, { + type: 'vim_delete_to_char_forward' as const, + payload: { char: 'o', count: 1, till: true }, + }); + // dt stops before 'o', so deletes 'hell' + expect(result.yankRegister).toEqual({ text: 'hell', linewise: false }); + }); + + it('should populate register on dF (vim_delete_to_char_backward, inclusive)', () => { + // cursor at 7 ('o' in world), dFo finds 'o' at col 4, deletes [4, 8) + const state = createTestState(['hello world'], 0, 7); + const result = handleVimAction(state, { + type: 'vim_delete_to_char_backward' as const, + payload: { char: 'o', count: 1, till: false }, + }); + expect(result.yankRegister).toEqual({ text: 'o wo', linewise: false }); + }); + + it('should populate register on dT (vim_delete_to_char_backward, till)', () => { + // cursor at 7 ('o' in world), dTo finds 'o' at col 4, deletes [5, 8) = ' wo' + const state = createTestState(['hello world'], 0, 7); + const result = handleVimAction(state, { + type: 'vim_delete_to_char_backward' as const, + payload: { char: 'o', count: 1, till: true }, + }); + expect(result.yankRegister).toEqual({ text: ' wo', linewise: false }); + }); + + it('should preserve existing register when delete finds nothing to delete', () => { + const state = { + ...createTestState(['hello'], 0, 5), + yankRegister: { text: 'preserved', linewise: false }, + }; + // x at end-of-line does nothing + const result = handleVimAction(state, { + type: 'vim_delete_char' as const, + payload: { count: 1 }, + }); + expect(result.yankRegister).toEqual({ + text: 'preserved', + linewise: false, + }); + }); + }); + + describe('vim_paste_after (p)', () => { + it('should paste charwise text after cursor and land on last pasted char', () => { + const state = { + ...createTestState(['abc'], 0, 1), + yankRegister: { text: 'XY', linewise: false }, + }; + const result = handleVimAction(state, { + type: 'vim_paste_after' as const, + payload: { count: 1 }, + }); + expect(result).toHaveOnlyValidCharacters(); + expect(result.lines[0]).toBe('abXYc'); + expect(result.cursorCol).toBe(3); + }); + + it('should paste charwise at end of line when cursor is on last char', () => { + const state = { + ...createTestState(['ab'], 0, 1), + yankRegister: { text: 'Z', linewise: false }, + }; + const result = handleVimAction(state, { + type: 'vim_paste_after' as const, + payload: { count: 1 }, + }); + expect(result).toHaveOnlyValidCharacters(); + expect(result.lines[0]).toBe('abZ'); + expect(result.cursorCol).toBe(2); + }); + + it('should paste linewise below current row', () => { + const state = { + ...createTestState(['hello', 'world'], 0, 0), + yankRegister: { text: 'inserted', linewise: true }, + }; + const result = handleVimAction(state, { + type: 'vim_paste_after' as const, + payload: { count: 1 }, + }); + expect(result).toHaveOnlyValidCharacters(); + expect(result.lines).toEqual(['hello', 'inserted', 'world']); + expect(result.cursorRow).toBe(1); + expect(result.cursorCol).toBe(0); + }); + + it('should do nothing when register is empty', () => { + const state = createTestState(['hello'], 0, 0); + const result = handleVimAction(state, { + type: 'vim_paste_after' as const, + payload: { count: 1 }, + }); + expect(result.lines).toEqual(['hello']); + expect(result.cursorCol).toBe(0); + }); + + it('should paste charwise text count times', () => { + const state = { + ...createTestState(['abc'], 0, 1), + yankRegister: { text: 'X', linewise: false }, + }; + const result = handleVimAction(state, { + type: 'vim_paste_after' as const, + payload: { count: 2 }, + }); + expect(result).toHaveOnlyValidCharacters(); + expect(result.lines[0]).toBe('abXXc'); + }); + + it('should paste linewise count times', () => { + const state = { + ...createTestState(['hello', 'world'], 0, 0), + yankRegister: { text: 'foo', linewise: true }, + }; + const result = handleVimAction(state, { + type: 'vim_paste_after' as const, + payload: { count: 2 }, + }); + expect(result).toHaveOnlyValidCharacters(); + expect(result.lines).toEqual(['hello', 'foo', 'foo', 'world']); + expect(result.cursorRow).toBe(1); + }); + + it('should land cursor on last char when pasting multiline charwise text', () => { + // Simulates yanking across a line boundary and pasting charwise. + // Cursor must land on the last pasted char, not a large out-of-bounds column. + const state = { + ...createTestState(['ab', 'cd'], 0, 1), + yankRegister: { text: 'b\nc', linewise: false }, + }; + const result = handleVimAction(state, { + type: 'vim_paste_after' as const, + payload: { count: 1 }, + }); + expect(result).toHaveOnlyValidCharacters(); + expect(result.cursorRow).toBe(1); + expect(result.cursorCol).toBe(0); + }); + + it('should land cursor correctly for count > 1 multiline charwise paste', () => { + const state = { + ...createTestState(['ab', 'cd'], 0, 0), + yankRegister: { text: 'x\ny', linewise: false }, + }; + const result = handleVimAction(state, { + type: 'vim_paste_after' as const, + payload: { count: 2 }, + }); + expect(result).toHaveOnlyValidCharacters(); + // cursor should be on the last char of the last pasted copy, not off-screen + expect(result.cursorCol).toBeLessThanOrEqual( + result.lines[result.cursorRow].length - 1, + ); + }); + }); + + describe('vim_paste_before (P)', () => { + it('should paste charwise text before cursor and land on last pasted char', () => { + const state = { + ...createTestState(['abc'], 0, 2), + yankRegister: { text: 'XY', linewise: false }, + }; + const result = handleVimAction(state, { + type: 'vim_paste_before' as const, + payload: { count: 1 }, + }); + expect(result).toHaveOnlyValidCharacters(); + expect(result.lines[0]).toBe('abXYc'); + expect(result.cursorCol).toBe(3); + }); + + it('should land cursor on last char when pasting multiline charwise text', () => { + const state = { + ...createTestState(['ab', 'cd'], 0, 1), + yankRegister: { text: 'b\nc', linewise: false }, + }; + const result = handleVimAction(state, { + type: 'vim_paste_before' as const, + payload: { count: 1 }, + }); + expect(result).toHaveOnlyValidCharacters(); + expect(result.cursorCol).toBeLessThanOrEqual( + result.lines[result.cursorRow].length - 1, + ); + }); + + it('should paste linewise above current row', () => { + const state = { + ...createTestState(['hello', 'world'], 1, 0), + yankRegister: { text: 'inserted', linewise: true }, + }; + const result = handleVimAction(state, { + type: 'vim_paste_before' as const, + payload: { count: 1 }, + }); + expect(result).toHaveOnlyValidCharacters(); + expect(result.lines).toEqual(['hello', 'inserted', 'world']); + 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 c4a65d0d67..f1f8dfb216 100644 --- a/packages/cli/src/ui/components/shared/vim-buffer-actions.ts +++ b/packages/cli/src/ui/components/shared/vim-buffer-actions.ts @@ -78,6 +78,14 @@ export type VimAction = Extract< | { type: 'vim_move_to_last_line' } | { type: 'vim_move_to_line' } | { type: 'vim_escape_insert_mode' } + | { type: 'vim_yank_line' } + | { type: 'vim_yank_word_forward' } + | { type: 'vim_yank_big_word_forward' } + | { type: 'vim_yank_word_end' } + | { type: 'vim_yank_big_word_end' } + | { type: 'vim_yank_to_end_of_line' } + | { type: 'vim_paste_after' } + | { type: 'vim_paste_before' } >; /** @@ -123,6 +131,36 @@ function clampNormalCursor(state: TextBufferState): TextBufferState { return { ...state, cursorCol: maxCol }; } +/** Extract the text that will be removed by a delete/yank operation. */ +function extractRange( + lines: string[], + startRow: number, + startCol: number, + endRow: number, + endCol: number, +): string { + if (startRow === endRow) { + return toCodePoints(lines[startRow] || '') + .slice(startCol, endCol) + .join(''); + } + const parts: string[] = []; + parts.push( + toCodePoints(lines[startRow] || '') + .slice(startCol) + .join(''), + ); + for (let r = startRow + 1; r < endRow; r++) { + parts.push(lines[r] || ''); + } + parts.push( + toCodePoints(lines[endRow] || '') + .slice(0, endCol) + .join(''), + ); + return parts.join('\n'); +} + export function handleVimAction( state: TextBufferState, action: VimAction, @@ -156,6 +194,13 @@ export function handleVimAction( } if (endRow !== cursorRow || endCol !== cursorCol) { + const yankedText = extractRange( + lines, + cursorRow, + cursorCol, + endRow, + endCol, + ); const nextState = detachExpandedPaste(pushUndo(state)); const newState = replaceRangeInternal( nextState, @@ -165,9 +210,13 @@ export function handleVimAction( endCol, '', ); - return action.type === 'vim_delete_word_forward' - ? clampNormalCursor(newState) - : newState; + if (action.type === 'vim_delete_word_forward') { + return { + ...clampNormalCursor(newState), + yankRegister: { text: yankedText, linewise: false }, + }; + } + return newState; } return state; } @@ -201,6 +250,13 @@ export function handleVimAction( } if (endRow !== cursorRow || endCol !== cursorCol) { + const yankedText = extractRange( + lines, + cursorRow, + cursorCol, + endRow, + endCol, + ); const nextState = pushUndo(state); const newState = replaceRangeInternal( nextState, @@ -210,9 +266,13 @@ export function handleVimAction( endCol, '', ); - return action.type === 'vim_delete_big_word_forward' - ? clampNormalCursor(newState) - : newState; + if (action.type === 'vim_delete_big_word_forward') { + return { + ...clampNormalCursor(newState), + yankRegister: { text: yankedText, linewise: false }, + }; + } + return newState; } return state; } @@ -317,6 +377,13 @@ export function handleVimAction( } if (endRow !== cursorRow || endCol !== cursorCol) { + const yankedText = extractRange( + lines, + cursorRow, + cursorCol, + endRow, + endCol, + ); const nextState = detachExpandedPaste(pushUndo(state)); const newState = replaceRangeInternal( nextState, @@ -326,9 +393,13 @@ export function handleVimAction( endCol, '', ); - return action.type === 'vim_delete_word_end' - ? clampNormalCursor(newState) - : newState; + if (action.type === 'vim_delete_word_end') { + return { + ...clampNormalCursor(newState), + yankRegister: { text: yankedText, linewise: false }, + }; + } + return newState; } return state; } @@ -373,6 +444,13 @@ export function handleVimAction( } if (endRow !== cursorRow || endCol !== cursorCol) { + const yankedText = extractRange( + lines, + cursorRow, + cursorCol, + endRow, + endCol, + ); const nextState = pushUndo(state); const newState = replaceRangeInternal( nextState, @@ -382,9 +460,13 @@ export function handleVimAction( endCol, '', ); - return action.type === 'vim_delete_big_word_end' - ? clampNormalCursor(newState) - : newState; + if (action.type === 'vim_delete_big_word_end') { + return { + ...clampNormalCursor(newState), + yankRegister: { text: yankedText, linewise: false }, + }; + } + return newState; } return state; } @@ -395,6 +477,9 @@ export function handleVimAction( const linesToDelete = Math.min(count, lines.length - cursorRow); const totalLines = lines.length; + const yankedText = lines + .slice(cursorRow, cursorRow + linesToDelete) + .join('\n'); if (totalLines === 1 || linesToDelete >= totalLines) { // If there's only one line, or we're deleting all remaining lines, @@ -406,6 +491,7 @@ export function handleVimAction( cursorRow: 0, cursorCol: 0, preferredCol: null, + yankRegister: { text: yankedText, linewise: true }, }; } @@ -423,6 +509,7 @@ export function handleVimAction( cursorRow: newCursorRow, cursorCol: newCursorCol, preferredCol: null, + yankRegister: { text: yankedText, linewise: true }, }; } @@ -463,6 +550,13 @@ export function handleVimAction( if (count === 1) { // Single line: delete from cursor to end of current line if (cursorCol < cpLen(currentLine)) { + const yankedText = extractRange( + lines, + cursorRow, + cursorCol, + cursorRow, + cpLen(currentLine), + ); const nextState = detachExpandedPaste(pushUndo(state)); const newState = replaceRangeInternal( nextState, @@ -472,7 +566,13 @@ export function handleVimAction( cpLen(currentLine), '', ); - return isDelete ? clampNormalCursor(newState) : newState; + if (isDelete) { + return { + ...clampNormalCursor(newState), + yankRegister: { text: yankedText, linewise: false }, + }; + } + return newState; } return state; } else { @@ -484,6 +584,13 @@ export function handleVimAction( if (endRow === cursorRow) { // No additional lines to delete, just delete to EOL if (cursorCol < cpLen(currentLine)) { + const yankedText = extractRange( + lines, + cursorRow, + cursorCol, + cursorRow, + cpLen(currentLine), + ); const nextState = detachExpandedPaste(pushUndo(state)); const newState = replaceRangeInternal( nextState, @@ -493,14 +600,27 @@ export function handleVimAction( cpLen(currentLine), '', ); - return isDelete ? clampNormalCursor(newState) : newState; + if (isDelete) { + return { + ...clampNormalCursor(newState), + yankRegister: { text: yankedText, linewise: false }, + }; + } + return newState; } return state; } // Delete from cursor position to end of endRow (including newlines) - const nextState = detachExpandedPaste(pushUndo(state)); const endLine = lines[endRow] || ''; + const yankedText = extractRange( + lines, + cursorRow, + cursorCol, + endRow, + cpLen(endLine), + ); + const nextState = detachExpandedPaste(pushUndo(state)); const newState = replaceRangeInternal( nextState, cursorRow, @@ -509,7 +629,13 @@ export function handleVimAction( cpLen(endLine), '', ); - return isDelete ? clampNormalCursor(newState) : newState; + if (isDelete) { + return { + ...clampNormalCursor(newState), + yankRegister: { text: yankedText, linewise: false }, + }; + } + return newState; } } @@ -1064,6 +1190,9 @@ export function handleVimAction( if (cursorCol < lineLength) { const deleteCount = Math.min(count, lineLength - cursorCol); + const deletedText = toCodePoints(currentLine) + .slice(cursorCol, cursorCol + deleteCount) + .join(''); const nextState = detachExpandedPaste(pushUndo(state)); const newState = replaceRangeInternal( nextState, @@ -1073,7 +1202,10 @@ export function handleVimAction( cursorCol + deleteCount, '', ); - return clampNormalCursor(newState); + return { + ...clampNormalCursor(newState), + yankRegister: { text: deletedText, linewise: false }, + }; } return state; } @@ -1254,8 +1386,11 @@ export function handleVimAction( const { count } = action.payload; if (cursorCol > 0) { const deleteStart = Math.max(0, cursorCol - count); + const deletedText = toCodePoints(lines[cursorRow] || '') + .slice(deleteStart, cursorCol) + .join(''); const nextState = detachExpandedPaste(pushUndo(state)); - return replaceRangeInternal( + const newState = replaceRangeInternal( nextState, cursorRow, deleteStart, @@ -1263,6 +1398,10 @@ export function handleVimAction( cursorCol, '', ); + return { + ...newState, + yankRegister: { text: deletedText, linewise: false }, + }; } return state; } @@ -1328,17 +1467,21 @@ export function handleVimAction( ); if (found === -1) return state; const endCol = till ? found : found + 1; + const yankedText = lineCodePoints.slice(cursorCol, endCol).join(''); const nextState = detachExpandedPaste(pushUndo(state)); - return clampNormalCursor( - replaceRangeInternal( - nextState, - cursorRow, - cursorCol, - cursorRow, - endCol, - '', + return { + ...clampNormalCursor( + replaceRangeInternal( + nextState, + cursorRow, + cursorCol, + cursorRow, + endCol, + '', + ), ), - ); + yankRegister: { text: yankedText, linewise: false }, + }; } case 'vim_delete_to_char_backward': { @@ -1355,6 +1498,7 @@ export function handleVimAction( const startCol = till ? found + 1 : found; const endCol = cursorCol + 1; // inclusive: cursor char is part of the deletion if (startCol >= endCol) return state; + const yankedText = lineCodePoints.slice(startCol, endCol).join(''); const nextState = detachExpandedPaste(pushUndo(state)); const resultState = replaceRangeInternal( nextState, @@ -1364,11 +1508,14 @@ export function handleVimAction( endCol, '', ); - return clampNormalCursor({ - ...resultState, - cursorCol: startCol, - preferredCol: null, - }); + return { + ...clampNormalCursor({ + ...resultState, + cursorCol: startCol, + preferredCol: null, + }), + yankRegister: { text: yankedText, linewise: false }, + }; } case 'vim_find_char_forward': { @@ -1401,6 +1548,298 @@ export function handleVimAction( return { ...state, cursorCol: newCol, preferredCol: null }; } + case 'vim_yank_line': { + const { count } = action.payload; + const linesToYank = Math.min(count, lines.length - cursorRow); + const text = lines.slice(cursorRow, cursorRow + linesToYank).join('\n'); + return { ...state, yankRegister: { text, linewise: true } }; + } + + case 'vim_yank_word_forward': { + const { count } = action.payload; + let endRow = cursorRow; + let endCol = cursorCol; + + for (let i = 0; i < count; i++) { + const nextWord = findNextWordAcrossLines(lines, endRow, endCol, true); + if (nextWord) { + endRow = nextWord.row; + endCol = nextWord.col; + } else { + const currentLine = lines[endRow] || ''; + const wordEnd = findWordEndInLine(currentLine, endCol); + if (wordEnd !== null) { + endCol = wordEnd + 1; + } + break; + } + } + + if (endRow !== cursorRow || endCol !== cursorCol) { + const yankedText = extractRange( + lines, + cursorRow, + cursorCol, + endRow, + endCol, + ); + return { + ...state, + yankRegister: { text: yankedText, linewise: false }, + }; + } + return state; + } + + case 'vim_yank_big_word_forward': { + const { count } = action.payload; + let endRow = cursorRow; + let endCol = cursorCol; + + for (let i = 0; i < count; i++) { + const nextWord = findNextBigWordAcrossLines( + lines, + endRow, + endCol, + true, + ); + if (nextWord) { + endRow = nextWord.row; + endCol = nextWord.col; + } else { + const currentLine = lines[endRow] || ''; + const wordEnd = findBigWordEndInLine(currentLine, endCol); + if (wordEnd !== null) { + endCol = wordEnd + 1; + } + break; + } + } + + if (endRow !== cursorRow || endCol !== cursorCol) { + const yankedText = extractRange( + lines, + cursorRow, + cursorCol, + endRow, + endCol, + ); + return { + ...state, + yankRegister: { text: yankedText, linewise: false }, + }; + } + return state; + } + + case 'vim_yank_word_end': { + const { count } = action.payload; + let row = cursorRow; + let col = cursorCol; + let endRow = cursorRow; + let endCol = cursorCol; + + for (let i = 0; i < count; i++) { + const wordEnd = findNextWordAcrossLines(lines, row, col, false); + if (wordEnd) { + endRow = wordEnd.row; + endCol = wordEnd.col + 1; + if (i < count - 1) { + const nextWord = findNextWordAcrossLines( + lines, + wordEnd.row, + wordEnd.col + 1, + true, + ); + if (nextWord) { + row = nextWord.row; + col = nextWord.col; + } else { + break; + } + } + } else { + break; + } + } + + if (endRow < lines.length) { + endCol = Math.min(endCol, cpLen(lines[endRow] || '')); + } + + if (endRow !== cursorRow || endCol !== cursorCol) { + const yankedText = extractRange( + lines, + cursorRow, + cursorCol, + endRow, + endCol, + ); + return { + ...state, + yankRegister: { text: yankedText, linewise: false }, + }; + } + return state; + } + + case 'vim_yank_big_word_end': { + const { count } = action.payload; + let row = cursorRow; + let col = cursorCol; + let endRow = cursorRow; + let endCol = cursorCol; + + for (let i = 0; i < count; i++) { + const wordEnd = findNextBigWordAcrossLines(lines, row, col, false); + if (wordEnd) { + endRow = wordEnd.row; + endCol = wordEnd.col + 1; + if (i < count - 1) { + const nextWord = findNextBigWordAcrossLines( + lines, + wordEnd.row, + wordEnd.col + 1, + true, + ); + if (nextWord) { + row = nextWord.row; + col = nextWord.col; + } else { + break; + } + } + } else { + break; + } + } + + if (endRow < lines.length) { + endCol = Math.min(endCol, cpLen(lines[endRow] || '')); + } + + if (endRow !== cursorRow || endCol !== cursorCol) { + const yankedText = extractRange( + lines, + cursorRow, + cursorCol, + endRow, + endCol, + ); + return { + ...state, + yankRegister: { text: yankedText, linewise: false }, + }; + } + return state; + } + + case 'vim_yank_to_end_of_line': { + const currentLine = lines[cursorRow] || ''; + const lineLen = cpLen(currentLine); + if (cursorCol < lineLen) { + const yankedText = toCodePoints(currentLine).slice(cursorCol).join(''); + return { + ...state, + yankRegister: { text: yankedText, linewise: false }, + }; + } + return state; + } + + case 'vim_paste_after': { + const { count } = action.payload; + const reg = state.yankRegister; + if (!reg) return state; + + const nextState = detachExpandedPaste(pushUndo(state)); + + if (reg.linewise) { + // Insert lines BELOW cursorRow + const pasteText = (reg.text + '\n').repeat(count).slice(0, -1); // N copies, no trailing newline + const pasteLines = pasteText.split('\n'); + const newLines = [...nextState.lines]; + newLines.splice(cursorRow + 1, 0, ...pasteLines); + return { + ...nextState, + lines: newLines, + cursorRow: cursorRow + 1, + cursorCol: 0, + preferredCol: null, + }; + } else { + // Insert after cursor (at cursorCol + 1) + const currentLine = nextState.lines[cursorRow] || ''; + const lineLen = cpLen(currentLine); + const insertCol = Math.min(cursorCol + 1, lineLen); + const pasteText = reg.text.repeat(count); + const newState = replaceRangeInternal( + nextState, + cursorRow, + insertCol, + cursorRow, + insertCol, + pasteText, + ); + // replaceRangeInternal leaves cursorCol one past the last inserted char; + // step back by 1 to land on the last pasted character. + const pasteLength = pasteText.length; + return clampNormalCursor({ + ...newState, + cursorCol: Math.max( + 0, + newState.cursorCol - (pasteLength > 0 ? 1 : 0), + ), + preferredCol: null, + }); + } + } + + case 'vim_paste_before': { + const { count } = action.payload; + const reg = state.yankRegister; + if (!reg) return state; + + const nextState = detachExpandedPaste(pushUndo(state)); + + if (reg.linewise) { + // Insert lines ABOVE cursorRow + const pasteText = (reg.text + '\n').repeat(count).slice(0, -1); + const pasteLines = pasteText.split('\n'); + const newLines = [...nextState.lines]; + newLines.splice(cursorRow, 0, ...pasteLines); + return { + ...nextState, + lines: newLines, + cursorRow, + cursorCol: 0, + preferredCol: null, + }; + } else { + // Insert at cursorCol (not +1) + const pasteText = reg.text.repeat(count); + const newState = replaceRangeInternal( + nextState, + cursorRow, + cursorCol, + cursorRow, + cursorCol, + pasteText, + ); + // replaceRangeInternal leaves cursorCol one past the last inserted char; + // step back by 1 to land on the last pasted character. + const pasteLength = pasteText.length; + return clampNormalCursor({ + ...newState, + cursorCol: Math.max( + 0, + newState.cursorCol - (pasteLength > 0 ? 1 : 0), + ), + preferredCol: null, + }); + } + } + default: { // This should never happen if TypeScript is working correctly assumeExhaustive(action); diff --git a/packages/cli/src/ui/hooks/vim.test.tsx b/packages/cli/src/ui/hooks/vim.test.tsx index 8842c83162..774ae7e9df 100644 --- a/packages/cli/src/ui/hooks/vim.test.tsx +++ b/packages/cli/src/ui/hooks/vim.test.tsx @@ -77,6 +77,7 @@ const createMockTextBufferState = ( }, pastedContent: {}, expandedPaste: null, + yankRegister: null, ...partial, }; }; @@ -206,6 +207,14 @@ describe('useVim hook', () => { cursorState.pos = [row, col - 1]; } }), + vimYankLine: vi.fn(), + vimYankWordForward: vi.fn(), + vimYankBigWordForward: vi.fn(), + vimYankWordEnd: vi.fn(), + vimYankBigWordEnd: vi.fn(), + vimYankToEndOfLine: vi.fn(), + vimPasteAfter: vi.fn(), + vimPasteBefore: vi.fn(), // Additional properties for transformations transformedToLogicalMaps: lines.map(() => []), visualToTransformedMap: [], @@ -2387,4 +2396,205 @@ describe('useVim hook', () => { ); }); }); + + describe('Yank and paste (y/p/P)', () => { + it('should handle yy (yank line)', () => { + const { result } = renderVimHook(); + exitInsertMode(result); + act(() => { + result.current.handleInput(createKey({ sequence: 'y' })); + }); + act(() => { + result.current.handleInput(createKey({ sequence: 'y' })); + }); + expect(mockBuffer.vimYankLine).toHaveBeenCalledWith(1); + }); + + it('should handle 2yy (yank 2 lines)', () => { + const { result } = renderVimHook(); + exitInsertMode(result); + act(() => { + result.current.handleInput(createKey({ sequence: '2' })); + }); + act(() => { + result.current.handleInput(createKey({ sequence: 'y' })); + }); + act(() => { + result.current.handleInput(createKey({ sequence: 'y' })); + }); + expect(mockBuffer.vimYankLine).toHaveBeenCalledWith(2); + }); + + it('should handle Y (yank to end of line, equivalent to y$)', () => { + const { result } = renderVimHook(); + exitInsertMode(result); + act(() => { + result.current.handleInput(createKey({ sequence: 'Y' })); + }); + expect(mockBuffer.vimYankToEndOfLine).toHaveBeenCalledWith(1); + }); + + it('should handle yw (yank word forward)', () => { + const { result } = renderVimHook(); + exitInsertMode(result); + act(() => { + result.current.handleInput(createKey({ sequence: 'y' })); + }); + act(() => { + result.current.handleInput(createKey({ sequence: 'w' })); + }); + expect(mockBuffer.vimYankWordForward).toHaveBeenCalledWith(1); + }); + + it('should handle yW (yank big word forward)', () => { + const { result } = renderVimHook(); + exitInsertMode(result); + act(() => { + result.current.handleInput(createKey({ sequence: 'y' })); + }); + act(() => { + result.current.handleInput(createKey({ sequence: 'W' })); + }); + expect(mockBuffer.vimYankBigWordForward).toHaveBeenCalledWith(1); + }); + + it('should handle ye (yank to end of word)', () => { + const { result } = renderVimHook(); + exitInsertMode(result); + act(() => { + result.current.handleInput(createKey({ sequence: 'y' })); + }); + act(() => { + result.current.handleInput(createKey({ sequence: 'e' })); + }); + expect(mockBuffer.vimYankWordEnd).toHaveBeenCalledWith(1); + }); + + it('should handle yE (yank to end of big word)', () => { + const { result } = renderVimHook(); + exitInsertMode(result); + act(() => { + result.current.handleInput(createKey({ sequence: 'y' })); + }); + act(() => { + result.current.handleInput(createKey({ sequence: 'E' })); + }); + expect(mockBuffer.vimYankBigWordEnd).toHaveBeenCalledWith(1); + }); + + it('should handle y$ (yank to end of line)', () => { + const { result } = renderVimHook(); + exitInsertMode(result); + act(() => { + result.current.handleInput(createKey({ sequence: 'y' })); + }); + act(() => { + result.current.handleInput(createKey({ sequence: '$' })); + }); + expect(mockBuffer.vimYankToEndOfLine).toHaveBeenCalledWith(1); + }); + + it('should handle p (paste after)', () => { + const { result } = renderVimHook(); + exitInsertMode(result); + act(() => { + result.current.handleInput(createKey({ sequence: 'p' })); + }); + expect(mockBuffer.vimPasteAfter).toHaveBeenCalledWith(1); + }); + + it('should handle 2p (paste after, count 2)', () => { + const { result } = renderVimHook(); + exitInsertMode(result); + act(() => { + result.current.handleInput(createKey({ sequence: '2' })); + }); + act(() => { + result.current.handleInput(createKey({ sequence: 'p' })); + }); + expect(mockBuffer.vimPasteAfter).toHaveBeenCalledWith(2); + }); + + it('should handle P (paste before)', () => { + const { result } = renderVimHook(); + exitInsertMode(result); + act(() => { + result.current.handleInput(createKey({ sequence: 'P' })); + }); + expect(mockBuffer.vimPasteBefore).toHaveBeenCalledWith(1); + }); + + // Integration tests using actual textBufferReducer to verify full state changes + it('should duplicate a line below with yy then p', () => { + const initialState = createMockTextBufferState({ + lines: ['hello', 'world'], + cursorRow: 0, + cursorCol: 0, + }); + // Simulate yy action + let state = textBufferReducer(initialState, { + type: 'vim_yank_line', + payload: { count: 1 }, + }); + expect(state.yankRegister).toEqual({ text: 'hello', linewise: true }); + expect(state.lines).toEqual(['hello', 'world']); // unchanged + + // Simulate p action + state = textBufferReducer(state, { + type: 'vim_paste_after', + payload: { count: 1 }, + }); + expect(state.lines).toEqual(['hello', 'hello', 'world']); + expect(state.cursorRow).toBe(1); + expect(state.cursorCol).toBe(0); + }); + + it('should paste a yanked word after cursor with yw then p', () => { + const initialState = createMockTextBufferState({ + lines: ['hello world'], + cursorRow: 0, + cursorCol: 0, + }); + // Simulate yw action + let state = textBufferReducer(initialState, { + type: 'vim_yank_word_forward', + payload: { count: 1 }, + }); + expect(state.yankRegister).toEqual({ text: 'hello ', linewise: false }); + expect(state.lines).toEqual(['hello world']); // unchanged + + // Move cursor to col 6 (start of 'world') and paste + state = { ...state, cursorCol: 6 }; + state = textBufferReducer(state, { + type: 'vim_paste_after', + payload: { count: 1 }, + }); + // 'hello world' with paste after col 6 (between 'w' and 'o') + // insert 'hello ' at col 7, result: 'hello whello orld' + expect(state.lines[0]).toContain('hello '); + }); + + it('should move a word forward with dw then p', () => { + const initialState = createMockTextBufferState({ + lines: ['hello world'], + cursorRow: 0, + cursorCol: 0, + }); + // Simulate dw (delete word, populates register) + let state = textBufferReducer(initialState, { + type: 'vim_delete_word_forward', + payload: { count: 1 }, + }); + expect(state.yankRegister).toEqual({ text: 'hello ', linewise: false }); + expect(state.lines[0]).toBe('world'); + + // Paste at end of 'world' (after last char) + state = { ...state, cursorCol: 4 }; + state = textBufferReducer(state, { + type: 'vim_paste_after', + payload: { count: 1 }, + }); + expect(state.lines[0]).toContain('hello'); + }); + }); }); diff --git a/packages/cli/src/ui/hooks/vim.ts b/packages/cli/src/ui/hooks/vim.ts index a736c27eed..d1780c3c98 100644 --- a/packages/cli/src/ui/hooks/vim.ts +++ b/packages/cli/src/ui/hooks/vim.ts @@ -63,6 +63,14 @@ const CMD_TYPES = { DELETE_TO_LAST_LINE: 'dG', CHANGE_TO_FIRST_LINE: 'cgg', CHANGE_TO_LAST_LINE: 'cG', + YANK_LINE: 'yy', + YANK_WORD_FORWARD: 'yw', + YANK_BIG_WORD_FORWARD: 'yW', + YANK_WORD_END: 'ye', + YANK_BIG_WORD_END: 'yE', + YANK_TO_EOL: 'y$', + PASTE_AFTER: 'p', + PASTE_BEFORE: 'P', } as const; type PendingFindOp = { @@ -80,7 +88,7 @@ const createClearPendingState = () => ({ type VimState = { mode: VimMode; count: number; - pendingOperator: 'g' | 'd' | 'c' | 'dg' | 'cg' | null; + pendingOperator: 'g' | 'd' | 'c' | 'y' | 'dg' | 'cg' | null; pendingFindOp: PendingFindOp | undefined; lastCommand: { type: string; count: number; char?: string } | null; lastFind: { op: 'f' | 'F' | 't' | 'T'; char: string } | undefined; @@ -93,7 +101,7 @@ type VimAction = | { type: 'CLEAR_COUNT' } | { type: 'SET_PENDING_OPERATOR'; - operator: 'g' | 'd' | 'c' | 'dg' | 'cg' | null; + operator: 'g' | 'd' | 'c' | 'y' | 'dg' | 'cg' | null; } | { type: 'SET_PENDING_FIND_OP'; pendingFindOp: PendingFindOp | undefined } | { @@ -408,6 +416,46 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { break; } + case CMD_TYPES.YANK_LINE: { + buffer.vimYankLine(count); + break; + } + + case CMD_TYPES.YANK_WORD_FORWARD: { + buffer.vimYankWordForward(count); + break; + } + + case CMD_TYPES.YANK_BIG_WORD_FORWARD: { + buffer.vimYankBigWordForward(count); + break; + } + + case CMD_TYPES.YANK_WORD_END: { + buffer.vimYankWordEnd(count); + break; + } + + case CMD_TYPES.YANK_BIG_WORD_END: { + buffer.vimYankBigWordEnd(count); + break; + } + + case CMD_TYPES.YANK_TO_EOL: { + buffer.vimYankToEndOfLine(count); + break; + } + + case CMD_TYPES.PASTE_AFTER: { + buffer.vimPasteAfter(count); + break; + } + + case CMD_TYPES.PASTE_BEFORE: { + buffer.vimPasteBefore(count); + break; + } + default: return false; } @@ -776,6 +824,17 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { if (state.pendingOperator === 'c') { return handleOperatorMotion('c', 'w'); } + if (state.pendingOperator === 'y') { + const count = getCurrentCount(); + executeCommand(CMD_TYPES.YANK_WORD_FORWARD, count); + dispatch({ + type: 'SET_LAST_COMMAND', + command: { type: CMD_TYPES.YANK_WORD_FORWARD, count }, + }); + dispatch({ type: 'CLEAR_COUNT' }); + dispatch({ type: 'SET_PENDING_OPERATOR', operator: null }); + return true; + } // Normal word movement buffer.vimMoveWordForward(repeatCount); @@ -791,6 +850,17 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { if (state.pendingOperator === 'c') { return handleOperatorMotion('c', 'W'); } + if (state.pendingOperator === 'y') { + const count = getCurrentCount(); + executeCommand(CMD_TYPES.YANK_BIG_WORD_FORWARD, count); + dispatch({ + type: 'SET_LAST_COMMAND', + command: { type: CMD_TYPES.YANK_BIG_WORD_FORWARD, count }, + }); + dispatch({ type: 'CLEAR_COUNT' }); + dispatch({ type: 'SET_PENDING_OPERATOR', operator: null }); + return true; + } // Normal big word movement buffer.vimMoveBigWordForward(repeatCount); @@ -836,6 +906,17 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { if (state.pendingOperator === 'c') { return handleOperatorMotion('c', 'e'); } + if (state.pendingOperator === 'y') { + const count = getCurrentCount(); + executeCommand(CMD_TYPES.YANK_WORD_END, count); + dispatch({ + type: 'SET_LAST_COMMAND', + command: { type: CMD_TYPES.YANK_WORD_END, count }, + }); + dispatch({ type: 'CLEAR_COUNT' }); + dispatch({ type: 'SET_PENDING_OPERATOR', operator: null }); + return true; + } // Normal word end movement buffer.vimMoveWordEnd(repeatCount); @@ -851,6 +932,17 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { if (state.pendingOperator === 'c') { return handleOperatorMotion('c', 'E'); } + if (state.pendingOperator === 'y') { + const count = getCurrentCount(); + executeCommand(CMD_TYPES.YANK_BIG_WORD_END, count); + dispatch({ + type: 'SET_LAST_COMMAND', + command: { type: CMD_TYPES.YANK_BIG_WORD_END, count }, + }); + dispatch({ type: 'CLEAR_COUNT' }); + dispatch({ type: 'SET_PENDING_OPERATOR', operator: null }); + return true; + } // Normal big word end movement buffer.vimMoveBigWordEnd(repeatCount); @@ -1027,6 +1119,17 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { updateMode('INSERT'); return true; } + // Check if this is part of a yank command (y$) + if (state.pendingOperator === 'y') { + executeCommand(CMD_TYPES.YANK_TO_EOL, repeatCount); + dispatch({ + type: 'SET_LAST_COMMAND', + command: { type: CMD_TYPES.YANK_TO_EOL, count: repeatCount }, + }); + dispatch({ type: 'CLEAR_COUNT' }); + dispatch({ type: 'SET_PENDING_OPERATOR', operator: null }); + return true; + } // Move to end of line (with count, move down count-1 lines first) if (repeatCount > 1) { @@ -1220,6 +1323,59 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { return true; } + case 'y': { + if (state.pendingOperator === 'y') { + // Second 'y' - yank N lines (yy command) + const repeatCount = getCurrentCount(); + executeCommand(CMD_TYPES.YANK_LINE, repeatCount); + dispatch({ + type: 'SET_LAST_COMMAND', + command: { type: CMD_TYPES.YANK_LINE, count: repeatCount }, + }); + dispatch({ type: 'CLEAR_COUNT' }); + dispatch({ type: 'SET_PENDING_OPERATOR', operator: null }); + } else if (state.pendingOperator === null) { + // First 'y' - wait for motion + dispatch({ type: 'SET_PENDING_OPERATOR', operator: 'y' }); + } else { + // Another operator is pending; clear it + dispatch({ type: 'CLEAR_PENDING_STATES' }); + } + return true; + } + + case 'Y': { + // Y yanks from cursor to end of line (equivalent to y$) + const repeatCount = getCurrentCount(); + executeCommand(CMD_TYPES.YANK_TO_EOL, repeatCount); + dispatch({ + type: 'SET_LAST_COMMAND', + command: { type: CMD_TYPES.YANK_TO_EOL, count: repeatCount }, + }); + dispatch({ type: 'CLEAR_COUNT' }); + return true; + } + + case 'p': { + executeCommand(CMD_TYPES.PASTE_AFTER, repeatCount); + dispatch({ + type: 'SET_LAST_COMMAND', + command: { type: CMD_TYPES.PASTE_AFTER, count: repeatCount }, + }); + dispatch({ type: 'CLEAR_COUNT' }); + return true; + } + + case 'P': { + executeCommand(CMD_TYPES.PASTE_BEFORE, repeatCount); + dispatch({ + type: 'SET_LAST_COMMAND', + command: { type: CMD_TYPES.PASTE_BEFORE, count: repeatCount }, + }); + dispatch({ type: 'CLEAR_COUNT' }); + return true; + } + case 'D': { // Delete from cursor to end of line (with count, delete to end of N lines) executeCommand(CMD_TYPES.DELETE_TO_EOL, repeatCount);