Files
qwen-code/packages/cli/src/ui/hooks/vim.ts
Justin Mahood 91035ad7b0 Fix(vim): Fix shell mode in Vim mode (#5567)
Co-authored-by: Jacob Richman <jacob314@gmail.com>
2025-08-05 23:29:37 +00:00

785 lines
23 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useCallback, useReducer, useEffect } from 'react';
import type { Key } from './useKeypress.js';
import type { TextBuffer } from '../components/shared/text-buffer.js';
import { useVimMode } from '../contexts/VimModeContext.js';
export type VimMode = 'NORMAL' | 'INSERT';
// Constants
const DIGIT_MULTIPLIER = 10;
const DEFAULT_COUNT = 1;
const DIGIT_1_TO_9 = /^[1-9]$/;
// Command types
const CMD_TYPES = {
DELETE_WORD_FORWARD: 'dw',
DELETE_WORD_BACKWARD: 'db',
DELETE_WORD_END: 'de',
CHANGE_WORD_FORWARD: 'cw',
CHANGE_WORD_BACKWARD: 'cb',
CHANGE_WORD_END: 'ce',
DELETE_CHAR: 'x',
DELETE_LINE: 'dd',
CHANGE_LINE: 'cc',
DELETE_TO_EOL: 'D',
CHANGE_TO_EOL: 'C',
CHANGE_MOVEMENT: {
LEFT: 'ch',
DOWN: 'cj',
UP: 'ck',
RIGHT: 'cl',
},
} as const;
// Helper function to clear pending state
const createClearPendingState = () => ({
count: 0,
pendingOperator: null as 'g' | 'd' | 'c' | null,
});
// State and action types for useReducer
type VimState = {
mode: VimMode;
count: number;
pendingOperator: 'g' | 'd' | 'c' | null;
lastCommand: { type: string; count: number } | null;
};
type VimAction =
| { type: 'SET_MODE'; mode: VimMode }
| { type: 'SET_COUNT'; count: number }
| { type: 'INCREMENT_COUNT'; digit: number }
| { type: 'CLEAR_COUNT' }
| { type: 'SET_PENDING_OPERATOR'; operator: 'g' | 'd' | 'c' | null }
| {
type: 'SET_LAST_COMMAND';
command: { type: string; count: number } | null;
}
| { type: 'CLEAR_PENDING_STATES' }
| { type: 'ESCAPE_TO_NORMAL' };
const initialVimState: VimState = {
mode: 'NORMAL',
count: 0,
pendingOperator: null,
lastCommand: null,
};
// Reducer function
const vimReducer = (state: VimState, action: VimAction): VimState => {
switch (action.type) {
case 'SET_MODE':
return { ...state, mode: action.mode };
case 'SET_COUNT':
return { ...state, count: action.count };
case 'INCREMENT_COUNT':
return { ...state, count: state.count * DIGIT_MULTIPLIER + action.digit };
case 'CLEAR_COUNT':
return { ...state, count: 0 };
case 'SET_PENDING_OPERATOR':
return { ...state, pendingOperator: action.operator };
case 'SET_LAST_COMMAND':
return { ...state, lastCommand: action.command };
case 'CLEAR_PENDING_STATES':
return {
...state,
...createClearPendingState(),
};
case 'ESCAPE_TO_NORMAL':
// Handle escape - clear all pending states (mode is updated via context)
return {
...state,
...createClearPendingState(),
};
default:
return state;
}
};
/**
* React hook that provides vim-style editing functionality for text input.
*
* Features:
* - Modal editing (INSERT/NORMAL modes)
* - Navigation: h,j,k,l,w,b,e,0,$,^,gg,G with count prefixes
* - Editing: x,a,i,o,O,A,I,d,c,D,C with count prefixes
* - Complex operations: dd,cc,dw,cw,db,cb,de,ce
* - Command repetition (.)
* - Settings persistence
*
* @param buffer - TextBuffer instance for text manipulation
* @param onSubmit - Optional callback for command submission
* @returns Object with vim state and input handler
*/
export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
const { vimEnabled, vimMode, setVimMode } = useVimMode();
const [state, dispatch] = useReducer(vimReducer, initialVimState);
// Sync vim mode from context to local state
useEffect(() => {
dispatch({ type: 'SET_MODE', mode: vimMode });
}, [vimMode]);
// Helper to update mode in both reducer and context
const updateMode = useCallback(
(mode: VimMode) => {
setVimMode(mode);
dispatch({ type: 'SET_MODE', mode });
},
[setVimMode],
);
// Helper functions using the reducer state
const getCurrentCount = useCallback(
() => state.count || DEFAULT_COUNT,
[state.count],
);
/** Executes common commands to eliminate duplication in dot (.) repeat command */
const executeCommand = useCallback(
(cmdType: string, count: number) => {
switch (cmdType) {
case CMD_TYPES.DELETE_WORD_FORWARD: {
buffer.vimDeleteWordForward(count);
break;
}
case CMD_TYPES.DELETE_WORD_BACKWARD: {
buffer.vimDeleteWordBackward(count);
break;
}
case CMD_TYPES.DELETE_WORD_END: {
buffer.vimDeleteWordEnd(count);
break;
}
case CMD_TYPES.CHANGE_WORD_FORWARD: {
buffer.vimChangeWordForward(count);
updateMode('INSERT');
break;
}
case CMD_TYPES.CHANGE_WORD_BACKWARD: {
buffer.vimChangeWordBackward(count);
updateMode('INSERT');
break;
}
case CMD_TYPES.CHANGE_WORD_END: {
buffer.vimChangeWordEnd(count);
updateMode('INSERT');
break;
}
case CMD_TYPES.DELETE_CHAR: {
buffer.vimDeleteChar(count);
break;
}
case CMD_TYPES.DELETE_LINE: {
buffer.vimDeleteLine(count);
break;
}
case CMD_TYPES.CHANGE_LINE: {
buffer.vimChangeLine(count);
updateMode('INSERT');
break;
}
case CMD_TYPES.CHANGE_MOVEMENT.LEFT:
case CMD_TYPES.CHANGE_MOVEMENT.DOWN:
case CMD_TYPES.CHANGE_MOVEMENT.UP:
case CMD_TYPES.CHANGE_MOVEMENT.RIGHT: {
const movementMap: Record<string, 'h' | 'j' | 'k' | 'l'> = {
[CMD_TYPES.CHANGE_MOVEMENT.LEFT]: 'h',
[CMD_TYPES.CHANGE_MOVEMENT.DOWN]: 'j',
[CMD_TYPES.CHANGE_MOVEMENT.UP]: 'k',
[CMD_TYPES.CHANGE_MOVEMENT.RIGHT]: 'l',
};
const movementType = movementMap[cmdType];
if (movementType) {
buffer.vimChangeMovement(movementType, count);
updateMode('INSERT');
}
break;
}
case CMD_TYPES.DELETE_TO_EOL: {
buffer.vimDeleteToEndOfLine();
break;
}
case CMD_TYPES.CHANGE_TO_EOL: {
buffer.vimChangeToEndOfLine();
updateMode('INSERT');
break;
}
default:
return false;
}
return true;
},
[buffer, updateMode],
);
/**
* Handles key input in INSERT mode
* @param normalizedKey - The normalized key input
* @returns boolean indicating if the key was handled
*/
const handleInsertModeInput = useCallback(
(normalizedKey: Key): boolean => {
// Handle escape key immediately - switch to NORMAL mode on any escape
if (normalizedKey.name === 'escape') {
// Vim behavior: move cursor left when exiting insert mode (unless at beginning of line)
buffer.vimEscapeInsertMode();
dispatch({ type: 'ESCAPE_TO_NORMAL' });
updateMode('NORMAL');
return true;
}
// In INSERT mode, let InputPrompt handle completion keys and special commands
if (
normalizedKey.name === 'tab' ||
(normalizedKey.name === 'return' && !normalizedKey.ctrl) ||
normalizedKey.name === 'up' ||
normalizedKey.name === 'down' ||
(normalizedKey.ctrl && normalizedKey.name === 'r')
) {
return false; // Let InputPrompt handle completion
}
// Let InputPrompt handle Ctrl+V for clipboard image pasting
if (normalizedKey.ctrl && normalizedKey.name === 'v') {
return false; // Let InputPrompt handle clipboard functionality
}
// Let InputPrompt handle shell commands
if (normalizedKey.sequence === '!' && buffer.text.length === 0) {
return false;
}
// Special handling for Enter key to allow command submission (lower priority than completion)
if (
normalizedKey.name === 'return' &&
!normalizedKey.ctrl &&
!normalizedKey.meta
) {
if (buffer.text.trim() && onSubmit) {
// Handle command submission directly
const submittedValue = buffer.text;
buffer.setText('');
onSubmit(submittedValue);
return true;
}
return true; // Handled by vim (even if no onSubmit callback)
}
// useKeypress already provides the correct format for TextBuffer
buffer.handleInput(normalizedKey);
return true; // Handled by vim
},
[buffer, dispatch, updateMode, onSubmit],
);
/**
* Normalizes key input to ensure all required properties are present
* @param key - Raw key input
* @returns Normalized key with all properties
*/
const normalizeKey = useCallback(
(key: Key): Key => ({
name: key.name || '',
sequence: key.sequence || '',
ctrl: key.ctrl || false,
meta: key.meta || false,
shift: key.shift || false,
paste: key.paste || false,
}),
[],
);
/**
* Handles change movement commands (ch, cj, ck, cl)
* @param movement - The movement direction
* @returns boolean indicating if command was handled
*/
const handleChangeMovement = useCallback(
(movement: 'h' | 'j' | 'k' | 'l'): boolean => {
const count = getCurrentCount();
dispatch({ type: 'CLEAR_COUNT' });
buffer.vimChangeMovement(movement, count);
updateMode('INSERT');
const cmdTypeMap = {
h: CMD_TYPES.CHANGE_MOVEMENT.LEFT,
j: CMD_TYPES.CHANGE_MOVEMENT.DOWN,
k: CMD_TYPES.CHANGE_MOVEMENT.UP,
l: CMD_TYPES.CHANGE_MOVEMENT.RIGHT,
};
dispatch({
type: 'SET_LAST_COMMAND',
command: { type: cmdTypeMap[movement], count },
});
dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
return true;
},
[getCurrentCount, dispatch, buffer, updateMode],
);
/**
* Handles operator-motion commands (dw/cw, db/cb, de/ce)
* @param operator - The operator type ('d' for delete, 'c' for change)
* @param motion - The motion type ('w', 'b', 'e')
* @returns boolean indicating if command was handled
*/
const handleOperatorMotion = useCallback(
(operator: 'd' | 'c', motion: 'w' | 'b' | 'e'): boolean => {
const count = getCurrentCount();
const commandMap = {
d: {
w: CMD_TYPES.DELETE_WORD_FORWARD,
b: CMD_TYPES.DELETE_WORD_BACKWARD,
e: CMD_TYPES.DELETE_WORD_END,
},
c: {
w: CMD_TYPES.CHANGE_WORD_FORWARD,
b: CMD_TYPES.CHANGE_WORD_BACKWARD,
e: CMD_TYPES.CHANGE_WORD_END,
},
};
const cmdType = commandMap[operator][motion];
executeCommand(cmdType, count);
dispatch({
type: 'SET_LAST_COMMAND',
command: { type: cmdType, count },
});
dispatch({ type: 'CLEAR_COUNT' });
dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
return true;
},
[getCurrentCount, executeCommand, dispatch],
);
const handleInput = useCallback(
(key: Key): boolean => {
if (!vimEnabled) {
return false; // Let InputPrompt handle it
}
let normalizedKey: Key;
try {
normalizedKey = normalizeKey(key);
} catch (error) {
// Handle malformed key inputs gracefully
console.warn('Malformed key input in vim mode:', key, error);
return false;
}
// Handle INSERT mode
if (state.mode === 'INSERT') {
return handleInsertModeInput(normalizedKey);
}
// Handle NORMAL mode
if (state.mode === 'NORMAL') {
// If in NORMAL mode, allow escape to pass through to other handlers
// if there's no pending operation.
if (normalizedKey.name === 'escape') {
if (state.pendingOperator) {
dispatch({ type: 'CLEAR_PENDING_STATES' });
return true; // Handled by vim
}
return false; // Pass through to other handlers
}
// Handle count input (numbers 1-9, and 0 if count > 0)
if (
DIGIT_1_TO_9.test(normalizedKey.sequence) ||
(normalizedKey.sequence === '0' && state.count > 0)
) {
dispatch({
type: 'INCREMENT_COUNT',
digit: parseInt(normalizedKey.sequence, 10),
});
return true; // Handled by vim
}
const repeatCount = getCurrentCount();
switch (normalizedKey.sequence) {
case 'h': {
// Check if this is part of a change command (ch)
if (state.pendingOperator === 'c') {
return handleChangeMovement('h');
}
// Normal left movement
buffer.vimMoveLeft(repeatCount);
dispatch({ type: 'CLEAR_COUNT' });
return true;
}
case 'j': {
// Check if this is part of a change command (cj)
if (state.pendingOperator === 'c') {
return handleChangeMovement('j');
}
// Normal down movement
buffer.vimMoveDown(repeatCount);
dispatch({ type: 'CLEAR_COUNT' });
return true;
}
case 'k': {
// Check if this is part of a change command (ck)
if (state.pendingOperator === 'c') {
return handleChangeMovement('k');
}
// Normal up movement
buffer.vimMoveUp(repeatCount);
dispatch({ type: 'CLEAR_COUNT' });
return true;
}
case 'l': {
// Check if this is part of a change command (cl)
if (state.pendingOperator === 'c') {
return handleChangeMovement('l');
}
// Normal right movement
buffer.vimMoveRight(repeatCount);
dispatch({ type: 'CLEAR_COUNT' });
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 word movement
buffer.vimMoveWordForward(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') {
return handleOperatorMotion('d', 'b');
}
if (state.pendingOperator === 'c') {
return handleOperatorMotion('c', 'b');
}
// Normal backward word movement
buffer.vimMoveWordBackward(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') {
return handleOperatorMotion('d', 'e');
}
if (state.pendingOperator === 'c') {
return handleOperatorMotion('c', 'e');
}
// Normal word end movement
buffer.vimMoveWordEnd(repeatCount);
dispatch({ type: 'CLEAR_COUNT' });
return true;
}
case 'x': {
// Delete character under cursor
buffer.vimDeleteChar(repeatCount);
dispatch({
type: 'SET_LAST_COMMAND',
command: { type: CMD_TYPES.DELETE_CHAR, count: repeatCount },
});
dispatch({ type: 'CLEAR_COUNT' });
return true;
}
case 'i': {
// Enter INSERT mode at current position
buffer.vimInsertAtCursor();
updateMode('INSERT');
dispatch({ type: 'CLEAR_COUNT' });
return true;
}
case 'a': {
// Enter INSERT mode after current position
buffer.vimAppendAtCursor();
updateMode('INSERT');
dispatch({ type: 'CLEAR_COUNT' });
return true;
}
case 'o': {
// Insert new line after current line and enter INSERT mode
buffer.vimOpenLineBelow();
updateMode('INSERT');
dispatch({ type: 'CLEAR_COUNT' });
return true;
}
case 'O': {
// Insert new line before current line and enter INSERT mode
buffer.vimOpenLineAbove();
updateMode('INSERT');
dispatch({ type: 'CLEAR_COUNT' });
return true;
}
case '0': {
// Move to start of line
buffer.vimMoveToLineStart();
dispatch({ type: 'CLEAR_COUNT' });
return true;
}
case '$': {
// Move to end of line
buffer.vimMoveToLineEnd();
dispatch({ type: 'CLEAR_COUNT' });
return true;
}
case '^': {
// Move to first non-whitespace character
buffer.vimMoveToFirstNonWhitespace();
dispatch({ type: 'CLEAR_COUNT' });
return true;
}
case 'g': {
if (state.pendingOperator === 'g') {
// Second 'g' - go to first line (gg command)
buffer.vimMoveToFirstLine();
dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
} else {
// First 'g' - wait for second g
dispatch({ type: 'SET_PENDING_OPERATOR', operator: 'g' });
}
dispatch({ type: 'CLEAR_COUNT' });
return true;
}
case 'G': {
if (state.count > 0) {
// Go to specific line number (1-based) when a count was provided
buffer.vimMoveToLine(state.count);
} else {
// Go to last line when no count was provided
buffer.vimMoveToLastLine();
}
dispatch({ type: 'CLEAR_COUNT' });
return true;
}
case 'I': {
// Enter INSERT mode at start of line (first non-whitespace)
buffer.vimInsertAtLineStart();
updateMode('INSERT');
dispatch({ type: 'CLEAR_COUNT' });
return true;
}
case 'A': {
// Enter INSERT mode at end of line
buffer.vimAppendAtLineEnd();
updateMode('INSERT');
dispatch({ type: 'CLEAR_COUNT' });
return true;
}
case 'd': {
if (state.pendingOperator === 'd') {
// Second 'd' - delete N lines (dd command)
const repeatCount = getCurrentCount();
executeCommand(CMD_TYPES.DELETE_LINE, repeatCount);
dispatch({
type: 'SET_LAST_COMMAND',
command: { type: CMD_TYPES.DELETE_LINE, count: repeatCount },
});
dispatch({ type: 'CLEAR_COUNT' });
dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
} else {
// First 'd' - wait for movement command
dispatch({ type: 'SET_PENDING_OPERATOR', operator: 'd' });
}
return true;
}
case 'c': {
if (state.pendingOperator === 'c') {
// Second 'c' - change N entire lines (cc command)
const repeatCount = getCurrentCount();
executeCommand(CMD_TYPES.CHANGE_LINE, repeatCount);
dispatch({
type: 'SET_LAST_COMMAND',
command: { type: CMD_TYPES.CHANGE_LINE, count: repeatCount },
});
dispatch({ type: 'CLEAR_COUNT' });
dispatch({ type: 'SET_PENDING_OPERATOR', operator: null });
} else {
// First 'c' - wait for movement command
dispatch({ type: 'SET_PENDING_OPERATOR', operator: 'c' });
}
return true;
}
case 'D': {
// Delete from cursor to end of line (equivalent to d$)
executeCommand(CMD_TYPES.DELETE_TO_EOL, 1);
dispatch({
type: 'SET_LAST_COMMAND',
command: { type: CMD_TYPES.DELETE_TO_EOL, count: 1 },
});
dispatch({ type: 'CLEAR_COUNT' });
return true;
}
case 'C': {
// Change from cursor to end of line (equivalent to c$)
executeCommand(CMD_TYPES.CHANGE_TO_EOL, 1);
dispatch({
type: 'SET_LAST_COMMAND',
command: { type: CMD_TYPES.CHANGE_TO_EOL, count: 1 },
});
dispatch({ type: 'CLEAR_COUNT' });
return true;
}
case '.': {
// Repeat last command
if (state.lastCommand) {
const cmdData = state.lastCommand;
// All repeatable commands are now handled by executeCommand
executeCommand(cmdData.type, cmdData.count);
}
dispatch({ type: 'CLEAR_COUNT' });
return true;
}
default: {
// Check for arrow keys (they have different sequences but known names)
if (normalizedKey.name === 'left') {
// Left arrow - same as 'h'
if (state.pendingOperator === 'c') {
return handleChangeMovement('h');
}
// Normal left movement (same as 'h')
buffer.vimMoveLeft(repeatCount);
dispatch({ type: 'CLEAR_COUNT' });
return true;
}
if (normalizedKey.name === 'down') {
// Down arrow - same as 'j'
if (state.pendingOperator === 'c') {
return handleChangeMovement('j');
}
// Normal down movement (same as 'j')
buffer.vimMoveDown(repeatCount);
dispatch({ type: 'CLEAR_COUNT' });
return true;
}
if (normalizedKey.name === 'up') {
// Up arrow - same as 'k'
if (state.pendingOperator === 'c') {
return handleChangeMovement('k');
}
// Normal up movement (same as 'k')
buffer.vimMoveUp(repeatCount);
dispatch({ type: 'CLEAR_COUNT' });
return true;
}
if (normalizedKey.name === 'right') {
// Right arrow - same as 'l'
if (state.pendingOperator === 'c') {
return handleChangeMovement('l');
}
// Normal right movement (same as 'l')
buffer.vimMoveRight(repeatCount);
dispatch({ type: 'CLEAR_COUNT' });
return true;
}
// Unknown command, clear count and pending states
dispatch({ type: 'CLEAR_PENDING_STATES' });
return true; // Still handled by vim to prevent other handlers
}
}
}
return false; // Not handled by vim
},
[
vimEnabled,
normalizeKey,
handleInsertModeInput,
state.mode,
state.count,
state.pendingOperator,
state.lastCommand,
dispatch,
getCurrentCount,
handleChangeMovement,
handleOperatorMotion,
buffer,
executeCommand,
updateMode,
],
);
return {
mode: state.mode,
vimModeEnabled: vimEnabled,
handleInput, // Expose the input handler for InputPrompt to use
};
}