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) --- .../cli/src/ui/components/shared/text-buffer.ts | 799 ++++++++++++++++++--- 1 file changed, 711 insertions(+), 88 deletions(-) (limited to 'packages/cli/src/ui/components/shared/text-buffer.ts') diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index 31db1f14..d2d9087a 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -13,6 +13,7 @@ import { useState, useCallback, useEffect, useMemo, useReducer } from 'react'; import stringWidth from 'string-width'; import { unescapePath } from '@google/gemini-cli-core'; import { toCodePoints, cpLen, cpSlice } from '../../utils/textUtils.js'; +import { handleVimAction, VimAction } from './vim-buffer-actions.js'; export type Direction = | 'left' @@ -32,6 +33,283 @@ function isWordChar(ch: string | undefined): boolean { return !/[\s,.;!?]/.test(ch); } +// Vim-specific word boundary functions +export const findNextWordStart = ( + text: string, + currentOffset: number, +): number => { + let i = currentOffset; + + if (i >= text.length) return i; + + const currentChar = text[i]; + + // Skip current word/sequence based on character type + if (/\w/.test(currentChar)) { + // Skip current word characters + while (i < text.length && /\w/.test(text[i])) { + i++; + } + } else if (!/\s/.test(currentChar)) { + // Skip current non-word, non-whitespace characters (like "/", ".", etc.) + while (i < text.length && !/\w/.test(text[i]) && !/\s/.test(text[i])) { + i++; + } + } + + // Skip whitespace + while (i < text.length && /\s/.test(text[i])) { + i++; + } + + // If we reached the end of text and there's no next word, + // vim behavior for dw is to delete to the end of the current word + if (i >= text.length) { + // Go back to find the end of the last word + let endOfLastWord = text.length - 1; + while (endOfLastWord >= 0 && /\s/.test(text[endOfLastWord])) { + endOfLastWord--; + } + // For dw on last word, return position AFTER the last character to delete entire word + return Math.max(currentOffset + 1, endOfLastWord + 1); + } + + return i; +}; + +export const findPrevWordStart = ( + text: string, + currentOffset: number, +): number => { + let i = currentOffset; + + // If at beginning of text, return current position + if (i <= 0) { + return currentOffset; + } + + // Move back one character to start searching + i--; + + // Skip whitespace moving backwards + while (i >= 0 && (text[i] === ' ' || text[i] === '\t' || text[i] === '\n')) { + i--; + } + + if (i < 0) { + return 0; // Reached beginning of text + } + + const charAtI = text[i]; + + if (/\w/.test(charAtI)) { + // We're in a word, move to its beginning + while (i >= 0 && /\w/.test(text[i])) { + i--; + } + return i + 1; // Return first character of word + } else { + // We're in punctuation, move to its beginning + while ( + i >= 0 && + !/\w/.test(text[i]) && + text[i] !== ' ' && + text[i] !== '\t' && + text[i] !== '\n' + ) { + i--; + } + return i + 1; // Return first character of punctuation sequence + } +}; + +export const findWordEnd = (text: string, currentOffset: number): number => { + let i = currentOffset; + + // If we're already at the end of a word, advance to next word + if ( + i < text.length && + /\w/.test(text[i]) && + (i + 1 >= text.length || !/\w/.test(text[i + 1])) + ) { + // We're at the end of a word, move forward to find next word + i++; + // Skip whitespace/punctuation to find next word + while (i < text.length && !/\w/.test(text[i])) { + i++; + } + } + + // If we're not on a word character, find the next word + if (i < text.length && !/\w/.test(text[i])) { + while (i < text.length && !/\w/.test(text[i])) { + i++; + } + } + + // Move to end of current word + while (i < text.length && /\w/.test(text[i])) { + i++; + } + + // Move back one to be on the last character of the word + return Math.max(currentOffset, i - 1); +}; + +// Helper functions for vim operations +export const getOffsetFromPosition = ( + row: number, + col: number, + lines: string[], +): number => { + let offset = 0; + for (let i = 0; i < row; i++) { + offset += lines[i].length + 1; // +1 for newline + } + offset += col; + return offset; +}; + +export const getPositionFromOffsets = ( + startOffset: number, + endOffset: number, + lines: string[], +) => { + let offset = 0; + let startRow = 0; + let startCol = 0; + let endRow = 0; + let endCol = 0; + + // Find start position + for (let i = 0; i < lines.length; i++) { + const lineLength = lines[i].length + 1; // +1 for newline + if (offset + lineLength > startOffset) { + startRow = i; + startCol = startOffset - offset; + break; + } + offset += lineLength; + } + + // Find end position + offset = 0; + for (let i = 0; i < lines.length; i++) { + const lineLength = lines[i].length + (i < lines.length - 1 ? 1 : 0); // +1 for newline except last line + if (offset + lineLength >= endOffset) { + endRow = i; + endCol = endOffset - offset; + break; + } + offset += lineLength; + } + + return { startRow, startCol, endRow, endCol }; +}; + +export const getLineRangeOffsets = ( + startRow: number, + lineCount: number, + lines: string[], +) => { + let startOffset = 0; + + // Calculate start offset + for (let i = 0; i < startRow; i++) { + startOffset += lines[i].length + 1; // +1 for newline + } + + // Calculate end offset + let endOffset = startOffset; + for (let i = 0; i < lineCount; i++) { + const lineIndex = startRow + i; + if (lineIndex < lines.length) { + endOffset += lines[lineIndex].length; + if (lineIndex < lines.length - 1) { + endOffset += 1; // +1 for newline + } + } + } + + return { startOffset, endOffset }; +}; + +export const replaceRangeInternal = ( + state: TextBufferState, + startRow: number, + startCol: number, + endRow: number, + endCol: number, + text: string, +): TextBufferState => { + const currentLine = (row: number) => state.lines[row] || ''; + const currentLineLen = (row: number) => cpLen(currentLine(row)); + const clamp = (value: number, min: number, max: number) => + Math.min(Math.max(value, min), max); + + if ( + startRow > endRow || + (startRow === endRow && startCol > endCol) || + startRow < 0 || + startCol < 0 || + endRow >= state.lines.length || + (endRow < state.lines.length && endCol > currentLineLen(endRow)) + ) { + return state; // Invalid range + } + + const newLines = [...state.lines]; + + const sCol = clamp(startCol, 0, currentLineLen(startRow)); + const eCol = clamp(endCol, 0, currentLineLen(endRow)); + + const prefix = cpSlice(currentLine(startRow), 0, sCol); + const suffix = cpSlice(currentLine(endRow), eCol); + + const normalisedReplacement = text + .replace(/\r\n/g, '\n') + .replace(/\r/g, '\n'); + const replacementParts = normalisedReplacement.split('\n'); + + // Replace the content + if (startRow === endRow) { + newLines[startRow] = prefix + normalisedReplacement + suffix; + } else { + const firstLine = prefix + replacementParts[0]; + if (replacementParts.length === 1) { + // Single line of replacement text, but spanning multiple original lines + newLines.splice(startRow, endRow - startRow + 1, firstLine + suffix); + } else { + // Multi-line replacement text + const lastLine = replacementParts[replacementParts.length - 1] + suffix; + const middleLines = replacementParts.slice(1, -1); + newLines.splice( + startRow, + endRow - startRow + 1, + firstLine, + ...middleLines, + lastLine, + ); + } + } + + const finalCursorRow = startRow + replacementParts.length - 1; + const finalCursorCol = + (replacementParts.length > 1 ? 0 : sCol) + + cpLen(replacementParts[replacementParts.length - 1]); + + return { + ...state, + lines: newLines, + cursorRow: Math.min(Math.max(finalCursorRow, 0), newLines.length - 1), + cursorCol: Math.max( + 0, + Math.min(finalCursorCol, cpLen(newLines[finalCursorRow] || '')), + ), + preferredCol: null, + }; +}; + /** * Strip characters that can break terminal rendering. * @@ -158,6 +436,33 @@ export function offsetToLogicalPos( return [row, col]; } +/** + * Converts logical row/col position to absolute text offset + * Inverse operation of offsetToLogicalPos + */ +export function logicalPosToOffset( + lines: string[], + row: number, + col: number, +): number { + let offset = 0; + + // Clamp row to valid range + const actualRow = Math.min(row, lines.length - 1); + + // Add lengths of all lines before the target row + for (let i = 0; i < actualRow; i++) { + offset += cpLen(lines[i]) + 1; // +1 for newline + } + + // Add column offset within the target row + if (actualRow >= 0 && actualRow < lines.length) { + offset += Math.min(col, cpLen(lines[actualRow])); + } + + return offset; +} + // Helper to calculate visual lines and map cursor positions function calculateVisualLayout( logicalLines: string[], @@ -376,7 +681,7 @@ function calculateVisualLayout( // --- Start of reducer logic --- -interface TextBufferState { +export interface TextBufferState { lines: string[]; cursorRow: number; cursorCol: number; @@ -390,7 +695,20 @@ interface TextBufferState { const historyLimit = 100; -type TextBufferAction = +export const pushUndo = (currentState: TextBufferState): TextBufferState => { + const snapshot = { + lines: [...currentState.lines], + cursorRow: currentState.cursorRow, + cursorCol: currentState.cursorCol, + }; + const newStack = [...currentState.undoStack, snapshot]; + if (newStack.length > historyLimit) { + newStack.shift(); + } + return { ...currentState, undoStack: newStack, redoStack: [] }; +}; + +export type TextBufferAction = | { type: 'set_text'; payload: string; pushToUndo?: boolean } | { type: 'insert'; payload: string } | { type: 'backspace' } @@ -419,24 +737,49 @@ type TextBufferAction = } | { type: 'move_to_offset'; payload: { offset: number } } | { type: 'create_undo_snapshot' } - | { type: 'set_viewport_width'; payload: number }; + | { type: 'set_viewport_width'; payload: number } + | { 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_change_word_forward'; payload: { count: number } } + | { type: 'vim_change_word_backward'; payload: { count: number } } + | { type: 'vim_change_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' } + | { type: 'vim_change_to_end_of_line' } + | { + type: 'vim_change_movement'; + payload: { movement: 'h' | 'j' | 'k' | 'l'; count: number }; + } + // New vim actions for stateless command handling + | { type: 'vim_move_left'; payload: { count: number } } + | { type: 'vim_move_right'; payload: { count: number } } + | { type: 'vim_move_up'; payload: { count: number } } + | { type: 'vim_move_down'; payload: { count: number } } + | { 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_delete_char'; payload: { count: number } } + | { type: 'vim_insert_at_cursor' } + | { type: 'vim_append_at_cursor' } + | { type: 'vim_open_line_below' } + | { type: 'vim_open_line_above' } + | { type: 'vim_append_at_line_end' } + | { type: 'vim_insert_at_line_start' } + | { type: 'vim_move_to_line_start' } + | { type: 'vim_move_to_line_end' } + | { type: 'vim_move_to_first_nonwhitespace' } + | { type: 'vim_move_to_first_line' } + | { type: 'vim_move_to_last_line' } + | { type: 'vim_move_to_line'; payload: { lineNumber: number } } + | { type: 'vim_escape_insert_mode' }; export function textBufferReducer( state: TextBufferState, action: TextBufferAction, ): TextBufferState { - const pushUndo = (currentState: TextBufferState): TextBufferState => { - const snapshot = { - lines: [...currentState.lines], - cursorRow: currentState.cursorRow, - cursorCol: currentState.cursorCol, - }; - const newStack = [...currentState.undoStack, snapshot]; - if (newStack.length > historyLimit) { - newStack.shift(); - } - return { ...currentState, undoStack: newStack, redoStack: [] }; - }; + const pushUndoLocal = pushUndo; const currentLine = (r: number): string => state.lines[r] ?? ''; const currentLineLen = (r: number): number => cpLen(currentLine(r)); @@ -445,7 +788,7 @@ export function textBufferReducer( case 'set_text': { let nextState = state; if (action.pushToUndo !== false) { - nextState = pushUndo(state); + nextState = pushUndoLocal(state); } const newContentLines = action.payload .replace(/\r\n?/g, '\n') @@ -462,7 +805,7 @@ export function textBufferReducer( } case 'insert': { - const nextState = pushUndo(state); + const nextState = pushUndoLocal(state); const newLines = [...nextState.lines]; let newCursorRow = nextState.cursorRow; let newCursorCol = nextState.cursorCol; @@ -504,7 +847,7 @@ export function textBufferReducer( } case 'backspace': { - const nextState = pushUndo(state); + const nextState = pushUndoLocal(state); const newLines = [...nextState.lines]; let newCursorRow = nextState.cursorRow; let newCursorCol = nextState.cursorCol; @@ -700,14 +1043,14 @@ export function textBufferReducer( const { cursorRow, cursorCol, lines } = state; const lineContent = currentLine(cursorRow); if (cursorCol < currentLineLen(cursorRow)) { - const nextState = pushUndo(state); + const nextState = pushUndoLocal(state); const newLines = [...nextState.lines]; newLines[cursorRow] = cpSlice(lineContent, 0, cursorCol) + cpSlice(lineContent, cursorCol + 1); return { ...nextState, lines: newLines, preferredCol: null }; } else if (cursorRow < lines.length - 1) { - const nextState = pushUndo(state); + const nextState = pushUndoLocal(state); const nextLineContent = currentLine(cursorRow + 1); const newLines = [...nextState.lines]; newLines[cursorRow] = lineContent + nextLineContent; @@ -722,7 +1065,7 @@ export function textBufferReducer( if (cursorCol === 0 && cursorRow === 0) return state; if (cursorCol === 0) { // Act as a backspace - const nextState = pushUndo(state); + const nextState = pushUndoLocal(state); const prevLineContent = currentLine(cursorRow - 1); const currentLineContentVal = currentLine(cursorRow); const newCol = cpLen(prevLineContent); @@ -737,7 +1080,7 @@ export function textBufferReducer( preferredCol: null, }; } - const nextState = pushUndo(state); + const nextState = pushUndoLocal(state); const lineContent = currentLine(cursorRow); const arr = toCodePoints(lineContent); let start = cursorCol; @@ -773,14 +1116,14 @@ export function textBufferReducer( return state; if (cursorCol >= arr.length) { // Act as a delete - const nextState = pushUndo(state); + const nextState = pushUndoLocal(state); const nextLineContent = currentLine(cursorRow + 1); const newLines = [...nextState.lines]; newLines[cursorRow] = lineContent + nextLineContent; newLines.splice(cursorRow + 1, 1); return { ...nextState, lines: newLines, preferredCol: null }; } - const nextState = pushUndo(state); + const nextState = pushUndoLocal(state); let end = cursorCol; while (end < arr.length && !isWordChar(arr[end])) end++; while (end < arr.length && isWordChar(arr[end])) end++; @@ -794,13 +1137,13 @@ export function textBufferReducer( const { cursorRow, cursorCol, lines } = state; const lineContent = currentLine(cursorRow); if (cursorCol < currentLineLen(cursorRow)) { - const nextState = pushUndo(state); + const nextState = pushUndoLocal(state); const newLines = [...nextState.lines]; newLines[cursorRow] = cpSlice(lineContent, 0, cursorCol); return { ...nextState, lines: newLines }; } else if (cursorRow < lines.length - 1) { // Act as a delete - const nextState = pushUndo(state); + const nextState = pushUndoLocal(state); const nextLineContent = currentLine(cursorRow + 1); const newLines = [...nextState.lines]; newLines[cursorRow] = lineContent + nextLineContent; @@ -813,7 +1156,7 @@ export function textBufferReducer( case 'kill_line_left': { const { cursorRow, cursorCol } = state; if (cursorCol > 0) { - const nextState = pushUndo(state); + const nextState = pushUndoLocal(state); const lineContent = currentLine(cursorRow); const newLines = [...nextState.lines]; newLines[cursorRow] = cpSlice(lineContent, cursorCol); @@ -863,66 +1206,15 @@ export function textBufferReducer( case 'replace_range': { const { startRow, startCol, endRow, endCol, text } = action.payload; - if ( - startRow > endRow || - (startRow === endRow && startCol > endCol) || - startRow < 0 || - startCol < 0 || - endRow >= state.lines.length || - (endRow < state.lines.length && endCol > currentLineLen(endRow)) - ) { - return state; // Invalid range - } - - const nextState = pushUndo(state); - const newLines = [...nextState.lines]; - - const sCol = clamp(startCol, 0, currentLineLen(startRow)); - const eCol = clamp(endCol, 0, currentLineLen(endRow)); - - const prefix = cpSlice(currentLine(startRow), 0, sCol); - const suffix = cpSlice(currentLine(endRow), eCol); - - const normalisedReplacement = text - .replace(/\r\n/g, '\n') - .replace(/\r/g, '\n'); - const replacementParts = normalisedReplacement.split('\n'); - - // Replace the content - if (startRow === endRow) { - newLines[startRow] = prefix + normalisedReplacement + suffix; - } else { - const firstLine = prefix + replacementParts[0]; - if (replacementParts.length === 1) { - // Single line of replacement text, but spanning multiple original lines - newLines.splice(startRow, endRow - startRow + 1, firstLine + suffix); - } else { - // Multi-line replacement text - const lastLine = - replacementParts[replacementParts.length - 1] + suffix; - const middleLines = replacementParts.slice(1, -1); - newLines.splice( - startRow, - endRow - startRow + 1, - firstLine, - ...middleLines, - lastLine, - ); - } - } - - const finalCursorRow = startRow + replacementParts.length - 1; - const finalCursorCol = - (replacementParts.length > 1 ? 0 : sCol) + - cpLen(replacementParts[replacementParts.length - 1]); - - return { - ...nextState, - lines: newLines, - cursorRow: finalCursorRow, - cursorCol: finalCursorCol, - preferredCol: null, - }; + const nextState = pushUndoLocal(state); + return replaceRangeInternal( + nextState, + startRow, + startCol, + endRow, + endCol, + text, + ); } case 'move_to_offset': { @@ -940,9 +1232,44 @@ export function textBufferReducer( } case 'create_undo_snapshot': { - return pushUndo(state); + return pushUndoLocal(state); } + // Vim-specific operations + case 'vim_delete_word_forward': + case 'vim_delete_word_backward': + case 'vim_delete_word_end': + case 'vim_change_word_forward': + case 'vim_change_word_backward': + case 'vim_change_word_end': + case 'vim_delete_line': + case 'vim_change_line': + case 'vim_delete_to_end_of_line': + case 'vim_change_to_end_of_line': + case 'vim_change_movement': + case 'vim_move_left': + case 'vim_move_right': + case 'vim_move_up': + case 'vim_move_down': + case 'vim_move_word_forward': + case 'vim_move_word_backward': + case 'vim_move_word_end': + case 'vim_delete_char': + case 'vim_insert_at_cursor': + case 'vim_append_at_cursor': + case 'vim_open_line_below': + case 'vim_open_line_above': + case 'vim_append_at_line_end': + case 'vim_insert_at_line_start': + case 'vim_move_to_line_start': + case 'vim_move_to_line_end': + case 'vim_move_to_first_nonwhitespace': + case 'vim_move_to_first_line': + case 'vim_move_to_last_line': + case 'vim_move_to_line': + case 'vim_escape_insert_mode': + return handleVimAction(state, action as VimAction); + default: { const exhaustiveCheck: never = action; console.error(`Unknown action encountered: ${exhaustiveCheck}`); @@ -1110,6 +1437,139 @@ export function useTextBuffer({ dispatch({ type: 'kill_line_left' }); }, []); + // Vim-specific operations + const vimDeleteWordForward = useCallback((count: number): void => { + dispatch({ type: 'vim_delete_word_forward', payload: { count } }); + }, []); + + const vimDeleteWordBackward = useCallback((count: number): void => { + dispatch({ type: 'vim_delete_word_backward', payload: { count } }); + }, []); + + const vimDeleteWordEnd = useCallback((count: number): void => { + dispatch({ type: 'vim_delete_word_end', payload: { count } }); + }, []); + + const vimChangeWordForward = useCallback((count: number): void => { + dispatch({ type: 'vim_change_word_forward', payload: { count } }); + }, []); + + const vimChangeWordBackward = useCallback((count: number): void => { + dispatch({ type: 'vim_change_word_backward', payload: { count } }); + }, []); + + const vimChangeWordEnd = useCallback((count: number): void => { + dispatch({ type: 'vim_change_word_end', payload: { count } }); + }, []); + + const vimDeleteLine = useCallback((count: number): void => { + dispatch({ type: 'vim_delete_line', payload: { count } }); + }, []); + + const vimChangeLine = useCallback((count: number): void => { + dispatch({ type: 'vim_change_line', payload: { count } }); + }, []); + + const vimDeleteToEndOfLine = useCallback((): void => { + dispatch({ type: 'vim_delete_to_end_of_line' }); + }, []); + + const vimChangeToEndOfLine = useCallback((): void => { + dispatch({ type: 'vim_change_to_end_of_line' }); + }, []); + + const vimChangeMovement = useCallback( + (movement: 'h' | 'j' | 'k' | 'l', count: number): void => { + dispatch({ type: 'vim_change_movement', payload: { movement, count } }); + }, + [], + ); + + // New vim navigation and operation methods + const vimMoveLeft = useCallback((count: number): void => { + dispatch({ type: 'vim_move_left', payload: { count } }); + }, []); + + const vimMoveRight = useCallback((count: number): void => { + dispatch({ type: 'vim_move_right', payload: { count } }); + }, []); + + const vimMoveUp = useCallback((count: number): void => { + dispatch({ type: 'vim_move_up', payload: { count } }); + }, []); + + const vimMoveDown = useCallback((count: number): void => { + dispatch({ type: 'vim_move_down', payload: { count } }); + }, []); + + const vimMoveWordForward = useCallback((count: number): void => { + dispatch({ type: 'vim_move_word_forward', payload: { count } }); + }, []); + + const vimMoveWordBackward = useCallback((count: number): void => { + dispatch({ type: 'vim_move_word_backward', payload: { count } }); + }, []); + + const vimMoveWordEnd = useCallback((count: number): void => { + dispatch({ type: 'vim_move_word_end', payload: { count } }); + }, []); + + const vimDeleteChar = useCallback((count: number): void => { + dispatch({ type: 'vim_delete_char', payload: { count } }); + }, []); + + const vimInsertAtCursor = useCallback((): void => { + dispatch({ type: 'vim_insert_at_cursor' }); + }, []); + + const vimAppendAtCursor = useCallback((): void => { + dispatch({ type: 'vim_append_at_cursor' }); + }, []); + + const vimOpenLineBelow = useCallback((): void => { + dispatch({ type: 'vim_open_line_below' }); + }, []); + + const vimOpenLineAbove = useCallback((): void => { + dispatch({ type: 'vim_open_line_above' }); + }, []); + + const vimAppendAtLineEnd = useCallback((): void => { + dispatch({ type: 'vim_append_at_line_end' }); + }, []); + + const vimInsertAtLineStart = useCallback((): void => { + dispatch({ type: 'vim_insert_at_line_start' }); + }, []); + + const vimMoveToLineStart = useCallback((): void => { + dispatch({ type: 'vim_move_to_line_start' }); + }, []); + + const vimMoveToLineEnd = useCallback((): void => { + dispatch({ type: 'vim_move_to_line_end' }); + }, []); + + const vimMoveToFirstNonWhitespace = useCallback((): void => { + dispatch({ type: 'vim_move_to_first_nonwhitespace' }); + }, []); + + const vimMoveToFirstLine = useCallback((): void => { + dispatch({ type: 'vim_move_to_first_line' }); + }, []); + + const vimMoveToLastLine = useCallback((): void => { + dispatch({ type: 'vim_move_to_last_line' }); + }, []); + + const vimMoveToLine = useCallback((lineNumber: number): void => { + dispatch({ type: 'vim_move_to_line', payload: { lineNumber } }); + }, []); + + const vimEscapeInsertMode = useCallback((): void => { + dispatch({ type: 'vim_escape_insert_mode' }); + }, []); + const openInExternalEditor = useCallback( async (opts: { editor?: string } = {}): Promise => { const editor = @@ -1273,6 +1733,39 @@ export function useTextBuffer({ killLineLeft, handleInput, openInExternalEditor, + // Vim-specific operations + vimDeleteWordForward, + vimDeleteWordBackward, + vimDeleteWordEnd, + vimChangeWordForward, + vimChangeWordBackward, + vimChangeWordEnd, + vimDeleteLine, + vimChangeLine, + vimDeleteToEndOfLine, + vimChangeToEndOfLine, + vimChangeMovement, + vimMoveLeft, + vimMoveRight, + vimMoveUp, + vimMoveDown, + vimMoveWordForward, + vimMoveWordBackward, + vimMoveWordEnd, + vimDeleteChar, + vimInsertAtCursor, + vimAppendAtCursor, + vimOpenLineBelow, + vimOpenLineAbove, + vimAppendAtLineEnd, + vimInsertAtLineStart, + vimMoveToLineStart, + vimMoveToLineEnd, + vimMoveToFirstNonWhitespace, + vimMoveToFirstLine, + vimMoveToLastLine, + vimMoveToLine, + vimEscapeInsertMode, }; return returnValue; } @@ -1387,4 +1880,134 @@ export interface TextBuffer { replacementText: string, ) => void; moveToOffset(offset: number): void; + + // Vim-specific operations + /** + * Delete N words forward from cursor position (vim 'dw' command) + */ + vimDeleteWordForward: (count: number) => void; + /** + * Delete N words backward from cursor position (vim 'db' command) + */ + vimDeleteWordBackward: (count: number) => void; + /** + * Delete to end of N words from cursor position (vim 'de' command) + */ + vimDeleteWordEnd: (count: number) => void; + /** + * Change N words forward from cursor position (vim 'cw' command) + */ + vimChangeWordForward: (count: number) => void; + /** + * Change N words backward from cursor position (vim 'cb' command) + */ + vimChangeWordBackward: (count: number) => void; + /** + * Change to end of N words from cursor position (vim 'ce' command) + */ + vimChangeWordEnd: (count: number) => void; + /** + * Delete N lines from cursor position (vim 'dd' command) + */ + vimDeleteLine: (count: number) => void; + /** + * Change N lines from cursor position (vim 'cc' command) + */ + vimChangeLine: (count: number) => void; + /** + * Delete from cursor to end of line (vim 'D' command) + */ + vimDeleteToEndOfLine: () => void; + /** + * Change from cursor to end of line (vim 'C' command) + */ + vimChangeToEndOfLine: () => void; + /** + * Change movement operations (vim 'ch', 'cj', 'ck', 'cl' commands) + */ + vimChangeMovement: (movement: 'h' | 'j' | 'k' | 'l', count: number) => void; + /** + * Move cursor left N times (vim 'h' command) + */ + vimMoveLeft: (count: number) => void; + /** + * Move cursor right N times (vim 'l' command) + */ + vimMoveRight: (count: number) => void; + /** + * Move cursor up N times (vim 'k' command) + */ + vimMoveUp: (count: number) => void; + /** + * Move cursor down N times (vim 'j' command) + */ + vimMoveDown: (count: number) => void; + /** + * Move cursor forward N words (vim 'w' command) + */ + vimMoveWordForward: (count: number) => void; + /** + * Move cursor backward N words (vim 'b' command) + */ + vimMoveWordBackward: (count: number) => void; + /** + * Move cursor to end of Nth word (vim 'e' command) + */ + vimMoveWordEnd: (count: number) => void; + /** + * Delete N characters at cursor (vim 'x' command) + */ + vimDeleteChar: (count: number) => void; + /** + * Enter insert mode at cursor (vim 'i' command) + */ + vimInsertAtCursor: () => void; + /** + * Enter insert mode after cursor (vim 'a' command) + */ + vimAppendAtCursor: () => void; + /** + * Open new line below and enter insert mode (vim 'o' command) + */ + vimOpenLineBelow: () => void; + /** + * Open new line above and enter insert mode (vim 'O' command) + */ + vimOpenLineAbove: () => void; + /** + * Move to end of line and enter insert mode (vim 'A' command) + */ + vimAppendAtLineEnd: () => void; + /** + * Move to first non-whitespace and enter insert mode (vim 'I' command) + */ + vimInsertAtLineStart: () => void; + /** + * Move cursor to beginning of line (vim '0' command) + */ + vimMoveToLineStart: () => void; + /** + * Move cursor to end of line (vim '$' command) + */ + vimMoveToLineEnd: () => void; + /** + * Move cursor to first non-whitespace character (vim '^' command) + */ + vimMoveToFirstNonWhitespace: () => void; + /** + * Move cursor to first line (vim 'gg' command) + */ + vimMoveToFirstLine: () => void; + /** + * Move cursor to last line (vim 'G' command) + */ + vimMoveToLastLine: () => void; + /** + * Move cursor to specific line number (vim '[N]G' command) + */ + vimMoveToLine: (lineNumber: number) => void; + /** + * Handle escape from insert mode (moves cursor left if not at line start) + */ + vimEscapeInsertMode: () => void; } -- cgit v1.2.3