feat(cli): Add W, B, E Vim motions and operator support (#16209)

Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
This commit is contained in:
Adam DeMuri
2026-02-05 10:29:30 -07:00
committed by GitHub
parent 1cae5ab158
commit ee2c8eef19
6 changed files with 836 additions and 82 deletions

View File

@@ -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;

View File

@@ -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);