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 93bed18c52..00ecb83c99 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.test.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.test.ts @@ -27,6 +27,9 @@ import { textBufferReducer, findWordEndInLine, findNextWordStartInLine, + findNextBigWordStartInLine, + findPrevBigWordStartInLine, + findBigWordEndInLine, isWordCharStrict, calculateTransformationsForLine, calculateTransformedLine, @@ -87,6 +90,43 @@ describe('textBufferReducer', () => { expect(state).toEqual(initialState); }); + describe('Big Word Navigation Helpers', () => { + describe('findNextBigWordStartInLine (W)', () => { + it('should skip non-whitespace and then whitespace', () => { + expect(findNextBigWordStartInLine('hello world', 0)).toBe(6); + expect(findNextBigWordStartInLine('hello.world test', 0)).toBe(12); + expect(findNextBigWordStartInLine(' test', 0)).toBe(3); + expect(findNextBigWordStartInLine('test ', 0)).toBe(null); + }); + }); + + describe('findPrevBigWordStartInLine (B)', () => { + it('should skip whitespace backwards then non-whitespace', () => { + expect(findPrevBigWordStartInLine('hello world', 6)).toBe(0); + expect(findPrevBigWordStartInLine('hello.world test', 12)).toBe(0); + expect(findPrevBigWordStartInLine(' test', 3)).toBe(null); // At start of word + expect(findPrevBigWordStartInLine(' test', 4)).toBe(3); // Inside word + expect(findPrevBigWordStartInLine('test ', 6)).toBe(0); + }); + }); + + describe('findBigWordEndInLine (E)', () => { + it('should find end of current big word', () => { + expect(findBigWordEndInLine('hello world', 0)).toBe(4); + expect(findBigWordEndInLine('hello.world test', 0)).toBe(10); + expect(findBigWordEndInLine('hello.world test', 11)).toBe(15); + }); + + it('should skip whitespace if currently on whitespace', () => { + expect(findBigWordEndInLine('hello world', 5)).toBe(12); + }); + + it('should find next big word end if at end of current', () => { + expect(findBigWordEndInLine('hello world', 4)).toBe(10); + }); + }); + }); + describe('set_text action', () => { it('should set new text and move cursor to the end', () => { const action: TextBufferAction = { diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index 4d0956298c..1264f7eae9 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -174,15 +174,21 @@ export const findWordEndInLine = (line: string, col: number): number | null => { // If we're already at the end of a word (including punctuation sequences), advance to next word // This includes both regular word endings and script boundaries + let nextBaseCharIdx = i + 1; + while ( + nextBaseCharIdx < chars.length && + isCombiningMark(chars[nextBaseCharIdx]) + ) { + nextBaseCharIdx++; + } + const atEndOfWordChar = i < chars.length && isWordCharWithCombining(chars[i]) && - (i + 1 >= chars.length || - !isWordCharWithCombining(chars[i + 1]) || + (nextBaseCharIdx >= chars.length || + !isWordCharStrict(chars[nextBaseCharIdx]) || (isWordCharStrict(chars[i]) && - i + 1 < chars.length && - isWordCharStrict(chars[i + 1]) && - isDifferentScript(chars[i], chars[i + 1]))); + isDifferentScript(chars[i], chars[nextBaseCharIdx]))); const atEndOfPunctuation = i < chars.length && @@ -195,6 +201,10 @@ export const findWordEndInLine = (line: string, col: number): number | null => { if (atEndOfWordChar || atEndOfPunctuation) { // We're at the end of a word or punctuation sequence, move forward to find next word i++; + // Skip any combining marks that belong to the word we just finished + while (i < chars.length && isCombiningMark(chars[i])) { + i++; + } // Skip whitespace to find next word or punctuation while (i < chars.length && isWhitespace(chars[i])) { i++; @@ -260,6 +270,91 @@ export const findWordEndInLine = (line: string, col: number): number | null => { return null; }; +// Find next big word start within a line (W) +export const findNextBigWordStartInLine = ( + line: string, + col: number, +): number | null => { + const chars = toCodePoints(line); + let i = col; + + if (i >= chars.length) return null; + + // If currently on non-whitespace, skip it + if (!isWhitespace(chars[i])) { + while (i < chars.length && !isWhitespace(chars[i])) { + i++; + } + } + + // Skip whitespace + while (i < chars.length && isWhitespace(chars[i])) { + i++; + } + + return i < chars.length ? i : null; +}; + +// Find previous big word start within a line (B) +export const findPrevBigWordStartInLine = ( + line: string, + col: number, +): number | null => { + const chars = toCodePoints(line); + let i = col; + + if (i <= 0) return null; + + i--; + + // Skip whitespace moving backwards + while (i >= 0 && isWhitespace(chars[i])) { + i--; + } + + if (i < 0) return null; + + // We're in a big word, move to its beginning + while (i >= 0 && !isWhitespace(chars[i])) { + i--; + } + return i + 1; +}; + +// Find big word end within a line (E) +export const findBigWordEndInLine = ( + line: string, + col: number, +): number | null => { + const chars = toCodePoints(line); + let i = col; + + // If we're already at the end of a big word, advance to next + const atEndOfBigWord = + i < chars.length && + !isWhitespace(chars[i]) && + (i + 1 >= chars.length || isWhitespace(chars[i + 1])); + + if (atEndOfBigWord) { + i++; + } + + // Skip whitespace + while (i < chars.length && isWhitespace(chars[i])) { + i++; + } + + // Move to end of current big word + if (i < chars.length && !isWhitespace(chars[i])) { + while (i < chars.length && !isWhitespace(chars[i])) { + i++; + } + return i - 1; + } + + return null; +}; + // Initialize segmenter for word boundary detection const segmenter = new Intl.Segmenter(undefined, { granularity: 'word' }); @@ -322,34 +417,17 @@ export const findNextWordAcrossLines = ( return { row: cursorRow, col: colInCurrentLine }; } + let firstEmptyRow: number | null = null; + // Search subsequent lines for (let row = cursorRow + 1; row < lines.length; row++) { const line = lines[row] || ''; const chars = toCodePoints(line); - // For empty lines, if we haven't found any words yet, return the empty line + // For empty lines, if we haven't found any words yet, remember the first empty line if (chars.length === 0) { - // Check if there are any words in remaining lines - let hasWordsInLaterLines = false; - for (let laterRow = row + 1; laterRow < lines.length; laterRow++) { - const laterLine = lines[laterRow] || ''; - const laterChars = toCodePoints(laterLine); - let firstNonWhitespace = 0; - while ( - firstNonWhitespace < laterChars.length && - isWhitespace(laterChars[firstNonWhitespace]) - ) { - firstNonWhitespace++; - } - if (firstNonWhitespace < laterChars.length) { - hasWordsInLaterLines = true; - break; - } - } - - // If no words in later lines, return the empty line - if (!hasWordsInLaterLines) { - return { row, col: 0 }; + if (firstEmptyRow === null) { + firstEmptyRow = row; } continue; } @@ -376,6 +454,11 @@ export const findNextWordAcrossLines = ( } } + // If no words in later lines, return the first empty line we found + if (firstEmptyRow !== null) { + return { row: firstEmptyRow, col: 0 }; + } + return null; }; @@ -418,6 +501,106 @@ export const findPrevWordAcrossLines = ( return null; }; +// Find next big word across lines +export const findNextBigWordAcrossLines = ( + lines: string[], + cursorRow: number, + cursorCol: number, + searchForWordStart: boolean, +): { row: number; col: number } | null => { + // First try current line + const currentLine = lines[cursorRow] || ''; + const colInCurrentLine = searchForWordStart + ? findNextBigWordStartInLine(currentLine, cursorCol) + : findBigWordEndInLine(currentLine, cursorCol); + + if (colInCurrentLine !== null) { + return { row: cursorRow, col: colInCurrentLine }; + } + + let firstEmptyRow: number | null = null; + + // Search subsequent lines + for (let row = cursorRow + 1; row < lines.length; row++) { + const line = lines[row] || ''; + const chars = toCodePoints(line); + + // For empty lines, if we haven't found any words yet, remember the first empty line + if (chars.length === 0) { + if (firstEmptyRow === null) { + firstEmptyRow = row; + } + continue; + } + + // Find first non-whitespace + let firstNonWhitespace = 0; + while ( + firstNonWhitespace < chars.length && + isWhitespace(chars[firstNonWhitespace]) + ) { + firstNonWhitespace++; + } + + if (firstNonWhitespace < chars.length) { + // Found a non-whitespace character (start of a big word) + if (searchForWordStart) { + return { row, col: firstNonWhitespace }; + } else { + const endCol = findBigWordEndInLine(line, firstNonWhitespace); + if (endCol !== null) { + return { row, col: endCol }; + } + } + } + } + + // If no words in later lines, return the first empty line we found + if (firstEmptyRow !== null) { + return { row: firstEmptyRow, col: 0 }; + } + + return null; +}; + +// Find previous big word across lines +export const findPrevBigWordAcrossLines = ( + lines: string[], + cursorRow: number, + cursorCol: number, +): { row: number; col: number } | null => { + // First try current line + const currentLine = lines[cursorRow] || ''; + const colInCurrentLine = findPrevBigWordStartInLine(currentLine, cursorCol); + + if (colInCurrentLine !== null) { + return { row: cursorRow, col: colInCurrentLine }; + } + + // Search previous lines + for (let row = cursorRow - 1; row >= 0; row--) { + const line = lines[row] || ''; + const chars = toCodePoints(line); + + if (chars.length === 0) continue; + + // Find last big word start + let lastWordStart = chars.length; + while (lastWordStart > 0 && isWhitespace(chars[lastWordStart - 1])) { + lastWordStart--; + } + + if (lastWordStart > 0) { + const wordStart = findPrevBigWordStartInLine(line, lastWordStart); + if (wordStart !== null) { + return { row, col: wordStart }; + } + } + } + + return null; +}; + // Helper functions for vim line operations export const getPositionFromOffsets = ( startOffset: number, @@ -1454,9 +1637,15 @@ export type TextBufferAction = | { type: 'vim_delete_word_forward'; payload: { count: number } } | { type: 'vim_delete_word_backward'; payload: { count: number } } | { type: 'vim_delete_word_end'; payload: { count: number } } + | { type: 'vim_delete_big_word_forward'; payload: { count: number } } + | { type: 'vim_delete_big_word_backward'; payload: { count: number } } + | { type: 'vim_delete_big_word_end'; payload: { count: number } } | { type: 'vim_change_word_forward'; payload: { count: number } } | { type: 'vim_change_word_backward'; payload: { count: number } } | { type: 'vim_change_word_end'; payload: { count: number } } + | { type: 'vim_change_big_word_forward'; payload: { count: number } } + | { type: 'vim_change_big_word_backward'; payload: { count: number } } + | { 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' } @@ -1473,6 +1662,9 @@ export type TextBufferAction = | { type: 'vim_move_word_forward'; payload: { count: number } } | { type: 'vim_move_word_backward'; payload: { count: number } } | { type: 'vim_move_word_end'; payload: { count: number } } + | { type: 'vim_move_big_word_forward'; payload: { count: number } } + | { type: 'vim_move_big_word_backward'; payload: { count: number } } + | { type: 'vim_move_big_word_end'; payload: { count: number } } | { type: 'vim_delete_char'; payload: { count: number } } | { type: 'vim_insert_at_cursor' } | { type: 'vim_append_at_cursor' } @@ -2207,9 +2399,15 @@ function textBufferReducerLogic( case 'vim_delete_word_forward': case 'vim_delete_word_backward': case 'vim_delete_word_end': + case 'vim_delete_big_word_forward': + case 'vim_delete_big_word_backward': + case 'vim_delete_big_word_end': case 'vim_change_word_forward': case 'vim_change_word_backward': case 'vim_change_word_end': + case 'vim_change_big_word_forward': + case 'vim_change_big_word_backward': + case 'vim_change_big_word_end': case 'vim_delete_line': case 'vim_change_line': case 'vim_delete_to_end_of_line': @@ -2222,6 +2420,9 @@ function textBufferReducerLogic( case 'vim_move_word_forward': case 'vim_move_word_backward': case 'vim_move_word_end': + case 'vim_move_big_word_forward': + case 'vim_move_big_word_backward': + case 'vim_move_big_word_end': case 'vim_delete_char': case 'vim_insert_at_cursor': case 'vim_append_at_cursor': @@ -2670,6 +2871,18 @@ export function useTextBuffer({ dispatch({ type: 'vim_delete_word_end', payload: { count } }); }, []); + const vimDeleteBigWordForward = useCallback((count: number): void => { + dispatch({ type: 'vim_delete_big_word_forward', payload: { count } }); + }, []); + + const vimDeleteBigWordBackward = useCallback((count: number): void => { + dispatch({ type: 'vim_delete_big_word_backward', payload: { count } }); + }, []); + + const vimDeleteBigWordEnd = useCallback((count: number): void => { + dispatch({ type: 'vim_delete_big_word_end', payload: { count } }); + }, []); + const vimChangeWordForward = useCallback((count: number): void => { dispatch({ type: 'vim_change_word_forward', payload: { count } }); }, []); @@ -2682,6 +2895,18 @@ export function useTextBuffer({ dispatch({ type: 'vim_change_word_end', payload: { count } }); }, []); + const vimChangeBigWordForward = useCallback((count: number): void => { + dispatch({ type: 'vim_change_big_word_forward', payload: { count } }); + }, []); + + const vimChangeBigWordBackward = useCallback((count: number): void => { + dispatch({ type: 'vim_change_big_word_backward', payload: { count } }); + }, []); + + const vimChangeBigWordEnd = useCallback((count: number): void => { + dispatch({ type: 'vim_change_big_word_end', payload: { count } }); + }, []); + const vimDeleteLine = useCallback((count: number): void => { dispatch({ type: 'vim_delete_line', payload: { count } }); }, []); @@ -2734,6 +2959,18 @@ export function useTextBuffer({ dispatch({ type: 'vim_move_word_end', payload: { count } }); }, []); + const vimMoveBigWordForward = useCallback((count: number): void => { + dispatch({ type: 'vim_move_big_word_forward', payload: { count } }); + }, []); + + const vimMoveBigWordBackward = useCallback((count: number): void => { + dispatch({ type: 'vim_move_big_word_backward', payload: { count } }); + }, []); + + const vimMoveBigWordEnd = useCallback((count: number): void => { + dispatch({ type: 'vim_move_big_word_end', payload: { count } }); + }, []); + const vimDeleteChar = useCallback((count: number): void => { dispatch({ type: 'vim_delete_char', payload: { count } }); }, []); @@ -3230,9 +3467,15 @@ export function useTextBuffer({ vimDeleteWordForward, vimDeleteWordBackward, vimDeleteWordEnd, + vimDeleteBigWordForward, + vimDeleteBigWordBackward, + vimDeleteBigWordEnd, vimChangeWordForward, vimChangeWordBackward, vimChangeWordEnd, + vimChangeBigWordForward, + vimChangeBigWordBackward, + vimChangeBigWordEnd, vimDeleteLine, vimChangeLine, vimDeleteToEndOfLine, @@ -3245,6 +3488,9 @@ export function useTextBuffer({ vimMoveWordForward, vimMoveWordBackward, vimMoveWordEnd, + vimMoveBigWordForward, + vimMoveBigWordBackward, + vimMoveBigWordEnd, vimDeleteChar, vimInsertAtCursor, vimAppendAtCursor, @@ -3303,9 +3549,15 @@ export function useTextBuffer({ vimDeleteWordForward, vimDeleteWordBackward, vimDeleteWordEnd, + vimDeleteBigWordForward, + vimDeleteBigWordBackward, + vimDeleteBigWordEnd, vimChangeWordForward, vimChangeWordBackward, vimChangeWordEnd, + vimChangeBigWordForward, + vimChangeBigWordBackward, + vimChangeBigWordEnd, vimDeleteLine, vimChangeLine, vimDeleteToEndOfLine, @@ -3318,6 +3570,9 @@ export function useTextBuffer({ vimMoveWordForward, vimMoveWordBackward, vimMoveWordEnd, + vimMoveBigWordForward, + vimMoveBigWordBackward, + vimMoveBigWordEnd, vimDeleteChar, vimInsertAtCursor, vimAppendAtCursor, @@ -3500,6 +3755,18 @@ export interface TextBuffer { * Delete to end of N words from cursor position (vim 'de' command) */ vimDeleteWordEnd: (count: number) => void; + /** + * Delete N big words forward from cursor position (vim 'dW' command) + */ + vimDeleteBigWordForward: (count: number) => void; + /** + * Delete N big words backward from cursor position (vim 'dB' command) + */ + vimDeleteBigWordBackward: (count: number) => void; + /** + * Delete to end of N big words from cursor position (vim 'dE' command) + */ + vimDeleteBigWordEnd: (count: number) => void; /** * Change N words forward from cursor position (vim 'cw' command) */ @@ -3512,6 +3779,18 @@ export interface TextBuffer { * Change to end of N words from cursor position (vim 'ce' command) */ vimChangeWordEnd: (count: number) => void; + /** + * Change N big words forward from cursor position (vim 'cW' command) + */ + vimChangeBigWordForward: (count: number) => void; + /** + * Change N big words backward from cursor position (vim 'cB' command) + */ + vimChangeBigWordBackward: (count: number) => void; + /** + * Change to end of N big words from cursor position (vim 'cE' command) + */ + vimChangeBigWordEnd: (count: number) => void; /** * Delete N lines from cursor position (vim 'dd' command) */ @@ -3560,6 +3839,18 @@ export interface TextBuffer { * Move cursor to end of Nth word (vim 'e' command) */ vimMoveWordEnd: (count: number) => void; + /** + * Move cursor forward N big words (vim 'W' command) + */ + vimMoveBigWordForward: (count: number) => void; + /** + * Move cursor backward N big words (vim 'B' command) + */ + vimMoveBigWordBackward: (count: number) => void; + /** + * Move cursor to end of Nth big word (vim 'E' command) + */ + vimMoveBigWordEnd: (count: number) => void; /** * Delete N characters at cursor (vim 'x' command) */ 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 9345a805b0..925a3511e0 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 @@ -310,6 +310,32 @@ describe('vim-buffer-actions', () => { }); }); + describe('vim_move_big_word_backward', () => { + it('should treat punctuation as part of the word (B)', () => { + const state = createTestState(['hello.world'], 0, 10); + const action = { + type: 'vim_move_big_word_backward' as const, + payload: { count: 1 }, + }; + + const result = handleVimAction(state, action); + expect(result).toHaveOnlyValidCharacters(); + expect(result.cursorCol).toBe(0); // Start of 'hello' + }); + + it('should skip punctuation when moving back to previous big word', () => { + const state = createTestState(['word1, word2'], 0, 7); + const action = { + type: 'vim_move_big_word_backward' as const, + payload: { count: 1 }, + }; + + const result = handleVimAction(state, action); + expect(result).toHaveOnlyValidCharacters(); + expect(result.cursorCol).toBe(0); // Start of 'word1,' + }); + }); + describe('vim_move_word_end', () => { it('should move to end of current word', () => { const state = createTestState(['hello world'], 0, 0); @@ -584,6 +610,44 @@ describe('vim-buffer-actions', () => { expect(result.lines[0]).toBe('hello '); expect(result.cursorCol).toBe(6); }); + + it('should delete only the word characters if it is the last word followed by whitespace', () => { + const state = createTestState(['foo bar '], 0, 4); // on 'b' + const action = { + type: 'vim_delete_word_forward' as const, + payload: { count: 1 }, + }; + + const result = handleVimAction(state, action); + expect(result).toHaveOnlyValidCharacters(); + expect(result.lines[0]).toBe('foo '); + }); + + it('should do nothing if cursor is on whitespace after the last word', () => { + const state = createTestState(['foo bar '], 0, 8); // on one of the trailing spaces + const action = { + type: 'vim_delete_word_forward' as const, + payload: { count: 1 }, + }; + + const result = handleVimAction(state, action); + expect(result).toHaveOnlyValidCharacters(); + expect(result.lines[0]).toBe('foo bar '); + }); + }); + + describe('vim_delete_big_word_forward', () => { + it('should delete only the big word characters if it is the last word followed by whitespace', () => { + const state = createTestState(['foo bar.baz '], 0, 4); // on 'b' + const action = { + type: 'vim_delete_big_word_forward' as const, + payload: { count: 1 }, + }; + + const result = handleVimAction(state, action); + expect(result).toHaveOnlyValidCharacters(); + expect(result.lines[0]).toBe('foo '); + }); }); describe('vim_delete_word_backward', () => { 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 5bec8f033c..1018199474 100644 --- a/packages/cli/src/ui/components/shared/vim-buffer-actions.ts +++ b/packages/cli/src/ui/components/shared/vim-buffer-actions.ts @@ -11,41 +11,31 @@ import { replaceRangeInternal, pushUndo, detachExpandedPaste, - isWordCharStrict, - isWordCharWithCombining, isCombiningMark, findNextWordAcrossLines, findPrevWordAcrossLines, + findNextBigWordAcrossLines, + findPrevBigWordAcrossLines, findWordEndInLine, + findBigWordEndInLine, } from './text-buffer.js'; import { cpLen, toCodePoints } from '../../utils/textUtils.js'; import { assumeExhaustive } from '@google/gemini-cli-core'; -// Check if we're at the end of a base word (on the last base character) -// Returns true if current position has a base character followed only by combining marks until non-word -function isAtEndOfBaseWord(lineCodePoints: string[], col: number): boolean { - if (!isWordCharStrict(lineCodePoints[col])) return false; - - // Look ahead to see if we have only combining marks followed by non-word - let i = col + 1; - - // Skip any combining marks - while (i < lineCodePoints.length && isCombiningMark(lineCodePoints[i])) { - i++; - } - - // If we hit end of line or non-word character, we were at end of base word - return i >= lineCodePoints.length || !isWordCharStrict(lineCodePoints[i]); -} - export type VimAction = Extract< TextBufferAction, | { type: 'vim_delete_word_forward' } | { type: 'vim_delete_word_backward' } | { type: 'vim_delete_word_end' } + | { type: 'vim_delete_big_word_forward' } + | { type: 'vim_delete_big_word_backward' } + | { type: 'vim_delete_big_word_end' } | { type: 'vim_change_word_forward' } | { type: 'vim_change_word_backward' } | { type: 'vim_change_word_end' } + | { type: 'vim_change_big_word_forward' } + | { type: 'vim_change_big_word_backward' } + | { type: 'vim_change_big_word_end' } | { type: 'vim_delete_line' } | { type: 'vim_change_line' } | { type: 'vim_delete_to_end_of_line' } @@ -58,6 +48,9 @@ export type VimAction = Extract< | { type: 'vim_move_word_forward' } | { type: 'vim_move_word_backward' } | { type: 'vim_move_word_end' } + | { type: 'vim_move_big_word_forward' } + | { type: 'vim_move_big_word_backward' } + | { type: 'vim_move_big_word_end' } | { type: 'vim_delete_char' } | { type: 'vim_insert_at_cursor' } | { type: 'vim_append_at_cursor' } @@ -93,14 +86,15 @@ export function handleVimAction( endRow = nextWord.row; endCol = nextWord.col; } else { - // No more words, delete/change to end of current word or line + // No more words. Check if we can delete to the end of the current word. const currentLine = lines[endRow] || ''; const wordEnd = findWordEndInLine(currentLine, endCol); + if (wordEnd !== null) { - endCol = wordEnd + 1; // Include the character at word end - } else { - endCol = cpLen(currentLine); + // Found word end, delete up to (and including) it + endCol = wordEnd + 1; } + // If wordEnd is null, we are likely on trailing whitespace, so do nothing. break; } } @@ -119,6 +113,48 @@ export function handleVimAction( return state; } + case 'vim_delete_big_word_forward': + case 'vim_change_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 { + // No more words. Check if we can delete to the end of the current big word. + const currentLine = lines[endRow] || ''; + const wordEnd = findBigWordEndInLine(currentLine, endCol); + + if (wordEnd !== null) { + endCol = wordEnd + 1; + } + break; + } + } + + if (endRow !== cursorRow || endCol !== cursorCol) { + const nextState = pushUndo(state); + return replaceRangeInternal( + nextState, + cursorRow, + cursorCol, + endRow, + endCol, + '', + ); + } + return state; + } + case 'vim_delete_word_backward': case 'vim_change_word_backward': { const { count } = action.payload; @@ -149,6 +185,36 @@ export function handleVimAction( return state; } + case 'vim_delete_big_word_backward': + case 'vim_change_big_word_backward': { + const { count } = action.payload; + let startRow = cursorRow; + let startCol = cursorCol; + + for (let i = 0; i < count; i++) { + const prevWord = findPrevBigWordAcrossLines(lines, startRow, startCol); + if (prevWord) { + startRow = prevWord.row; + startCol = prevWord.col; + } else { + break; + } + } + + if (startRow !== cursorRow || startCol !== cursorCol) { + const nextState = pushUndo(state); + return replaceRangeInternal( + nextState, + startRow, + startCol, + cursorRow, + cursorCol, + '', + ); + } + return state; + } + case 'vim_delete_word_end': case 'vim_change_word_end': { const { count } = action.payload; @@ -202,6 +268,59 @@ export function handleVimAction( return state; } + case 'vim_delete_big_word_end': + case 'vim_change_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; // Include the character at word end + // For next iteration, move to start of next word + if (i < count - 1) { + const nextWord = findNextBigWordAcrossLines( + lines, + wordEnd.row, + wordEnd.col + 1, + true, + ); + if (nextWord) { + row = nextWord.row; + col = nextWord.col; + } else { + break; // No more words + } + } + } else { + break; + } + } + + // Ensure we don't go past the end of the last line + if (endRow < lines.length) { + const lineLen = cpLen(lines[endRow] || ''); + endCol = Math.min(endCol, lineLen); + } + + if (endRow !== cursorRow || endCol !== cursorCol) { + const nextState = pushUndo(state); + return replaceRangeInternal( + nextState, + cursorRow, + cursorCol, + endRow, + endCol, + '', + ); + } + return state; + } + case 'vim_delete_line': { const { count } = action.payload; if (lines.length === 0) return state; @@ -540,6 +659,30 @@ export function handleVimAction( }; } + case 'vim_move_big_word_forward': { + const { count } = action.payload; + let row = cursorRow; + let col = cursorCol; + + for (let i = 0; i < count; i++) { + const nextWord = findNextBigWordAcrossLines(lines, row, col, true); + if (nextWord) { + row = nextWord.row; + col = nextWord.col; + } else { + // No more words to move to + break; + } + } + + return { + ...state, + cursorRow: row, + cursorCol: col, + preferredCol: null, + }; + } + case 'vim_move_word_backward': { const { count } = action.payload; let row = cursorRow; @@ -563,43 +706,35 @@ export function handleVimAction( }; } + case 'vim_move_big_word_backward': { + const { count } = action.payload; + let row = cursorRow; + let col = cursorCol; + + for (let i = 0; i < count; i++) { + const prevWord = findPrevBigWordAcrossLines(lines, row, col); + if (prevWord) { + row = prevWord.row; + col = prevWord.col; + } else { + break; + } + } + + return { + ...state, + cursorRow: row, + cursorCol: col, + preferredCol: null, + }; + } + case 'vim_move_word_end': { const { count } = action.payload; let row = cursorRow; let col = cursorCol; for (let i = 0; i < count; i++) { - // Special handling for the first iteration when we're at end of word - if (i === 0) { - const currentLine = lines[row] || ''; - const lineCodePoints = toCodePoints(currentLine); - - // Check if we're at the end of a word (on the last base character) - const atEndOfWord = - col < lineCodePoints.length && - isWordCharStrict(lineCodePoints[col]) && - (col + 1 >= lineCodePoints.length || - !isWordCharWithCombining(lineCodePoints[col + 1]) || - // Or if we're on a base char followed only by combining marks until non-word - (isWordCharStrict(lineCodePoints[col]) && - isAtEndOfBaseWord(lineCodePoints, col))); - - if (atEndOfWord) { - // We're already at end of word, find next word end - const nextWord = findNextWordAcrossLines( - lines, - row, - col + 1, - false, - ); - if (nextWord) { - row = nextWord.row; - col = nextWord.col; - continue; - } - } - } - const wordEnd = findNextWordAcrossLines(lines, row, col, false); if (wordEnd) { row = wordEnd.row; @@ -617,6 +752,29 @@ export function handleVimAction( }; } + case 'vim_move_big_word_end': { + const { count } = action.payload; + let row = cursorRow; + let col = cursorCol; + + for (let i = 0; i < count; i++) { + const wordEnd = findNextBigWordAcrossLines(lines, row, col, false); + if (wordEnd) { + row = wordEnd.row; + col = wordEnd.col; + } else { + break; + } + } + + return { + ...state, + cursorRow: row, + cursorCol: col, + preferredCol: null, + }; + } + case 'vim_delete_char': { const { count } = action.payload; const { cursorRow, cursorCol, lines } = state; diff --git a/packages/cli/src/ui/hooks/vim.test.tsx b/packages/cli/src/ui/hooks/vim.test.tsx index f238c013f9..5a5ca6a858 100644 --- a/packages/cli/src/ui/hooks/vim.test.tsx +++ b/packages/cli/src/ui/hooks/vim.test.tsx @@ -156,6 +156,15 @@ describe('useVim hook', () => { vimMoveWordForward: vi.fn(), vimMoveWordBackward: vi.fn(), vimMoveWordEnd: vi.fn(), + vimMoveBigWordForward: vi.fn(), + vimMoveBigWordBackward: vi.fn(), + vimMoveBigWordEnd: vi.fn(), + vimDeleteBigWordForward: vi.fn(), + vimDeleteBigWordBackward: vi.fn(), + vimDeleteBigWordEnd: vi.fn(), + vimChangeBigWordForward: vi.fn(), + vimChangeBigWordBackward: vi.fn(), + vimChangeBigWordEnd: vi.fn(), vimDeleteChar: vi.fn(), vimInsertAtCursor: vi.fn(), vimAppendAtCursor: vi.fn().mockImplementation(() => { @@ -570,6 +579,105 @@ describe('useVim hook', () => { }); }); + describe('Big Word movement', () => { + it('should handle W (next big word)', () => { + const testBuffer = createMockBuffer('hello world test'); + const { result } = renderVimHook(testBuffer); + exitInsertMode(result); + + act(() => { + result.current.handleInput(createKey({ sequence: 'W' })); + }); + + expect(testBuffer.vimMoveBigWordForward).toHaveBeenCalledWith(1); + }); + + it('should handle B (previous big word)', () => { + const testBuffer = createMockBuffer('hello world test', [0, 6]); + const { result } = renderVimHook(testBuffer); + exitInsertMode(result); + + act(() => { + result.current.handleInput(createKey({ sequence: 'B' })); + }); + + expect(testBuffer.vimMoveBigWordBackward).toHaveBeenCalledWith(1); + }); + + it('should handle E (end of big word)', () => { + const testBuffer = createMockBuffer('hello world test'); + const { result } = renderVimHook(testBuffer); + exitInsertMode(result); + + act(() => { + result.current.handleInput(createKey({ sequence: 'E' })); + }); + + expect(testBuffer.vimMoveBigWordEnd).toHaveBeenCalledWith(1); + }); + + it('should handle dW (delete big word forward)', () => { + const testBuffer = createMockBuffer('hello.world test', [0, 0]); + const { result } = renderVimHook(testBuffer); + exitInsertMode(result); + + act(() => { + result.current.handleInput(createKey({ sequence: 'd' })); + }); + act(() => { + result.current.handleInput(createKey({ sequence: 'W' })); + }); + + expect(testBuffer.vimDeleteBigWordForward).toHaveBeenCalledWith(1); + }); + + it('should handle cW (change big word forward)', () => { + const testBuffer = createMockBuffer('hello.world test', [0, 0]); + const { result } = renderVimHook(testBuffer); + exitInsertMode(result); + + act(() => { + result.current.handleInput(createKey({ sequence: 'c' })); + }); + act(() => { + result.current.handleInput(createKey({ sequence: 'W' })); + }); + + expect(testBuffer.vimChangeBigWordForward).toHaveBeenCalledWith(1); + expect(result.current.mode).toBe('INSERT'); + }); + + it('should handle dB (delete big word backward)', () => { + const testBuffer = createMockBuffer('hello.world test', [0, 11]); + const { result } = renderVimHook(testBuffer); + exitInsertMode(result); + + act(() => { + result.current.handleInput(createKey({ sequence: 'd' })); + }); + act(() => { + result.current.handleInput(createKey({ sequence: 'B' })); + }); + + expect(testBuffer.vimDeleteBigWordBackward).toHaveBeenCalledWith(1); + }); + + it('should handle dE (delete big word end)', () => { + const testBuffer = createMockBuffer('hello.world test', [0, 0]); + const { result } = renderVimHook(testBuffer); + exitInsertMode(result); + + act(() => { + result.current.handleInput(createKey({ sequence: 'd' })); + }); + act(() => { + result.current.handleInput(createKey({ sequence: 'E' })); + }); + + expect(testBuffer.vimDeleteBigWordEnd).toHaveBeenCalledWith(1); + }); + }); + describe('Disabled vim mode', () => { it('should not respond to vim commands when disabled', () => { mockVimContext.vimEnabled = false; diff --git a/packages/cli/src/ui/hooks/vim.ts b/packages/cli/src/ui/hooks/vim.ts index eae1a38d51..bf91ba062b 100644 --- a/packages/cli/src/ui/hooks/vim.ts +++ b/packages/cli/src/ui/hooks/vim.ts @@ -24,9 +24,15 @@ const CMD_TYPES = { DELETE_WORD_FORWARD: 'dw', DELETE_WORD_BACKWARD: 'db', DELETE_WORD_END: 'de', + DELETE_BIG_WORD_FORWARD: 'dW', + DELETE_BIG_WORD_BACKWARD: 'dB', + DELETE_BIG_WORD_END: 'dE', CHANGE_WORD_FORWARD: 'cw', CHANGE_WORD_BACKWARD: 'cb', CHANGE_WORD_END: 'ce', + CHANGE_BIG_WORD_FORWARD: 'cW', + CHANGE_BIG_WORD_BACKWARD: 'cB', + CHANGE_BIG_WORD_END: 'cE', DELETE_CHAR: 'x', DELETE_LINE: 'dd', CHANGE_LINE: 'cc', @@ -187,6 +193,21 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { break; } + case CMD_TYPES.DELETE_BIG_WORD_FORWARD: { + buffer.vimDeleteBigWordForward(count); + break; + } + + case CMD_TYPES.DELETE_BIG_WORD_BACKWARD: { + buffer.vimDeleteBigWordBackward(count); + break; + } + + case CMD_TYPES.DELETE_BIG_WORD_END: { + buffer.vimDeleteBigWordEnd(count); + break; + } + case CMD_TYPES.CHANGE_WORD_FORWARD: { buffer.vimChangeWordForward(count); updateMode('INSERT'); @@ -205,6 +226,24 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { break; } + case CMD_TYPES.CHANGE_BIG_WORD_FORWARD: { + buffer.vimChangeBigWordForward(count); + updateMode('INSERT'); + break; + } + + case CMD_TYPES.CHANGE_BIG_WORD_BACKWARD: { + buffer.vimChangeBigWordBackward(count); + updateMode('INSERT'); + break; + } + + case CMD_TYPES.CHANGE_BIG_WORD_END: { + buffer.vimChangeBigWordEnd(count); + updateMode('INSERT'); + break; + } + case CMD_TYPES.DELETE_CHAR: { buffer.vimDeleteChar(count); break; @@ -371,7 +410,10 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { * @returns boolean indicating if command was handled */ const handleOperatorMotion = useCallback( - (operator: 'd' | 'c', motion: 'w' | 'b' | 'e'): boolean => { + ( + operator: 'd' | 'c', + motion: 'w' | 'b' | 'e' | 'W' | 'B' | 'E', + ): boolean => { const count = getCurrentCount(); const commandMap = { @@ -379,11 +421,17 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { w: CMD_TYPES.DELETE_WORD_FORWARD, b: CMD_TYPES.DELETE_WORD_BACKWARD, e: CMD_TYPES.DELETE_WORD_END, + W: CMD_TYPES.DELETE_BIG_WORD_FORWARD, + B: CMD_TYPES.DELETE_BIG_WORD_BACKWARD, + E: CMD_TYPES.DELETE_BIG_WORD_END, }, c: { w: CMD_TYPES.CHANGE_WORD_FORWARD, b: CMD_TYPES.CHANGE_WORD_BACKWARD, e: CMD_TYPES.CHANGE_WORD_END, + W: CMD_TYPES.CHANGE_BIG_WORD_FORWARD, + B: CMD_TYPES.CHANGE_BIG_WORD_BACKWARD, + E: CMD_TYPES.CHANGE_BIG_WORD_END, }, }; @@ -524,6 +572,21 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { return true; } + case 'W': { + // Check if this is part of a delete or change command (dW/cW) + if (state.pendingOperator === 'd') { + return handleOperatorMotion('d', 'W'); + } + if (state.pendingOperator === 'c') { + return handleOperatorMotion('c', 'W'); + } + + // Normal big word movement + buffer.vimMoveBigWordForward(repeatCount); + dispatch({ type: 'CLEAR_COUNT' }); + return true; + } + case 'b': { // Check if this is part of a delete or change command (db/cb) if (state.pendingOperator === 'd') { @@ -539,6 +602,21 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { return true; } + case 'B': { + // Check if this is part of a delete or change command (dB/cB) + if (state.pendingOperator === 'd') { + return handleOperatorMotion('d', 'B'); + } + if (state.pendingOperator === 'c') { + return handleOperatorMotion('c', 'B'); + } + + // Normal backward big word movement + buffer.vimMoveBigWordBackward(repeatCount); + dispatch({ type: 'CLEAR_COUNT' }); + return true; + } + case 'e': { // Check if this is part of a delete or change command (de/ce) if (state.pendingOperator === 'd') { @@ -554,6 +632,21 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { return true; } + case 'E': { + // Check if this is part of a delete or change command (dE/cE) + if (state.pendingOperator === 'd') { + return handleOperatorMotion('d', 'E'); + } + if (state.pendingOperator === 'c') { + return handleOperatorMotion('c', 'E'); + } + + // Normal big word end movement + buffer.vimMoveBigWordEnd(repeatCount); + dispatch({ type: 'CLEAR_COUNT' }); + return true; + } + case 'x': { // Delete character under cursor buffer.vimDeleteChar(repeatCount);