From fbdc8d5ab3f76aef32af6a8f516d97771c56a7ac Mon Sep 17 00:00:00 2001 From: Sijie Wang <3463757+sijieamoy@users.noreply.github.com> Date: Fri, 25 Jul 2025 15:36:42 -0700 Subject: Vim mode (#3936) --- packages/cli/src/ui/hooks/vim.ts | 774 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 774 insertions(+) create mode 100644 packages/cli/src/ui/hooks/vim.ts (limited to 'packages/cli/src/ui/hooks/vim.ts') diff --git a/packages/cli/src/ui/hooks/vim.ts b/packages/cli/src/ui/hooks/vim.ts new file mode 100644 index 00000000..cb65e1ee --- /dev/null +++ b/packages/cli/src/ui/hooks/vim.ts @@ -0,0 +1,774 @@ +/** + * @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 = { + [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' + ) { + 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 + } + + // 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') { + // Handle Escape key in NORMAL mode - clear all pending states + if (normalizedKey.name === 'escape') { + dispatch({ type: 'CLEAR_PENDING_STATES' }); + return true; // Handled by vim + } + + // 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 + }; +} -- cgit v1.2.3