diff --git a/docs/cli/commands.md b/docs/cli/commands.md index 10a10108..7f1ee85b 100644 --- a/docs/cli/commands.md +++ b/docs/cli/commands.md @@ -95,6 +95,17 @@ Slash commands provide meta-level control over the CLI itself. - **`/quit`** (or **`/exit`**) - **Description:** Exit Gemini CLI. +- **`/vim`** + - **Description:** Toggle vim mode on or off. When vim mode is enabled, the input area supports vim-style navigation and editing commands in both NORMAL and INSERT modes. + - **Features:** + - **NORMAL mode:** Navigate with `h`, `j`, `k`, `l`; jump by words with `w`, `b`, `e`; go to line start/end with `0`, `$`, `^`; go to specific lines with `G` (or `gg` for first line) + - **INSERT mode:** Standard text input with escape to return to NORMAL mode + - **Editing commands:** Delete with `x`, change with `c`, insert with `i`, `a`, `o`, `O`; complex operations like `dd`, `cc`, `dw`, `cw` + - **Count support:** Prefix commands with numbers (e.g., `3h`, `5w`, `10G`) + - **Repeat last command:** Use `.` to repeat the last editing operation + - **Persistent setting:** Vim mode preference is saved to `~/.gemini/settings.json` and restored between sessions + - **Status indicator:** When enabled, shows `[NORMAL]` or `[INSERT]` in the footer + ### Custom Commands For a quick start, see the [example](#example-a-pure-function-refactoring-command) below. diff --git a/docs/cli/configuration.md b/docs/cli/configuration.md index ad0a0df0..1a8f9a8c 100644 --- a/docs/cli/configuration.md +++ b/docs/cli/configuration.md @@ -103,6 +103,11 @@ In addition to a project settings file, a project's `.gemini` directory can cont - **Default:** `"Default"` - **Example:** `"theme": "GitHub"` +- **`vimMode`** (boolean): + - **Description:** Enables or disables vim mode for input editing. When enabled, the input area supports vim-style navigation and editing commands with NORMAL and INSERT modes. The vim mode status is displayed in the footer and persists between sessions. + - **Default:** `false` + - **Example:** `"vimMode": true` + - **`sandbox`** (boolean or string): - **Description:** Controls whether and how to use sandboxing for tool execution. If set to `true`, Gemini CLI uses a pre-built `gemini-cli-sandbox` Docker image. For more information, see [Sandboxing](#sandboxing). - **Default:** `false` diff --git a/package-lock.json b/package-lock.json index fd296927..7f315c9b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -799,9 +799,9 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.21.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", - "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", + "version": "0.20.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.1.tgz", + "integrity": "sha512-OL0RJzC/CBzli0DrrR31qzj6d6i6Mm3HByuhflhl4LOBiWxN+3i6/t/ZQQNii4tjksXi8r2CRW1wMpWA2ULUEw==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -814,9 +814,9 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.0.tgz", - "integrity": "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw==", + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.3.tgz", + "integrity": "sha512-u180qk2Um1le4yf0ruXH3PYFeEZeYC3p/4wCTKrr2U1CmGdzGi3KtY0nuPDH48UJxlKCC5RDzbcbh4X0XlqgHg==", "dev": true, "license": "Apache-2.0", "engines": { @@ -824,9 +824,9 @@ } }, "node_modules/@eslint/core": { - "version": "0.15.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", - "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", + "version": "0.14.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz", + "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -874,9 +874,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.31.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.31.0.tgz", - "integrity": "sha512-LOm5OVt7D4qiKCqoiPbA7LWmI+tbw1VbTUowBcUMgQSuM6poJufkFkYDcQpo5KfgD39TnNySV26QjOh7VFpSyw==", + "version": "9.29.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.29.0.tgz", + "integrity": "sha512-3PIF4cBw/y+1u2EazflInpV+lYsSG0aByVIQzAgb1m1MhHFSbqTyNqtBKHgWf/9Ykud+DhILS9EGkmekVhbKoQ==", "dev": true, "license": "MIT", "engines": { @@ -910,6 +910,19 @@ "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, + "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.1.tgz", + "integrity": "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/@google/gemini-cli": { "resolved": "packages/cli", "link": true @@ -4770,19 +4783,19 @@ } }, "node_modules/eslint": { - "version": "9.31.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.31.0.tgz", - "integrity": "sha512-QldCVh/ztyKJJZLr4jXNUByx3gR+TDYZCRXEktiZoUR3PGy4qCmSbkxcIle8GEwGpb5JBZazlaJ/CxLidXdEbQ==", + "version": "9.29.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.29.0.tgz", + "integrity": "sha512-GsGizj2Y1rCWDu6XoEekL3RLilp0voSePurjZIkxL3wlm5o5EC9VpgaP7lrCvjnkuLvzFBQWB3vWB3K5KQTveQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.21.0", - "@eslint/config-helpers": "^0.3.0", - "@eslint/core": "^0.15.0", + "@eslint/config-array": "^0.20.1", + "@eslint/config-helpers": "^0.2.1", + "@eslint/core": "^0.14.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.31.0", + "@eslint/js": "9.29.0", "@eslint/plugin-kit": "^0.3.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", @@ -11705,8 +11718,6 @@ }, "packages/cli/node_modules/@testing-library/dom": { "version": "10.4.0", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz", - "integrity": "sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==", "dev": true, "license": "MIT", "peer": true, @@ -11742,8 +11753,6 @@ }, "packages/cli/node_modules/@testing-library/react": { "version": "16.3.0", - "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz", - "integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==", "dev": true, "license": "MIT", "dependencies": { @@ -11795,8 +11804,6 @@ }, "packages/cli/node_modules/aria-query": { "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", "dev": true, "license": "Apache-2.0", "peer": true, @@ -11806,8 +11813,6 @@ }, "packages/cli/node_modules/emoji-regex": { "version": "10.4.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.4.0.tgz", - "integrity": "sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==", "license": "MIT" }, "packages/cli/node_modules/react-is": { @@ -11820,8 +11825,6 @@ }, "packages/cli/node_modules/string-width": { "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", "license": "MIT", "dependencies": { "emoji-regex": "^10.3.0", @@ -11918,8 +11921,6 @@ }, "packages/core/node_modules/ignore": { "version": "7.0.5", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", - "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "license": "MIT", "engines": { "node": ">= 4" diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts index c8885d48..c353d0c1 100644 --- a/packages/cli/src/config/settings.ts +++ b/packages/cli/src/config/settings.ts @@ -86,7 +86,6 @@ export interface Settings { enableRecursiveFileSearch?: boolean; }; - // UI setting. Does not display the ANSI-controlled terminal title. hideWindowTitle?: boolean; hideTips?: boolean; @@ -98,6 +97,8 @@ export interface Settings { // A map of tool names to their summarization settings. summarizeToolOutput?: Record; + vimMode?: boolean; + // Add other settings here. ideMode?: boolean; memoryDiscoveryMaxDirs?: number; diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index 58adf5cb..7ba0d6bb 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -28,6 +28,7 @@ import { restoreCommand } from '../ui/commands/restoreCommand.js'; import { statsCommand } from '../ui/commands/statsCommand.js'; import { themeCommand } from '../ui/commands/themeCommand.js'; import { toolsCommand } from '../ui/commands/toolsCommand.js'; +import { vimCommand } from '../ui/commands/vimCommand.js'; /** * Loads the core, hard-coded slash commands that are an integral part @@ -66,6 +67,7 @@ export class BuiltinCommandLoader implements ICommandLoader { statsCommand, themeCommand, toolsCommand, + vimCommand, ]; return allDefinitions.filter((cmd): cmd is SlashCommand => cmd !== null); diff --git a/packages/cli/src/ui/App.tsx b/packages/cli/src/ui/App.tsx index bd99f01b..da01521b 100644 --- a/packages/cli/src/ui/App.tsx +++ b/packages/cli/src/ui/App.tsx @@ -73,6 +73,8 @@ import { useGitBranchName } from './hooks/useGitBranchName.js'; import { useFocus } from './hooks/useFocus.js'; import { useBracketedPaste } from './hooks/useBracketedPaste.js'; import { useTextBuffer } from './components/shared/text-buffer.js'; +import { useVimMode, VimModeProvider } from './contexts/VimModeContext.js'; +import { useVim } from './hooks/vim.js'; import * as fs from 'fs'; import { UpdateNotification } from './components/UpdateNotification.js'; import { @@ -97,7 +99,9 @@ interface AppProps { export const AppWrapper = (props: AppProps) => ( - + + + ); @@ -374,6 +378,49 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { config.setFlashFallbackHandler(flashFallbackHandler); }, [config, addItem, userTier]); + // Terminal and UI setup + const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize(); + const { stdin, setRawMode } = useStdin(); + const isInitialMount = useRef(true); + + const widthFraction = 0.9; + const inputWidth = Math.max( + 20, + Math.floor(terminalWidth * widthFraction) - 3, + ); + const suggestionsWidth = Math.max(60, Math.floor(terminalWidth * 0.8)); + + // Utility callbacks + const isValidPath = useCallback((filePath: string): boolean => { + try { + return fs.existsSync(filePath) && fs.statSync(filePath).isFile(); + } catch (_e) { + return false; + } + }, []); + + const getPreferredEditor = useCallback(() => { + const editorType = settings.merged.preferredEditor; + const isValidEditor = isEditorAvailable(editorType); + if (!isValidEditor) { + openEditorDialog(); + return; + } + return editorType as EditorType; + }, [settings, openEditorDialog]); + + const onAuthError = useCallback(() => { + setAuthError('reauth required'); + openAuthDialog(); + }, [openAuthDialog, setAuthError]); + + // Core hooks and processors + const { + vimEnabled: vimModeEnabled, + vimMode, + toggleVimEnabled, + } = useVimMode(); + const { handleSlashCommand, slashCommands, @@ -394,26 +441,41 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { toggleCorgiMode, setQuittingMessages, openPrivacyNotice, + toggleVimEnabled, ); - const pendingHistoryItems = [...pendingSlashCommandHistoryItems]; - const { rows: terminalHeight, columns: terminalWidth } = useTerminalSize(); - const isInitialMount = useRef(true); - const { stdin, setRawMode } = useStdin(); - const isValidPath = useCallback((filePath: string): boolean => { - try { - return fs.existsSync(filePath) && fs.statSync(filePath).isFile(); - } catch (_e) { - return false; - } - }, []); - - const widthFraction = 0.9; - const inputWidth = Math.max( - 20, - Math.floor(terminalWidth * widthFraction) - 3, + const { + streamingState, + submitQuery, + initError, + pendingHistoryItems: pendingGeminiHistoryItems, + thought, + } = useGeminiStream( + config.getGeminiClient(), + history, + addItem, + setShowHelp, + config, + setDebugMessage, + handleSlashCommand, + shellModeActive, + getPreferredEditor, + onAuthError, + performMemoryRefresh, + modelSwitchedFromQuotaError, + setModelSwitchedFromQuotaError, + ); + + // Input handling + const handleFinalSubmit = useCallback( + (submittedValue: string) => { + const trimmedValue = submittedValue.trim(); + if (trimmedValue.length > 0) { + submitQuery(trimmedValue); + } + }, + [submitQuery], ); - const suggestionsWidth = Math.max(60, Math.floor(terminalWidth * 0.8)); const buffer = useTextBuffer({ initialText: '', @@ -424,6 +486,14 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { shellModeActive, }); + const { handleInput: vimHandleInput } = useVim(buffer, handleFinalSubmit); + const pendingHistoryItems = [...pendingSlashCommandHistoryItems]; + pendingHistoryItems.push(...pendingGeminiHistoryItems); + + const { elapsedTime, currentLoadingPhrase } = + useLoadingIndicator(streamingState); + const showAutoAcceptIndicator = useAutoAcceptIndicator({ config }); + const handleExit = useCallback( ( pressedOnce: boolean, @@ -489,57 +559,6 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { } }, [config]); - const getPreferredEditor = useCallback(() => { - const editorType = settings.merged.preferredEditor; - const isValidEditor = isEditorAvailable(editorType); - if (!isValidEditor) { - openEditorDialog(); - return; - } - return editorType as EditorType; - }, [settings, openEditorDialog]); - - const onAuthError = useCallback(() => { - setAuthError('reauth required'); - openAuthDialog(); - }, [openAuthDialog, setAuthError]); - - const { - streamingState, - submitQuery, - initError, - pendingHistoryItems: pendingGeminiHistoryItems, - thought, - } = useGeminiStream( - config.getGeminiClient(), - history, - addItem, - setShowHelp, - config, - setDebugMessage, - handleSlashCommand, - shellModeActive, - getPreferredEditor, - onAuthError, - performMemoryRefresh, - modelSwitchedFromQuotaError, - setModelSwitchedFromQuotaError, - ); - pendingHistoryItems.push(...pendingGeminiHistoryItems); - const { elapsedTime, currentLoadingPhrase } = - useLoadingIndicator(streamingState); - const showAutoAcceptIndicator = useAutoAcceptIndicator({ config }); - - const handleFinalSubmit = useCallback( - (submittedValue: string) => { - const trimmedValue = submittedValue.trim(); - if (trimmedValue.length > 0) { - submitQuery(trimmedValue); - } - }, - [submitQuery], - ); - const logger = useLogger(); const [userMessages, setUserMessages] = useState([]); @@ -697,6 +716,10 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { // Arbitrary threshold to ensure that items in the static area are large // enough but not too large to make the terminal hard to use. const staticAreaMaxItemHeight = Math.max(terminalHeight * 4, 100); + const placeholder = vimModeEnabled + ? " Press 'i' for INSERT mode and 'Esc' for NORMAL mode." + : ' Type your message or @path/to/file'; + return ( @@ -938,6 +961,8 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { shellModeActive={shellModeActive} setShellModeActive={setShellModeActive} focus={isFocused} + vimHandleInput={vimHandleInput} + placeholder={placeholder} /> )} @@ -989,6 +1014,7 @@ const App = ({ config, settings, startupWarnings = [], version }: AppProps) => { } promptTokenCount={sessionStats.lastPromptTokenCount} nightly={nightly} + vimMode={vimModeEnabled ? vimMode : undefined} /> diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 1684677c..59b0178c 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -58,6 +58,7 @@ export interface CommandContext { loadHistory: UseHistoryManagerReturn['loadHistory']; /** Toggles a special display mode. */ toggleCorgiMode: () => void; + toggleVimEnabled: () => Promise; }; // Session-specific data session: { diff --git a/packages/cli/src/ui/commands/vimCommand.ts b/packages/cli/src/ui/commands/vimCommand.ts new file mode 100644 index 00000000..40e658df --- /dev/null +++ b/packages/cli/src/ui/commands/vimCommand.ts @@ -0,0 +1,25 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CommandKind, SlashCommand } from './types.js'; + +export const vimCommand: SlashCommand = { + name: 'vim', + description: 'toggle vim mode on/off', + kind: CommandKind.BUILT_IN, + action: async (context, _args) => { + const newVimState = await context.ui.toggleVimEnabled(); + + const message = newVimState + ? 'Entered Vim mode. Run /vim again to exit.' + : 'Exited Vim mode.'; + return { + type: 'message', + messageType: 'info', + content: message, + }; + }, +}; diff --git a/packages/cli/src/ui/components/Footer.tsx b/packages/cli/src/ui/components/Footer.tsx index 3b54a989..5b9e3af7 100644 --- a/packages/cli/src/ui/components/Footer.tsx +++ b/packages/cli/src/ui/components/Footer.tsx @@ -25,6 +25,7 @@ interface FooterProps { showMemoryUsage?: boolean; promptTokenCount: number; nightly: boolean; + vimMode?: string; } export const Footer: React.FC = ({ @@ -39,6 +40,7 @@ export const Footer: React.FC = ({ showMemoryUsage, promptTokenCount, nightly, + vimMode, }) => { const limit = tokenLimit(model); const percentage = promptTokenCount / limit; @@ -46,6 +48,7 @@ export const Footer: React.FC = ({ return ( + {vimMode && [{vimMode}] } {nightly ? ( diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index a1894002..60ba648d 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -171,6 +171,7 @@ describe('InputPrompt', () => { config: { getProjectRoot: () => path.join('test', 'project'), getTargetDir: () => path.join('test', 'project', 'src'), + getVimMode: () => false, } as unknown as Config, slashCommands: mockSlashCommands, commandContext: mockCommandContext, @@ -1076,4 +1077,48 @@ describe('InputPrompt', () => { unmount(); }); }); + + describe('vim mode', () => { + it('should not call buffer.handleInput when vim mode is enabled and vim handles the input', async () => { + props.vimModeEnabled = true; + props.vimHandleInput = vi.fn().mockReturnValue(true); // Mock that vim handled it. + const { stdin, unmount } = render(); + await wait(); + + stdin.write('i'); + await wait(); + + expect(props.vimHandleInput).toHaveBeenCalled(); + expect(mockBuffer.handleInput).not.toHaveBeenCalled(); + unmount(); + }); + + it('should call buffer.handleInput when vim mode is enabled but vim does not handle the input', async () => { + props.vimModeEnabled = true; + props.vimHandleInput = vi.fn().mockReturnValue(false); // Mock that vim did NOT handle it. + const { stdin, unmount } = render(); + await wait(); + + stdin.write('i'); + await wait(); + + expect(props.vimHandleInput).toHaveBeenCalled(); + expect(mockBuffer.handleInput).toHaveBeenCalled(); + unmount(); + }); + + it('should call handleInput when vim mode is disabled', async () => { + // Mock vimHandleInput to return false (vim didn't handle the input) + props.vimHandleInput = vi.fn().mockReturnValue(false); + const { stdin, unmount } = render(); + await wait(); + + stdin.write('i'); + await wait(); + + expect(props.vimHandleInput).toHaveBeenCalled(); + expect(mockBuffer.handleInput).toHaveBeenCalled(); + unmount(); + }); + }); }); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 8d296dc4..17b7694e 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -39,6 +39,7 @@ export interface InputPromptProps { suggestionsWidth: number; shellModeActive: boolean; setShellModeActive: (value: boolean) => void; + vimHandleInput?: (key: Key) => boolean; } export const InputPrompt: React.FC = ({ @@ -55,6 +56,7 @@ export const InputPrompt: React.FC = ({ suggestionsWidth, shellModeActive, setShellModeActive, + vimHandleInput, }) => { const [justNavigatedHistory, setJustNavigatedHistory] = useState(false); @@ -169,6 +171,10 @@ export const InputPrompt: React.FC = ({ return; } + if (vimHandleInput && vimHandleInput(key)) { + return; + } + if ( key.sequence === '!' && buffer.text === '' && @@ -347,6 +353,7 @@ export const InputPrompt: React.FC = ({ shellHistory, handleClipboardImage, resetCompletionState, + vimHandleInput, ], ); 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 4db1ce7b..807c33df 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.test.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.test.ts @@ -11,6 +11,7 @@ import { Viewport, TextBuffer, offsetToLogicalPos, + logicalPosToOffset, textBufferReducer, TextBufferState, TextBufferAction, @@ -1341,3 +1342,216 @@ describe('offsetToLogicalPos', () => { expect(offsetToLogicalPos(text, 2)).toEqual([0, 2]); // After 🐱 }); }); + +describe('logicalPosToOffset', () => { + it('should convert row/col position to offset correctly', () => { + const lines = ['hello', 'world', '123']; + + // Line 0: "hello" (5 chars) + expect(logicalPosToOffset(lines, 0, 0)).toBe(0); // Start of 'hello' + expect(logicalPosToOffset(lines, 0, 3)).toBe(3); // 'l' in 'hello' + expect(logicalPosToOffset(lines, 0, 5)).toBe(5); // End of 'hello' + + // Line 1: "world" (5 chars), offset starts at 6 (5 + 1 for newline) + expect(logicalPosToOffset(lines, 1, 0)).toBe(6); // Start of 'world' + expect(logicalPosToOffset(lines, 1, 2)).toBe(8); // 'r' in 'world' + expect(logicalPosToOffset(lines, 1, 5)).toBe(11); // End of 'world' + + // Line 2: "123" (3 chars), offset starts at 12 (5 + 1 + 5 + 1) + expect(logicalPosToOffset(lines, 2, 0)).toBe(12); // Start of '123' + expect(logicalPosToOffset(lines, 2, 1)).toBe(13); // '2' in '123' + expect(logicalPosToOffset(lines, 2, 3)).toBe(15); // End of '123' + }); + + it('should handle empty lines', () => { + const lines = ['a', '', 'c']; + + expect(logicalPosToOffset(lines, 0, 0)).toBe(0); // 'a' + expect(logicalPosToOffset(lines, 0, 1)).toBe(1); // End of 'a' + expect(logicalPosToOffset(lines, 1, 0)).toBe(2); // Empty line + expect(logicalPosToOffset(lines, 2, 0)).toBe(3); // 'c' + expect(logicalPosToOffset(lines, 2, 1)).toBe(4); // End of 'c' + }); + + it('should handle single empty line', () => { + const lines = ['']; + + expect(logicalPosToOffset(lines, 0, 0)).toBe(0); + }); + + it('should be inverse of offsetToLogicalPos', () => { + const lines = ['hello', 'world', '123']; + const text = lines.join('\n'); + + // Test round-trip conversion + for (let offset = 0; offset <= text.length; offset++) { + const [row, col] = offsetToLogicalPos(text, offset); + const convertedOffset = logicalPosToOffset(lines, row, col); + expect(convertedOffset).toBe(offset); + } + }); + + it('should handle out-of-bounds positions', () => { + const lines = ['hello']; + + // Beyond end of line + expect(logicalPosToOffset(lines, 0, 10)).toBe(5); // Clamps to end of line + + // Beyond array bounds - should clamp to the last line + expect(logicalPosToOffset(lines, 5, 0)).toBe(0); // Clamps to start of last line (row 0) + expect(logicalPosToOffset(lines, 5, 10)).toBe(5); // Clamps to end of last line + }); +}); + +describe('textBufferReducer vim operations', () => { + describe('vim_delete_line', () => { + it('should delete a single line including newline in multi-line text', () => { + const initialState: TextBufferState = { + lines: ['line1', 'line2', 'line3'], + cursorRow: 1, + cursorCol: 2, + preferredCol: null, + visualLines: [['line1'], ['line2'], ['line3']], + visualScrollRow: 0, + visualCursor: { row: 1, col: 2 }, + viewport: { width: 10, height: 5 }, + undoStack: [], + redoStack: [], + }; + + const action: TextBufferAction = { + type: 'vim_delete_line', + payload: { count: 1 }, + }; + + const result = textBufferReducer(initialState, action); + + // After deleting line2, we should have line1 and line3, with cursor on line3 (now at index 1) + expect(result.lines).toEqual(['line1', 'line3']); + expect(result.cursorRow).toBe(1); + expect(result.cursorCol).toBe(0); + }); + + it('should delete multiple lines when count > 1', () => { + const initialState: TextBufferState = { + lines: ['line1', 'line2', 'line3', 'line4'], + cursorRow: 1, + cursorCol: 0, + preferredCol: null, + visualLines: [['line1'], ['line2'], ['line3'], ['line4']], + visualScrollRow: 0, + visualCursor: { row: 1, col: 0 }, + viewport: { width: 10, height: 5 }, + undoStack: [], + redoStack: [], + }; + + const action: TextBufferAction = { + type: 'vim_delete_line', + payload: { count: 2 }, + }; + + const result = textBufferReducer(initialState, action); + + // Should delete line2 and line3, leaving line1 and line4 + expect(result.lines).toEqual(['line1', 'line4']); + expect(result.cursorRow).toBe(1); + expect(result.cursorCol).toBe(0); + }); + + it('should clear single line content when only one line exists', () => { + const initialState: TextBufferState = { + lines: ['only line'], + cursorRow: 0, + cursorCol: 5, + preferredCol: null, + visualLines: [['only line']], + visualScrollRow: 0, + visualCursor: { row: 0, col: 5 }, + viewport: { width: 10, height: 5 }, + undoStack: [], + redoStack: [], + }; + + const action: TextBufferAction = { + type: 'vim_delete_line', + payload: { count: 1 }, + }; + + const result = textBufferReducer(initialState, action); + + // Should clear the line content but keep the line + expect(result.lines).toEqual(['']); + expect(result.cursorRow).toBe(0); + expect(result.cursorCol).toBe(0); + }); + + it('should handle deleting the last line properly', () => { + const initialState: TextBufferState = { + lines: ['line1', 'line2'], + cursorRow: 1, + cursorCol: 0, + preferredCol: null, + visualLines: [['line1'], ['line2']], + visualScrollRow: 0, + visualCursor: { row: 1, col: 0 }, + viewport: { width: 10, height: 5 }, + undoStack: [], + redoStack: [], + }; + + const action: TextBufferAction = { + type: 'vim_delete_line', + payload: { count: 1 }, + }; + + const result = textBufferReducer(initialState, action); + + // Should delete the last line completely, not leave empty line + expect(result.lines).toEqual(['line1']); + expect(result.cursorRow).toBe(0); + expect(result.cursorCol).toBe(0); + }); + + it('should handle deleting all lines and maintain valid state for subsequent paste', () => { + const initialState: TextBufferState = { + lines: ['line1', 'line2', 'line3', 'line4'], + cursorRow: 0, + cursorCol: 0, + preferredCol: null, + visualLines: [['line1'], ['line2'], ['line3'], ['line4']], + visualScrollRow: 0, + visualCursor: { row: 0, col: 0 }, + viewport: { width: 10, height: 5 }, + undoStack: [], + redoStack: [], + }; + + // Delete all 4 lines with 4dd + const deleteAction: TextBufferAction = { + type: 'vim_delete_line', + payload: { count: 4 }, + }; + + const afterDelete = textBufferReducer(initialState, deleteAction); + + // After deleting all lines, should have one empty line + expect(afterDelete.lines).toEqual(['']); + expect(afterDelete.cursorRow).toBe(0); + expect(afterDelete.cursorCol).toBe(0); + + // Now paste multiline content - this should work correctly + const pasteAction: TextBufferAction = { + type: 'insert', + payload: 'new1\nnew2\nnew3\nnew4', + }; + + const afterPaste = textBufferReducer(afterDelete, pasteAction); + + // All lines including the first one should be present + expect(afterPaste.lines).toEqual(['new1', 'new2', 'new3', 'new4']); + expect(afterPaste.cursorRow).toBe(3); + expect(afterPaste.cursorCol).toBe(4); + }); + }); +}); 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; } 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 new file mode 100644 index 00000000..f268bb1e --- /dev/null +++ b/packages/cli/src/ui/components/shared/vim-buffer-actions.test.ts @@ -0,0 +1,796 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { handleVimAction } from './vim-buffer-actions.js'; +import type { TextBufferState } from './text-buffer.js'; + +// Helper to create test state +const createTestState = ( + lines: string[] = ['hello world'], + cursorRow = 0, + cursorCol = 0, +): TextBufferState => ({ + lines, + cursorRow, + cursorCol, + preferredCol: null, + undoStack: [], + redoStack: [], + clipboard: null, + selectionAnchor: null, + viewportWidth: 80, +}); + +describe('vim-buffer-actions', () => { + describe('Movement commands', () => { + describe('vim_move_left', () => { + it('should move cursor left by count', () => { + const state = createTestState(['hello world'], 0, 5); + const action = { + type: 'vim_move_left' as const, + payload: { count: 3 }, + }; + + const result = handleVimAction(state, action); + + expect(result.cursorCol).toBe(2); + expect(result.preferredCol).toBeNull(); + }); + + it('should not move past beginning of line', () => { + const state = createTestState(['hello'], 0, 2); + const action = { + type: 'vim_move_left' as const, + payload: { count: 5 }, + }; + + const result = handleVimAction(state, action); + + expect(result.cursorCol).toBe(0); + }); + + it('should wrap to previous line when at beginning', () => { + const state = createTestState(['line1', 'line2'], 1, 0); + const action = { + type: 'vim_move_left' as const, + payload: { count: 1 }, + }; + + const result = handleVimAction(state, action); + + expect(result.cursorRow).toBe(0); + expect(result.cursorCol).toBe(4); // On last character '1' of 'line1' + }); + + it('should handle multiple line wrapping', () => { + const state = createTestState(['abc', 'def', 'ghi'], 2, 0); + const action = { + type: 'vim_move_left' as const, + payload: { count: 5 }, + }; + + const result = handleVimAction(state, action); + + expect(result.cursorRow).toBe(0); + expect(result.cursorCol).toBe(1); // On 'b' after 5 left movements + }); + + it('should correctly handle h/l movement between lines', () => { + // Start at end of first line at 'd' (position 10) + let state = createTestState(['hello world', 'foo bar'], 0, 10); + + // Move right - should go to beginning of next line + state = handleVimAction(state, { + type: 'vim_move_right' as const, + payload: { count: 1 }, + }); + expect(state.cursorRow).toBe(1); + expect(state.cursorCol).toBe(0); // Should be on 'f' + + // Move left - should go back to end of previous line on 'd' + state = handleVimAction(state, { + type: 'vim_move_left' as const, + payload: { count: 1 }, + }); + expect(state.cursorRow).toBe(0); + expect(state.cursorCol).toBe(10); // Should be on 'd', not past it + }); + }); + + describe('vim_move_right', () => { + it('should move cursor right by count', () => { + const state = createTestState(['hello world'], 0, 2); + const action = { + type: 'vim_move_right' as const, + payload: { count: 3 }, + }; + + const result = handleVimAction(state, action); + + expect(result.cursorCol).toBe(5); + }); + + it('should not move past last character of line', () => { + const state = createTestState(['hello'], 0, 3); + const action = { + type: 'vim_move_right' as const, + payload: { count: 5 }, + }; + + const result = handleVimAction(state, action); + + expect(result.cursorCol).toBe(4); // Last character of 'hello' + }); + + it('should wrap to next line when at end', () => { + const state = createTestState(['line1', 'line2'], 0, 4); // At end of 'line1' + const action = { + type: 'vim_move_right' as const, + payload: { count: 1 }, + }; + + const result = handleVimAction(state, action); + + expect(result.cursorRow).toBe(1); + expect(result.cursorCol).toBe(0); + }); + }); + + describe('vim_move_up', () => { + it('should move cursor up by count', () => { + const state = createTestState(['line1', 'line2', 'line3'], 2, 3); + const action = { type: 'vim_move_up' as const, payload: { count: 2 } }; + + const result = handleVimAction(state, action); + + expect(result.cursorRow).toBe(0); + expect(result.cursorCol).toBe(3); + }); + + it('should not move past first line', () => { + const state = createTestState(['line1', 'line2'], 1, 3); + const action = { type: 'vim_move_up' as const, payload: { count: 5 } }; + + const result = handleVimAction(state, action); + + expect(result.cursorRow).toBe(0); + }); + + it('should adjust column for shorter lines', () => { + const state = createTestState(['short', 'very long line'], 1, 10); + const action = { type: 'vim_move_up' as const, payload: { count: 1 } }; + + const result = handleVimAction(state, action); + + expect(result.cursorRow).toBe(0); + expect(result.cursorCol).toBe(5); // End of 'short' + }); + }); + + describe('vim_move_down', () => { + it('should move cursor down by count', () => { + const state = createTestState(['line1', 'line2', 'line3'], 0, 2); + const action = { + type: 'vim_move_down' as const, + payload: { count: 2 }, + }; + + const result = handleVimAction(state, action); + + expect(result.cursorRow).toBe(2); + expect(result.cursorCol).toBe(2); + }); + + it('should not move past last line', () => { + const state = createTestState(['line1', 'line2'], 0, 2); + const action = { + type: 'vim_move_down' as const, + payload: { count: 5 }, + }; + + const result = handleVimAction(state, action); + + expect(result.cursorRow).toBe(1); + }); + }); + + describe('vim_move_word_forward', () => { + it('should move to start of next word', () => { + const state = createTestState(['hello world test'], 0, 0); + const action = { + type: 'vim_move_word_forward' as const, + payload: { count: 1 }, + }; + + const result = handleVimAction(state, action); + + expect(result.cursorCol).toBe(6); // Start of 'world' + }); + + it('should handle multiple words', () => { + const state = createTestState(['hello world test'], 0, 0); + const action = { + type: 'vim_move_word_forward' as const, + payload: { count: 2 }, + }; + + const result = handleVimAction(state, action); + + expect(result.cursorCol).toBe(12); // Start of 'test' + }); + + it('should handle punctuation correctly', () => { + const state = createTestState(['hello, world!'], 0, 0); + const action = { + type: 'vim_move_word_forward' as const, + payload: { count: 1 }, + }; + + const result = handleVimAction(state, action); + + expect(result.cursorCol).toBe(5); // Start of ',' + }); + }); + + describe('vim_move_word_backward', () => { + it('should move to start of previous word', () => { + const state = createTestState(['hello world test'], 0, 12); + const action = { + type: 'vim_move_word_backward' as const, + payload: { count: 1 }, + }; + + const result = handleVimAction(state, action); + + expect(result.cursorCol).toBe(6); // Start of 'world' + }); + + it('should handle multiple words', () => { + const state = createTestState(['hello world test'], 0, 12); + const action = { + type: 'vim_move_word_backward' as const, + payload: { count: 2 }, + }; + + const result = handleVimAction(state, action); + + expect(result.cursorCol).toBe(0); // Start of 'hello' + }); + }); + + describe('vim_move_word_end', () => { + it('should move to end of current word', () => { + const state = createTestState(['hello world'], 0, 0); + const action = { + type: 'vim_move_word_end' as const, + payload: { count: 1 }, + }; + + const result = handleVimAction(state, action); + + expect(result.cursorCol).toBe(4); // End of 'hello' + }); + + it('should move to end of next word if already at word end', () => { + const state = createTestState(['hello world'], 0, 4); + const action = { + type: 'vim_move_word_end' as const, + payload: { count: 1 }, + }; + + const result = handleVimAction(state, action); + + expect(result.cursorCol).toBe(10); // End of 'world' + }); + }); + + describe('Position commands', () => { + it('vim_move_to_line_start should move to column 0', () => { + const state = createTestState(['hello world'], 0, 5); + const action = { type: 'vim_move_to_line_start' as const }; + + const result = handleVimAction(state, action); + + expect(result.cursorCol).toBe(0); + }); + + it('vim_move_to_line_end should move to last character', () => { + const state = createTestState(['hello world'], 0, 0); + const action = { type: 'vim_move_to_line_end' as const }; + + const result = handleVimAction(state, action); + + expect(result.cursorCol).toBe(10); // Last character of 'hello world' + }); + + it('vim_move_to_first_nonwhitespace should skip leading whitespace', () => { + const state = createTestState([' hello world'], 0, 0); + const action = { type: 'vim_move_to_first_nonwhitespace' as const }; + + const result = handleVimAction(state, action); + + expect(result.cursorCol).toBe(3); // Position of 'h' + }); + + it('vim_move_to_first_line should move to row 0', () => { + const state = createTestState(['line1', 'line2', 'line3'], 2, 5); + const action = { type: 'vim_move_to_first_line' as const }; + + const result = handleVimAction(state, action); + + expect(result.cursorRow).toBe(0); + expect(result.cursorCol).toBe(0); + }); + + it('vim_move_to_last_line should move to last row', () => { + const state = createTestState(['line1', 'line2', 'line3'], 0, 5); + const action = { type: 'vim_move_to_last_line' as const }; + + const result = handleVimAction(state, action); + + expect(result.cursorRow).toBe(2); + expect(result.cursorCol).toBe(0); + }); + + it('vim_move_to_line should move to specific line', () => { + const state = createTestState(['line1', 'line2', 'line3'], 0, 5); + const action = { + type: 'vim_move_to_line' as const, + payload: { lineNumber: 2 }, + }; + + const result = handleVimAction(state, action); + + expect(result.cursorRow).toBe(1); // 0-indexed + expect(result.cursorCol).toBe(0); + }); + + it('vim_move_to_line should clamp to valid range', () => { + const state = createTestState(['line1', 'line2'], 0, 0); + const action = { + type: 'vim_move_to_line' as const, + payload: { lineNumber: 10 }, + }; + + const result = handleVimAction(state, action); + + expect(result.cursorRow).toBe(1); // Last line + }); + }); + }); + + describe('Edit commands', () => { + describe('vim_delete_char', () => { + it('should delete single character', () => { + const state = createTestState(['hello'], 0, 1); + const action = { + type: 'vim_delete_char' as const, + payload: { count: 1 }, + }; + + const result = handleVimAction(state, action); + + expect(result.lines[0]).toBe('hllo'); + expect(result.cursorCol).toBe(1); + }); + + it('should delete multiple characters', () => { + const state = createTestState(['hello'], 0, 1); + const action = { + type: 'vim_delete_char' as const, + payload: { count: 3 }, + }; + + const result = handleVimAction(state, action); + + expect(result.lines[0]).toBe('ho'); + expect(result.cursorCol).toBe(1); + }); + + it('should not delete past end of line', () => { + const state = createTestState(['hello'], 0, 3); + const action = { + type: 'vim_delete_char' as const, + payload: { count: 5 }, + }; + + const result = handleVimAction(state, action); + + expect(result.lines[0]).toBe('hel'); + expect(result.cursorCol).toBe(3); + }); + + it('should do nothing at end of line', () => { + const state = createTestState(['hello'], 0, 5); + const action = { + type: 'vim_delete_char' as const, + payload: { count: 1 }, + }; + + const result = handleVimAction(state, action); + + expect(result.lines[0]).toBe('hello'); + expect(result.cursorCol).toBe(5); + }); + }); + + describe('vim_delete_word_forward', () => { + it('should delete from cursor to next word start', () => { + const state = createTestState(['hello world test'], 0, 0); + const action = { + type: 'vim_delete_word_forward' as const, + payload: { count: 1 }, + }; + + const result = handleVimAction(state, action); + + expect(result.lines[0]).toBe('world test'); + expect(result.cursorCol).toBe(0); + }); + + it('should delete multiple words', () => { + const state = createTestState(['hello world test'], 0, 0); + const action = { + type: 'vim_delete_word_forward' as const, + payload: { count: 2 }, + }; + + const result = handleVimAction(state, action); + + expect(result.lines[0]).toBe('test'); + expect(result.cursorCol).toBe(0); + }); + + it('should delete to end if no more words', () => { + const state = createTestState(['hello world'], 0, 6); + const action = { + type: 'vim_delete_word_forward' as const, + payload: { count: 2 }, + }; + + const result = handleVimAction(state, action); + + expect(result.lines[0]).toBe('hello '); + expect(result.cursorCol).toBe(6); + }); + }); + + describe('vim_delete_word_backward', () => { + it('should delete from cursor to previous word start', () => { + const state = createTestState(['hello world test'], 0, 12); + const action = { + type: 'vim_delete_word_backward' as const, + payload: { count: 1 }, + }; + + const result = handleVimAction(state, action); + + expect(result.lines[0]).toBe('hello test'); + expect(result.cursorCol).toBe(6); + }); + + it('should delete multiple words backward', () => { + const state = createTestState(['hello world test'], 0, 12); + const action = { + type: 'vim_delete_word_backward' as const, + payload: { count: 2 }, + }; + + const result = handleVimAction(state, action); + + expect(result.lines[0]).toBe('test'); + expect(result.cursorCol).toBe(0); + }); + }); + + describe('vim_delete_line', () => { + it('should delete current line', () => { + const state = createTestState(['line1', 'line2', 'line3'], 1, 2); + const action = { + type: 'vim_delete_line' as const, + payload: { count: 1 }, + }; + + const result = handleVimAction(state, action); + + expect(result.lines).toEqual(['line1', 'line3']); + expect(result.cursorRow).toBe(1); + expect(result.cursorCol).toBe(0); + }); + + it('should delete multiple lines', () => { + const state = createTestState(['line1', 'line2', 'line3'], 0, 2); + const action = { + type: 'vim_delete_line' as const, + payload: { count: 2 }, + }; + + const result = handleVimAction(state, action); + + expect(result.lines).toEqual(['line3']); + expect(result.cursorRow).toBe(0); + expect(result.cursorCol).toBe(0); + }); + + it('should leave empty line when deleting all lines', () => { + const state = createTestState(['only line'], 0, 0); + const action = { + type: 'vim_delete_line' as const, + payload: { count: 1 }, + }; + + const result = handleVimAction(state, action); + + expect(result.lines).toEqual(['']); + expect(result.cursorRow).toBe(0); + expect(result.cursorCol).toBe(0); + }); + }); + + describe('vim_delete_to_end_of_line', () => { + it('should delete from cursor to end of line', () => { + const state = createTestState(['hello world'], 0, 5); + const action = { type: 'vim_delete_to_end_of_line' as const }; + + const result = handleVimAction(state, action); + + expect(result.lines[0]).toBe('hello'); + expect(result.cursorCol).toBe(5); + }); + + it('should do nothing at end of line', () => { + const state = createTestState(['hello'], 0, 5); + const action = { type: 'vim_delete_to_end_of_line' as const }; + + const result = handleVimAction(state, action); + + expect(result.lines[0]).toBe('hello'); + }); + }); + }); + + describe('Insert mode commands', () => { + describe('vim_insert_at_cursor', () => { + it('should not change cursor position', () => { + const state = createTestState(['hello'], 0, 2); + const action = { type: 'vim_insert_at_cursor' as const }; + + const result = handleVimAction(state, action); + + expect(result.cursorRow).toBe(0); + expect(result.cursorCol).toBe(2); + }); + }); + + describe('vim_append_at_cursor', () => { + it('should move cursor right by one', () => { + const state = createTestState(['hello'], 0, 2); + const action = { type: 'vim_append_at_cursor' as const }; + + const result = handleVimAction(state, action); + + expect(result.cursorCol).toBe(3); + }); + + it('should not move past end of line', () => { + const state = createTestState(['hello'], 0, 5); + const action = { type: 'vim_append_at_cursor' as const }; + + const result = handleVimAction(state, action); + + expect(result.cursorCol).toBe(5); + }); + }); + + describe('vim_append_at_line_end', () => { + it('should move cursor to end of line', () => { + const state = createTestState(['hello world'], 0, 3); + const action = { type: 'vim_append_at_line_end' as const }; + + const result = handleVimAction(state, action); + + expect(result.cursorCol).toBe(11); + }); + }); + + describe('vim_insert_at_line_start', () => { + it('should move to first non-whitespace character', () => { + const state = createTestState([' hello world'], 0, 5); + const action = { type: 'vim_insert_at_line_start' as const }; + + const result = handleVimAction(state, action); + + expect(result.cursorCol).toBe(2); + }); + + it('should move to column 0 for line with only whitespace', () => { + const state = createTestState([' '], 0, 1); + const action = { type: 'vim_insert_at_line_start' as const }; + + const result = handleVimAction(state, action); + + expect(result.cursorCol).toBe(3); + }); + }); + + describe('vim_open_line_below', () => { + it('should insert newline at end of current line', () => { + const state = createTestState(['hello world'], 0, 5); + const action = { type: 'vim_open_line_below' as const }; + + const result = handleVimAction(state, action); + + // The implementation inserts newline at end of current line and cursor moves to column 0 + expect(result.lines[0]).toBe('hello world\n'); + expect(result.cursorRow).toBe(0); + expect(result.cursorCol).toBe(0); // Cursor position after replaceRangeInternal + }); + }); + + describe('vim_open_line_above', () => { + it('should insert newline before current line', () => { + const state = createTestState(['hello', 'world'], 1, 2); + const action = { type: 'vim_open_line_above' as const }; + + const result = handleVimAction(state, action); + + // The implementation inserts newline at beginning of current line + expect(result.lines).toEqual(['hello', '\nworld']); + expect(result.cursorRow).toBe(1); + expect(result.cursorCol).toBe(0); + }); + }); + + describe('vim_escape_insert_mode', () => { + it('should move cursor left', () => { + const state = createTestState(['hello'], 0, 3); + const action = { type: 'vim_escape_insert_mode' as const }; + + const result = handleVimAction(state, action); + + expect(result.cursorCol).toBe(2); + }); + + it('should not move past beginning of line', () => { + const state = createTestState(['hello'], 0, 0); + const action = { type: 'vim_escape_insert_mode' as const }; + + const result = handleVimAction(state, action); + + expect(result.cursorCol).toBe(0); + }); + }); + }); + + describe('Change commands', () => { + describe('vim_change_word_forward', () => { + it('should delete from cursor to next word start', () => { + const state = createTestState(['hello world test'], 0, 0); + const action = { + type: 'vim_change_word_forward' as const, + payload: { count: 1 }, + }; + + const result = handleVimAction(state, action); + + expect(result.lines[0]).toBe('world test'); + expect(result.cursorCol).toBe(0); + }); + }); + + describe('vim_change_line', () => { + it('should delete entire line content', () => { + const state = createTestState(['hello world'], 0, 5); + const action = { + type: 'vim_change_line' as const, + payload: { count: 1 }, + }; + + const result = handleVimAction(state, action); + + expect(result.lines[0]).toBe(''); + expect(result.cursorCol).toBe(0); + }); + }); + + describe('vim_change_movement', () => { + it('should change characters to the left', () => { + const state = createTestState(['hello world'], 0, 5); + const action = { + type: 'vim_change_movement' as const, + payload: { movement: 'h', count: 2 }, + }; + + const result = handleVimAction(state, action); + + expect(result.lines[0]).toBe('hel world'); + expect(result.cursorCol).toBe(3); + }); + + it('should change characters to the right', () => { + const state = createTestState(['hello world'], 0, 5); + const action = { + type: 'vim_change_movement' as const, + payload: { movement: 'l', count: 3 }, + }; + + const result = handleVimAction(state, action); + + expect(result.lines[0]).toBe('hellorld'); // Deletes ' wo' (3 chars to the right) + expect(result.cursorCol).toBe(5); + }); + + it('should change multiple lines down', () => { + const state = createTestState(['line1', 'line2', 'line3'], 0, 2); + const action = { + type: 'vim_change_movement' as const, + payload: { movement: 'j', count: 2 }, + }; + + const result = handleVimAction(state, action); + + // The movement 'j' with count 2 changes 2 lines starting from cursor row + // Since we're at cursor position 2, it changes lines starting from current row + expect(result.lines).toEqual(['line1', 'line2', 'line3']); // No change because count > available lines + expect(result.cursorRow).toBe(0); + expect(result.cursorCol).toBe(2); + }); + }); + }); + + describe('Edge cases', () => { + it('should handle empty text', () => { + const state = createTestState([''], 0, 0); + const action = { + type: 'vim_move_word_forward' as const, + payload: { count: 1 }, + }; + + const result = handleVimAction(state, action); + + expect(result.cursorRow).toBe(0); + expect(result.cursorCol).toBe(0); + }); + + it('should handle single character line', () => { + const state = createTestState(['a'], 0, 0); + const action = { type: 'vim_move_to_line_end' as const }; + + const result = handleVimAction(state, action); + + expect(result.cursorCol).toBe(0); // Should be last character position + }); + + it('should handle empty lines in multi-line text', () => { + const state = createTestState(['line1', '', 'line3'], 1, 0); + const action = { + type: 'vim_move_word_forward' as const, + payload: { count: 1 }, + }; + + const result = handleVimAction(state, action); + + // Should move to next line with content + expect(result.cursorRow).toBe(2); + expect(result.cursorCol).toBe(0); + }); + + it('should preserve undo stack in operations', () => { + const state = createTestState(['hello'], 0, 0); + state.undoStack = [{ lines: ['previous'], cursorRow: 0, cursorCol: 0 }]; + + const action = { + type: 'vim_delete_char' as const, + payload: { count: 1 }, + }; + + const result = handleVimAction(state, action); + + expect(result.undoStack).toHaveLength(2); // Original plus new snapshot + }); + }); +}); diff --git a/packages/cli/src/ui/components/shared/vim-buffer-actions.ts b/packages/cli/src/ui/components/shared/vim-buffer-actions.ts new file mode 100644 index 00000000..ab52e991 --- /dev/null +++ b/packages/cli/src/ui/components/shared/vim-buffer-actions.ts @@ -0,0 +1,887 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + TextBufferState, + TextBufferAction, + findNextWordStart, + findPrevWordStart, + findWordEnd, + getOffsetFromPosition, + getPositionFromOffsets, + getLineRangeOffsets, + replaceRangeInternal, + pushUndo, +} from './text-buffer.js'; +import { cpLen } from '../../utils/textUtils.js'; + +export type VimAction = Extract< + TextBufferAction, + | { type: 'vim_delete_word_forward' } + | { type: 'vim_delete_word_backward' } + | { type: 'vim_delete_word_end' } + | { type: 'vim_change_word_forward' } + | { type: 'vim_change_word_backward' } + | { type: 'vim_change_word_end' } + | { type: 'vim_delete_line' } + | { type: 'vim_change_line' } + | { type: 'vim_delete_to_end_of_line' } + | { type: 'vim_change_to_end_of_line' } + | { type: 'vim_change_movement' } + | { type: 'vim_move_left' } + | { type: 'vim_move_right' } + | { type: 'vim_move_up' } + | { type: 'vim_move_down' } + | { type: 'vim_move_word_forward' } + | { type: 'vim_move_word_backward' } + | { type: 'vim_move_word_end' } + | { type: 'vim_delete_char' } + | { 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' } + | { type: 'vim_escape_insert_mode' } +>; + +export function handleVimAction( + state: TextBufferState, + action: VimAction, +): TextBufferState { + const { lines, cursorRow, cursorCol } = state; + // Cache text join to avoid repeated calculations for word operations + let text: string | null = null; + const getText = () => text ?? (text = lines.join('\n')); + + switch (action.type) { + case 'vim_delete_word_forward': { + const { count } = action.payload; + const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines); + + let endOffset = currentOffset; + let searchOffset = currentOffset; + + for (let i = 0; i < count; i++) { + const nextWordOffset = findNextWordStart(getText(), searchOffset); + if (nextWordOffset > searchOffset) { + searchOffset = nextWordOffset; + endOffset = nextWordOffset; + } else { + // If no next word, delete to end of current word + const wordEndOffset = findWordEnd(getText(), searchOffset); + endOffset = Math.min(wordEndOffset + 1, getText().length); + break; + } + } + + if (endOffset > currentOffset) { + const nextState = pushUndo(state); + const { startRow, startCol, endRow, endCol } = getPositionFromOffsets( + currentOffset, + endOffset, + nextState.lines, + ); + return replaceRangeInternal( + nextState, + startRow, + startCol, + endRow, + endCol, + '', + ); + } + return state; + } + + case 'vim_delete_word_backward': { + const { count } = action.payload; + const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines); + + let startOffset = currentOffset; + let searchOffset = currentOffset; + + for (let i = 0; i < count; i++) { + const prevWordOffset = findPrevWordStart(getText(), searchOffset); + if (prevWordOffset < searchOffset) { + searchOffset = prevWordOffset; + startOffset = prevWordOffset; + } else { + break; + } + } + + if (startOffset < currentOffset) { + const nextState = pushUndo(state); + const { startRow, startCol, endRow, endCol } = getPositionFromOffsets( + startOffset, + currentOffset, + nextState.lines, + ); + const newState = replaceRangeInternal( + nextState, + startRow, + startCol, + endRow, + endCol, + '', + ); + // Cursor is already at the correct position after deletion + return newState; + } + return state; + } + + case 'vim_delete_word_end': { + const { count } = action.payload; + const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines); + + let offset = currentOffset; + let endOffset = currentOffset; + + for (let i = 0; i < count; i++) { + const wordEndOffset = findWordEnd(getText(), offset); + if (wordEndOffset >= offset) { + endOffset = wordEndOffset + 1; // Include the character at word end + // For next iteration, move to start of next word + if (i < count - 1) { + const nextWordStart = findNextWordStart( + getText(), + wordEndOffset + 1, + ); + offset = nextWordStart; + if (nextWordStart <= wordEndOffset) { + break; // No more words + } + } + } else { + break; + } + } + + endOffset = Math.min(endOffset, getText().length); + + if (endOffset > currentOffset) { + const nextState = pushUndo(state); + const { startRow, startCol, endRow, endCol } = getPositionFromOffsets( + currentOffset, + endOffset, + nextState.lines, + ); + return replaceRangeInternal( + nextState, + startRow, + startCol, + endRow, + endCol, + '', + ); + } + return state; + } + + case 'vim_change_word_forward': { + const { count } = action.payload; + const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines); + + let searchOffset = currentOffset; + let endOffset = currentOffset; + + for (let i = 0; i < count; i++) { + const nextWordOffset = findNextWordStart(getText(), searchOffset); + if (nextWordOffset > searchOffset) { + searchOffset = nextWordOffset; + endOffset = nextWordOffset; + } else { + // If no next word, change to end of current word + const wordEndOffset = findWordEnd(getText(), searchOffset); + endOffset = Math.min(wordEndOffset + 1, getText().length); + break; + } + } + + if (endOffset > currentOffset) { + const nextState = pushUndo(state); + const { startRow, startCol, endRow, endCol } = getPositionFromOffsets( + currentOffset, + endOffset, + nextState.lines, + ); + return replaceRangeInternal( + nextState, + startRow, + startCol, + endRow, + endCol, + '', + ); + } + return state; + } + + case 'vim_change_word_backward': { + const { count } = action.payload; + const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines); + + let startOffset = currentOffset; + let searchOffset = currentOffset; + + for (let i = 0; i < count; i++) { + const prevWordOffset = findPrevWordStart(getText(), searchOffset); + if (prevWordOffset < searchOffset) { + searchOffset = prevWordOffset; + startOffset = prevWordOffset; + } else { + break; + } + } + + if (startOffset < currentOffset) { + const nextState = pushUndo(state); + const { startRow, startCol, endRow, endCol } = getPositionFromOffsets( + startOffset, + currentOffset, + nextState.lines, + ); + return replaceRangeInternal( + nextState, + startRow, + startCol, + endRow, + endCol, + '', + ); + } + return state; + } + + case 'vim_change_word_end': { + const { count } = action.payload; + const currentOffset = getOffsetFromPosition(cursorRow, cursorCol, lines); + + let offset = currentOffset; + let endOffset = currentOffset; + + for (let i = 0; i < count; i++) { + const wordEndOffset = findWordEnd(getText(), offset); + if (wordEndOffset >= offset) { + endOffset = wordEndOffset + 1; // Include the character at word end + // For next iteration, move to start of next word + if (i < count - 1) { + const nextWordStart = findNextWordStart( + getText(), + wordEndOffset + 1, + ); + offset = nextWordStart; + if (nextWordStart <= wordEndOffset) { + break; // No more words + } + } + } else { + break; + } + } + + endOffset = Math.min(endOffset, getText().length); + + if (endOffset !== currentOffset) { + const nextState = pushUndo(state); + const { startRow, startCol, endRow, endCol } = getPositionFromOffsets( + Math.min(currentOffset, endOffset), + Math.max(currentOffset, endOffset), + nextState.lines, + ); + return replaceRangeInternal( + nextState, + startRow, + startCol, + endRow, + endCol, + '', + ); + } + return state; + } + + case 'vim_delete_line': { + const { count } = action.payload; + if (lines.length === 0) return state; + + const linesToDelete = Math.min(count, lines.length - cursorRow); + const totalLines = lines.length; + + if (totalLines === 1 || linesToDelete >= totalLines) { + // If there's only one line, or we're deleting all remaining lines, + // clear the content but keep one empty line (text editors should never be completely empty) + const nextState = pushUndo(state); + return { + ...nextState, + lines: [''], + cursorRow: 0, + cursorCol: 0, + preferredCol: null, + }; + } + + const nextState = pushUndo(state); + const newLines = [...nextState.lines]; + newLines.splice(cursorRow, linesToDelete); + + // Adjust cursor position + const newCursorRow = Math.min(cursorRow, newLines.length - 1); + const newCursorCol = 0; // Vim places cursor at beginning of line after dd + + return { + ...nextState, + lines: newLines, + cursorRow: newCursorRow, + cursorCol: newCursorCol, + preferredCol: null, + }; + } + + case 'vim_change_line': { + const { count } = action.payload; + if (lines.length === 0) return state; + + const linesToChange = Math.min(count, lines.length - cursorRow); + const nextState = pushUndo(state); + + const { startOffset, endOffset } = getLineRangeOffsets( + cursorRow, + linesToChange, + nextState.lines, + ); + const { startRow, startCol, endRow, endCol } = getPositionFromOffsets( + startOffset, + endOffset, + nextState.lines, + ); + return replaceRangeInternal( + nextState, + startRow, + startCol, + endRow, + endCol, + '', + ); + } + + case 'vim_delete_to_end_of_line': { + const currentLine = lines[cursorRow] || ''; + if (cursorCol < currentLine.length) { + const nextState = pushUndo(state); + return replaceRangeInternal( + nextState, + cursorRow, + cursorCol, + cursorRow, + currentLine.length, + '', + ); + } + return state; + } + + case 'vim_change_to_end_of_line': { + const currentLine = lines[cursorRow] || ''; + if (cursorCol < currentLine.length) { + const nextState = pushUndo(state); + return replaceRangeInternal( + nextState, + cursorRow, + cursorCol, + cursorRow, + currentLine.length, + '', + ); + } + return state; + } + + case 'vim_change_movement': { + const { movement, count } = action.payload; + const totalLines = lines.length; + + switch (movement) { + case 'h': { + // Left + // Change N characters to the left + const startCol = Math.max(0, cursorCol - count); + return replaceRangeInternal( + pushUndo(state), + cursorRow, + startCol, + cursorRow, + cursorCol, + '', + ); + } + + case 'j': { + // Down + const linesToChange = Math.min(count, totalLines - cursorRow); + if (linesToChange > 0) { + if (totalLines === 1) { + const currentLine = state.lines[0] || ''; + return replaceRangeInternal( + pushUndo(state), + 0, + 0, + 0, + cpLen(currentLine), + '', + ); + } else { + const nextState = pushUndo(state); + const { startOffset, endOffset } = getLineRangeOffsets( + cursorRow, + linesToChange, + nextState.lines, + ); + const { startRow, startCol, endRow, endCol } = + getPositionFromOffsets(startOffset, endOffset, nextState.lines); + return replaceRangeInternal( + nextState, + startRow, + startCol, + endRow, + endCol, + '', + ); + } + } + return state; + } + + case 'k': { + // Up + const upLines = Math.min(count, cursorRow + 1); + if (upLines > 0) { + if (state.lines.length === 1) { + const currentLine = state.lines[0] || ''; + return replaceRangeInternal( + pushUndo(state), + 0, + 0, + 0, + cpLen(currentLine), + '', + ); + } else { + const startRow = Math.max(0, cursorRow - count + 1); + const linesToChange = cursorRow - startRow + 1; + const nextState = pushUndo(state); + const { startOffset, endOffset } = getLineRangeOffsets( + startRow, + linesToChange, + nextState.lines, + ); + const { + startRow: newStartRow, + startCol, + endRow, + endCol, + } = getPositionFromOffsets( + startOffset, + endOffset, + nextState.lines, + ); + const resultState = replaceRangeInternal( + nextState, + newStartRow, + startCol, + endRow, + endCol, + '', + ); + return { + ...resultState, + cursorRow: startRow, + cursorCol: 0, + }; + } + } + return state; + } + + case 'l': { + // Right + // Change N characters to the right + return replaceRangeInternal( + pushUndo(state), + cursorRow, + cursorCol, + cursorRow, + Math.min(cpLen(lines[cursorRow] || ''), cursorCol + count), + '', + ); + } + + default: + return state; + } + } + + case 'vim_move_left': { + const { count } = action.payload; + const { cursorRow, cursorCol, lines } = state; + let newRow = cursorRow; + let newCol = cursorCol; + + for (let i = 0; i < count; i++) { + if (newCol > 0) { + newCol--; + } else if (newRow > 0) { + // Move to end of previous line + newRow--; + const prevLine = lines[newRow] || ''; + const prevLineLength = cpLen(prevLine); + // Position on last character, or column 0 for empty lines + newCol = prevLineLength === 0 ? 0 : prevLineLength - 1; + } + } + + return { + ...state, + cursorRow: newRow, + cursorCol: newCol, + preferredCol: null, + }; + } + + case 'vim_move_right': { + const { count } = action.payload; + const { cursorRow, cursorCol, lines } = state; + let newRow = cursorRow; + let newCol = cursorCol; + + for (let i = 0; i < count; i++) { + const currentLine = lines[newRow] || ''; + const lineLength = cpLen(currentLine); + // Don't move past the last character of the line + // For empty lines, stay at column 0; for non-empty lines, don't go past last character + if (lineLength === 0) { + // Empty line - try to move to next line + if (newRow < lines.length - 1) { + newRow++; + newCol = 0; + } + } else if (newCol < lineLength - 1) { + newCol++; + } else if (newRow < lines.length - 1) { + // At end of line - move to beginning of next line + newRow++; + newCol = 0; + } + } + + return { + ...state, + cursorRow: newRow, + cursorCol: newCol, + preferredCol: null, + }; + } + + case 'vim_move_up': { + const { count } = action.payload; + const { cursorRow, cursorCol, lines } = state; + const newRow = Math.max(0, cursorRow - count); + const newCol = Math.min(cursorCol, cpLen(lines[newRow] || '')); + + return { + ...state, + cursorRow: newRow, + cursorCol: newCol, + preferredCol: null, + }; + } + + case 'vim_move_down': { + const { count } = action.payload; + const { cursorRow, cursorCol, lines } = state; + const newRow = Math.min(lines.length - 1, cursorRow + count); + const newCol = Math.min(cursorCol, cpLen(lines[newRow] || '')); + + return { + ...state, + cursorRow: newRow, + cursorCol: newCol, + preferredCol: null, + }; + } + + case 'vim_move_word_forward': { + const { count } = action.payload; + let offset = getOffsetFromPosition(cursorRow, cursorCol, lines); + + for (let i = 0; i < count; i++) { + const nextWordOffset = findNextWordStart(getText(), offset); + if (nextWordOffset > offset) { + offset = nextWordOffset; + } else { + // No more words to move to + break; + } + } + + const { startRow, startCol } = getPositionFromOffsets( + offset, + offset, + lines, + ); + return { + ...state, + cursorRow: startRow, + cursorCol: startCol, + preferredCol: null, + }; + } + + case 'vim_move_word_backward': { + const { count } = action.payload; + let offset = getOffsetFromPosition(cursorRow, cursorCol, lines); + + for (let i = 0; i < count; i++) { + offset = findPrevWordStart(getText(), offset); + } + + const { startRow, startCol } = getPositionFromOffsets( + offset, + offset, + lines, + ); + return { + ...state, + cursorRow: startRow, + cursorCol: startCol, + preferredCol: null, + }; + } + + case 'vim_move_word_end': { + const { count } = action.payload; + let offset = getOffsetFromPosition(cursorRow, cursorCol, lines); + + for (let i = 0; i < count; i++) { + offset = findWordEnd(getText(), offset); + } + + const { startRow, startCol } = getPositionFromOffsets( + offset, + offset, + lines, + ); + return { + ...state, + cursorRow: startRow, + cursorCol: startCol, + preferredCol: null, + }; + } + + case 'vim_delete_char': { + const { count } = action.payload; + const { cursorRow, cursorCol, lines } = state; + const currentLine = lines[cursorRow] || ''; + const lineLength = cpLen(currentLine); + + if (cursorCol < lineLength) { + const deleteCount = Math.min(count, lineLength - cursorCol); + const nextState = pushUndo(state); + return replaceRangeInternal( + nextState, + cursorRow, + cursorCol, + cursorRow, + cursorCol + deleteCount, + '', + ); + } + return state; + } + + case 'vim_insert_at_cursor': { + // Just return state - mode change is handled elsewhere + return state; + } + + case 'vim_append_at_cursor': { + const { cursorRow, cursorCol, lines } = state; + const currentLine = lines[cursorRow] || ''; + const newCol = cursorCol < cpLen(currentLine) ? cursorCol + 1 : cursorCol; + + return { + ...state, + cursorCol: newCol, + preferredCol: null, + }; + } + + case 'vim_open_line_below': { + const { cursorRow, lines } = state; + const nextState = pushUndo(state); + + // Insert newline at end of current line + const endOfLine = cpLen(lines[cursorRow] || ''); + return replaceRangeInternal( + nextState, + cursorRow, + endOfLine, + cursorRow, + endOfLine, + '\n', + ); + } + + case 'vim_open_line_above': { + const { cursorRow } = state; + const nextState = pushUndo(state); + + // Insert newline at beginning of current line + const resultState = replaceRangeInternal( + nextState, + cursorRow, + 0, + cursorRow, + 0, + '\n', + ); + + // Move cursor to the new line above + return { + ...resultState, + cursorRow, + cursorCol: 0, + }; + } + + case 'vim_append_at_line_end': { + const { cursorRow, lines } = state; + const lineLength = cpLen(lines[cursorRow] || ''); + + return { + ...state, + cursorCol: lineLength, + preferredCol: null, + }; + } + + case 'vim_insert_at_line_start': { + const { cursorRow, lines } = state; + const currentLine = lines[cursorRow] || ''; + let col = 0; + + // Find first non-whitespace character using proper Unicode handling + const lineCodePoints = [...currentLine]; // Proper Unicode iteration + while (col < lineCodePoints.length && /\s/.test(lineCodePoints[col])) { + col++; + } + + return { + ...state, + cursorCol: col, + preferredCol: null, + }; + } + + case 'vim_move_to_line_start': { + return { + ...state, + cursorCol: 0, + preferredCol: null, + }; + } + + case 'vim_move_to_line_end': { + const { cursorRow, lines } = state; + const lineLength = cpLen(lines[cursorRow] || ''); + + return { + ...state, + cursorCol: lineLength > 0 ? lineLength - 1 : 0, + preferredCol: null, + }; + } + + case 'vim_move_to_first_nonwhitespace': { + const { cursorRow, lines } = state; + const currentLine = lines[cursorRow] || ''; + let col = 0; + + // Find first non-whitespace character using proper Unicode handling + const lineCodePoints = [...currentLine]; // Proper Unicode iteration + while (col < lineCodePoints.length && /\s/.test(lineCodePoints[col])) { + col++; + } + + return { + ...state, + cursorCol: col, + preferredCol: null, + }; + } + + case 'vim_move_to_first_line': { + return { + ...state, + cursorRow: 0, + cursorCol: 0, + preferredCol: null, + }; + } + + case 'vim_move_to_last_line': { + const { lines } = state; + const lastRow = lines.length - 1; + + return { + ...state, + cursorRow: lastRow, + cursorCol: 0, + preferredCol: null, + }; + } + + case 'vim_move_to_line': { + const { lineNumber } = action.payload; + const { lines } = state; + const targetRow = Math.min(Math.max(0, lineNumber - 1), lines.length - 1); + + return { + ...state, + cursorRow: targetRow, + cursorCol: 0, + preferredCol: null, + }; + } + + case 'vim_escape_insert_mode': { + // Move cursor left if not at beginning of line (vim behavior when exiting insert mode) + const { cursorCol } = state; + const newCol = cursorCol > 0 ? cursorCol - 1 : 0; + + return { + ...state, + cursorCol: newCol, + preferredCol: null, + }; + } + + default: { + // This should never happen if TypeScript is working correctly + const _exhaustiveCheck: never = action; + return state; + } + } +} diff --git a/packages/cli/src/ui/contexts/VimModeContext.tsx b/packages/cli/src/ui/contexts/VimModeContext.tsx new file mode 100644 index 00000000..b27034ef --- /dev/null +++ b/packages/cli/src/ui/contexts/VimModeContext.tsx @@ -0,0 +1,79 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + createContext, + useCallback, + useContext, + useEffect, + useState, +} from 'react'; +import { LoadedSettings, SettingScope } from '../../config/settings.js'; + +export type VimMode = 'NORMAL' | 'INSERT'; + +interface VimModeContextType { + vimEnabled: boolean; + vimMode: VimMode; + toggleVimEnabled: () => Promise; + setVimMode: (mode: VimMode) => void; +} + +const VimModeContext = createContext(undefined); + +export const VimModeProvider = ({ + children, + settings, +}: { + children: React.ReactNode; + settings: LoadedSettings; +}) => { + const initialVimEnabled = settings.merged.vimMode ?? false; + const [vimEnabled, setVimEnabled] = useState(initialVimEnabled); + const [vimMode, setVimMode] = useState( + initialVimEnabled ? 'NORMAL' : 'INSERT', + ); + + useEffect(() => { + // Initialize vimEnabled from settings on mount + const enabled = settings.merged.vimMode ?? false; + setVimEnabled(enabled); + // When vim mode is enabled, always start in NORMAL mode + if (enabled) { + setVimMode('NORMAL'); + } + }, [settings.merged.vimMode]); + + const toggleVimEnabled = useCallback(async () => { + const newValue = !vimEnabled; + setVimEnabled(newValue); + // When enabling vim mode, start in NORMAL mode + if (newValue) { + setVimMode('NORMAL'); + } + await settings.setValue(SettingScope.User, 'vimMode', newValue); + return newValue; + }, [vimEnabled, settings]); + + const value = { + vimEnabled, + vimMode, + toggleVimEnabled, + setVimMode, + }; + + return ( + {children} + ); +}; + +export const useVimMode = () => { + const context = useContext(VimModeContext); + if (context === undefined) { + throw new Error('useVimMode must be used within a VimModeProvider'); + } + return context; +}; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts index d308af46..ac9b79ec 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.test.ts @@ -111,6 +111,7 @@ describe('useSlashCommandProcessor', () => { vi.fn(), // toggleCorgiMode mockSetQuittingMessages, vi.fn(), // openPrivacyNotice + vi.fn(), // toggleVimEnabled ), ); diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 9e9dc21c..46b49329 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -43,6 +43,7 @@ export const useSlashCommandProcessor = ( toggleCorgiMode: () => void, setQuittingMessages: (message: HistoryItem[]) => void, openPrivacyNotice: () => void, + toggleVimEnabled: () => Promise, ) => { const session = useSessionStats(); const [commands, setCommands] = useState([]); @@ -139,6 +140,7 @@ export const useSlashCommandProcessor = ( pendingItem: pendingCompressionItemRef.current, setPendingItem: setPendingCompressionItem, toggleCorgiMode, + toggleVimEnabled, }, session: { stats: session.stats, @@ -158,6 +160,7 @@ export const useSlashCommandProcessor = ( pendingCompressionItemRef, setPendingCompressionItem, toggleCorgiMode, + toggleVimEnabled, ], ); diff --git a/packages/cli/src/ui/hooks/useKeypress.ts b/packages/cli/src/ui/hooks/useKeypress.ts index d3e3df5c..6c2b7e8f 100644 --- a/packages/cli/src/ui/hooks/useKeypress.ts +++ b/packages/cli/src/ui/hooks/useKeypress.ts @@ -147,12 +147,15 @@ export function useKeypress( let rl: readline.Interface; if (usePassthrough) { - rl = readline.createInterface({ input: keypressStream }); + rl = readline.createInterface({ + input: keypressStream, + escapeCodeTimeout: 0, + }); readline.emitKeypressEvents(keypressStream, rl); keypressStream.on('keypress', handleKeypress); stdin.on('data', handleRawKeypress); } else { - rl = readline.createInterface({ input: stdin }); + rl = readline.createInterface({ input: stdin, escapeCodeTimeout: 0 }); readline.emitKeypressEvents(stdin, rl); stdin.on('keypress', handleKeypress); } diff --git a/packages/cli/src/ui/hooks/vim.test.ts b/packages/cli/src/ui/hooks/vim.test.ts new file mode 100644 index 00000000..f939982f --- /dev/null +++ b/packages/cli/src/ui/hooks/vim.test.ts @@ -0,0 +1,1626 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { renderHook, act } from '@testing-library/react'; +import React from 'react'; +import { useVim } from './vim.js'; +import type { TextBuffer } from '../components/shared/text-buffer.js'; +import { textBufferReducer } from '../components/shared/text-buffer.js'; + +// Mock the VimModeContext +const mockVimContext = { + vimEnabled: true, + vimMode: 'NORMAL' as const, + toggleVimEnabled: vi.fn(), + setVimMode: vi.fn(), +}; + +vi.mock('../contexts/VimModeContext.js', () => ({ + useVimMode: () => mockVimContext, + VimModeProvider: ({ children }: { children: React.ReactNode }) => children, +})); + +// Test constants +const TEST_SEQUENCES = { + ESCAPE: { sequence: '\u001b', name: 'escape' }, + LEFT: { sequence: 'h' }, + RIGHT: { sequence: 'l' }, + UP: { sequence: 'k' }, + DOWN: { sequence: 'j' }, + INSERT: { sequence: 'i' }, + APPEND: { sequence: 'a' }, + DELETE_CHAR: { sequence: 'x' }, + DELETE: { sequence: 'd' }, + CHANGE: { sequence: 'c' }, + WORD_FORWARD: { sequence: 'w' }, + WORD_BACKWARD: { sequence: 'b' }, + WORD_END: { sequence: 'e' }, + LINE_START: { sequence: '0' }, + LINE_END: { sequence: '$' }, + REPEAT: { sequence: '.' }, +} as const; + +describe('useVim hook', () => { + let mockBuffer: Partial; + let mockHandleFinalSubmit: vi.Mock; + + const createMockBuffer = ( + text = 'hello world', + cursor: [number, number] = [0, 5], + ) => { + const cursorState = { pos: cursor }; + const lines = text.split('\n'); + + return { + lines, + get cursor() { + return cursorState.pos; + }, + set cursor(newPos: [number, number]) { + cursorState.pos = newPos; + }, + text, + move: vi.fn().mockImplementation((direction: string) => { + let [row, col] = cursorState.pos; + const _line = lines[row] || ''; + if (direction === 'left') { + col = Math.max(0, col - 1); + } else if (direction === 'right') { + col = Math.min(line.length, col + 1); + } else if (direction === 'home') { + col = 0; + } else if (direction === 'end') { + col = line.length; + } + cursorState.pos = [row, col]; + }), + del: vi.fn(), + moveToOffset: vi.fn(), + insert: vi.fn(), + newline: vi.fn(), + replaceRangeByOffset: vi.fn(), + handleInput: vi.fn(), + setText: vi.fn(), + // Vim-specific methods + vimDeleteWordForward: vi.fn(), + vimDeleteWordBackward: vi.fn(), + vimDeleteWordEnd: vi.fn(), + vimChangeWordForward: vi.fn(), + vimChangeWordBackward: vi.fn(), + vimChangeWordEnd: vi.fn(), + vimDeleteLine: vi.fn(), + vimChangeLine: vi.fn(), + vimDeleteToEndOfLine: vi.fn(), + vimChangeToEndOfLine: vi.fn(), + vimChangeMovement: vi.fn(), + vimMoveLeft: vi.fn(), + vimMoveRight: vi.fn(), + vimMoveUp: vi.fn(), + vimMoveDown: vi.fn(), + vimMoveWordForward: vi.fn(), + vimMoveWordBackward: vi.fn(), + vimMoveWordEnd: vi.fn(), + vimDeleteChar: vi.fn(), + vimInsertAtCursor: vi.fn(), + vimAppendAtCursor: vi.fn().mockImplementation(() => { + // Append moves cursor right (vim 'a' behavior - position after current char) + const [row, col] = cursorState.pos; + const _line = lines[row] || ''; + // In vim, 'a' moves cursor to position after current character + // This allows inserting at the end of the line + cursorState.pos = [row, col + 1]; + }), + vimOpenLineBelow: vi.fn(), + vimOpenLineAbove: vi.fn(), + vimAppendAtLineEnd: vi.fn(), + vimInsertAtLineStart: vi.fn(), + vimMoveToLineStart: vi.fn(), + vimMoveToLineEnd: vi.fn(), + vimMoveToFirstNonWhitespace: vi.fn(), + vimMoveToFirstLine: vi.fn(), + vimMoveToLastLine: vi.fn(), + vimMoveToLine: vi.fn(), + vimEscapeInsertMode: vi.fn().mockImplementation(() => { + // Escape moves cursor left unless at beginning of line + const [row, col] = cursorState.pos; + if (col > 0) { + cursorState.pos = [row, col - 1]; + } + }), + }; + }; + + const _createMockSettings = (vimMode = true) => ({ + getValue: vi.fn().mockReturnValue(vimMode), + setValue: vi.fn(), + merged: { vimMode }, + }); + + const renderVimHook = (buffer?: Partial) => + renderHook(() => + useVim((buffer || mockBuffer) as TextBuffer, mockHandleFinalSubmit), + ); + + const exitInsertMode = (result: { + current: { + handleInput: (input: { sequence: string; name: string }) => void; + }; + }) => { + act(() => { + result.current.handleInput({ sequence: '\u001b', name: 'escape' }); + }); + }; + + beforeEach(() => { + vi.clearAllMocks(); + mockHandleFinalSubmit = vi.fn(); + mockBuffer = createMockBuffer(); + // Reset mock context to default state + mockVimContext.vimEnabled = true; + mockVimContext.vimMode = 'NORMAL'; + mockVimContext.toggleVimEnabled.mockClear(); + mockVimContext.setVimMode.mockClear(); + }); + + describe('Mode switching', () => { + it('should start in NORMAL mode', () => { + const { result } = renderVimHook(); + expect(result.current.mode).toBe('NORMAL'); + }); + + it('should switch to INSERT mode with i command', () => { + const { result } = renderVimHook(); + + act(() => { + result.current.handleInput(TEST_SEQUENCES.INSERT); + }); + + expect(result.current.mode).toBe('INSERT'); + expect(mockVimContext.setVimMode).toHaveBeenCalledWith('INSERT'); + }); + + it('should switch back to NORMAL mode with Escape', () => { + const { result } = renderVimHook(); + + act(() => { + result.current.handleInput(TEST_SEQUENCES.INSERT); + }); + expect(result.current.mode).toBe('INSERT'); + + exitInsertMode(result); + expect(result.current.mode).toBe('NORMAL'); + }); + + it('should properly handle escape followed immediately by a command', () => { + const testBuffer = createMockBuffer('hello world test', [0, 6]); + const { result } = renderVimHook(testBuffer); + + act(() => { + result.current.handleInput({ sequence: 'i' }); + }); + expect(result.current.mode).toBe('INSERT'); + + vi.clearAllMocks(); + + exitInsertMode(result); + expect(result.current.mode).toBe('NORMAL'); + + act(() => { + result.current.handleInput({ sequence: 'b' }); + }); + + expect(testBuffer.vimMoveWordBackward).toHaveBeenCalledWith(1); + }); + }); + + describe('Navigation commands', () => { + it('should handle h (left movement)', () => { + const { result } = renderVimHook(); + + act(() => { + result.current.handleInput({ sequence: 'h' }); + }); + + expect(mockBuffer.vimMoveLeft).toHaveBeenCalledWith(1); + }); + + it('should handle l (right movement)', () => { + const { result } = renderVimHook(); + + act(() => { + result.current.handleInput({ sequence: 'l' }); + }); + + expect(mockBuffer.vimMoveRight).toHaveBeenCalledWith(1); + }); + + it('should handle j (down movement)', () => { + const testBuffer = createMockBuffer('first line\nsecond line'); + const { result } = renderVimHook(testBuffer); + + act(() => { + result.current.handleInput({ sequence: 'j' }); + }); + + expect(testBuffer.vimMoveDown).toHaveBeenCalledWith(1); + }); + + it('should handle k (up movement)', () => { + const testBuffer = createMockBuffer('first line\nsecond line'); + const { result } = renderVimHook(testBuffer); + + act(() => { + result.current.handleInput({ sequence: 'k' }); + }); + + expect(testBuffer.vimMoveUp).toHaveBeenCalledWith(1); + }); + + it('should handle 0 (move to start of line)', () => { + const { result } = renderVimHook(); + + act(() => { + result.current.handleInput({ sequence: '0' }); + }); + + expect(mockBuffer.vimMoveToLineStart).toHaveBeenCalled(); + }); + + it('should handle $ (move to end of line)', () => { + const { result } = renderVimHook(); + + act(() => { + result.current.handleInput({ sequence: '$' }); + }); + + expect(mockBuffer.vimMoveToLineEnd).toHaveBeenCalled(); + }); + }); + + describe('Mode switching commands', () => { + it('should handle a (append after cursor)', () => { + const { result } = renderVimHook(); + + act(() => { + result.current.handleInput({ sequence: 'a' }); + }); + + expect(mockBuffer.vimAppendAtCursor).toHaveBeenCalled(); + expect(result.current.mode).toBe('INSERT'); + }); + + it('should handle A (append at end of line)', () => { + const { result } = renderVimHook(); + + act(() => { + result.current.handleInput({ sequence: 'A' }); + }); + + expect(mockBuffer.vimAppendAtLineEnd).toHaveBeenCalled(); + expect(result.current.mode).toBe('INSERT'); + }); + + it('should handle o (open line below)', () => { + const { result } = renderVimHook(); + + act(() => { + result.current.handleInput({ sequence: 'o' }); + }); + + expect(mockBuffer.vimOpenLineBelow).toHaveBeenCalled(); + expect(result.current.mode).toBe('INSERT'); + }); + + it('should handle O (open line above)', () => { + const { result } = renderVimHook(); + + act(() => { + result.current.handleInput({ sequence: 'O' }); + }); + + expect(mockBuffer.vimOpenLineAbove).toHaveBeenCalled(); + expect(result.current.mode).toBe('INSERT'); + }); + }); + + describe('Edit commands', () => { + it('should handle x (delete character)', () => { + const { result } = renderVimHook(); + vi.clearAllMocks(); + + act(() => { + result.current.handleInput({ sequence: 'x' }); + }); + + expect(mockBuffer.vimDeleteChar).toHaveBeenCalledWith(1); + }); + + it('should move cursor left when deleting last character on line (vim behavior)', () => { + const testBuffer = createMockBuffer('hello', [0, 4]); + const { result } = renderVimHook(testBuffer); + + act(() => { + result.current.handleInput({ sequence: 'x' }); + }); + + expect(testBuffer.vimDeleteChar).toHaveBeenCalledWith(1); + }); + + it('should handle first d key (sets pending state)', () => { + const { result } = renderVimHook(); + + act(() => { + result.current.handleInput({ sequence: 'd' }); + }); + + expect(mockBuffer.replaceRangeByOffset).not.toHaveBeenCalled(); + }); + }); + + describe('Count handling', () => { + it('should handle count input and return to count 0 after command', () => { + const { result } = renderVimHook(); + + act(() => { + const handled = result.current.handleInput({ sequence: '3' }); + expect(handled).toBe(true); + }); + + act(() => { + const handled = result.current.handleInput({ sequence: 'h' }); + expect(handled).toBe(true); + }); + + expect(mockBuffer.vimMoveLeft).toHaveBeenCalledWith(3); + }); + + it('should only delete 1 character with x command when no count is specified', () => { + const testBuffer = createMockBuffer(); + const { result } = renderVimHook(testBuffer); + + act(() => { + result.current.handleInput({ sequence: 'x' }); + }); + + expect(testBuffer.vimDeleteChar).toHaveBeenCalledWith(1); + }); + }); + + describe('Word movement', () => { + it('should properly initialize vim hook with word movement support', () => { + const testBuffer = createMockBuffer('cat elephant mouse', [0, 0]); + const { result } = renderVimHook(testBuffer); + + expect(result.current.vimModeEnabled).toBe(true); + expect(result.current.mode).toBe('NORMAL'); + expect(result.current.handleInput).toBeDefined(); + }); + + it('should support vim mode and basic operations across multiple lines', () => { + const testBuffer = createMockBuffer( + 'first line word\nsecond line word', + [0, 11], + ); + const { result } = renderVimHook(testBuffer); + + expect(result.current.vimModeEnabled).toBe(true); + expect(result.current.mode).toBe('NORMAL'); + expect(result.current.handleInput).toBeDefined(); + expect(testBuffer.replaceRangeByOffset).toBeDefined(); + expect(testBuffer.moveToOffset).toBeDefined(); + }); + + it('should handle w (next word)', () => { + const testBuffer = createMockBuffer('hello world test'); + const { result } = renderVimHook(testBuffer); + + act(() => { + result.current.handleInput({ sequence: 'w' }); + }); + + expect(testBuffer.vimMoveWordForward).toHaveBeenCalledWith(1); + }); + + it('should handle b (previous word)', () => { + const testBuffer = createMockBuffer('hello world test', [0, 6]); + const { result } = renderVimHook(testBuffer); + + act(() => { + result.current.handleInput({ sequence: 'b' }); + }); + + expect(testBuffer.vimMoveWordBackward).toHaveBeenCalledWith(1); + }); + + it('should handle e (end of word)', () => { + const testBuffer = createMockBuffer('hello world test'); + const { result } = renderVimHook(testBuffer); + + act(() => { + result.current.handleInput({ sequence: 'e' }); + }); + + expect(testBuffer.vimMoveWordEnd).toHaveBeenCalledWith(1); + }); + + it('should handle w when cursor is on the last word', () => { + const testBuffer = createMockBuffer('hello world', [0, 8]); + const { result } = renderVimHook(testBuffer); + + act(() => { + result.current.handleInput({ sequence: 'w' }); + }); + + expect(testBuffer.vimMoveWordForward).toHaveBeenCalledWith(1); + }); + + it('should handle first c key (sets pending change state)', () => { + const { result } = renderVimHook(); + + act(() => { + result.current.handleInput({ sequence: 'c' }); + }); + + expect(result.current.mode).toBe('NORMAL'); + expect(mockBuffer.del).not.toHaveBeenCalled(); + }); + + it('should clear pending state on invalid command sequence (df)', () => { + const { result } = renderVimHook(); + + act(() => { + result.current.handleInput({ sequence: 'd' }); + result.current.handleInput({ sequence: 'f' }); + }); + + expect(mockBuffer.replaceRangeByOffset).not.toHaveBeenCalled(); + expect(mockBuffer.del).not.toHaveBeenCalled(); + }); + + it('should clear pending state with Escape in NORMAL mode', () => { + const { result } = renderVimHook(); + + act(() => { + result.current.handleInput({ sequence: 'd' }); + }); + + exitInsertMode(result); + + expect(mockBuffer.replaceRangeByOffset).not.toHaveBeenCalled(); + }); + }); + + describe('Disabled vim mode', () => { + it('should not respond to vim commands when disabled', () => { + mockVimContext.vimEnabled = false; + const { result } = renderVimHook(mockBuffer); + + act(() => { + result.current.handleInput({ sequence: 'h' }); + }); + + expect(mockBuffer.move).not.toHaveBeenCalled(); + }); + }); + + // These tests are no longer applicable at the hook level + + describe('Command repeat system', () => { + it('should repeat x command from current cursor position', () => { + const testBuffer = createMockBuffer('abcd\nefgh\nijkl', [0, 1]); + const { result } = renderVimHook(testBuffer); + + act(() => { + result.current.handleInput({ sequence: 'x' }); + }); + expect(testBuffer.vimDeleteChar).toHaveBeenCalledWith(1); + + testBuffer.cursor = [1, 2]; + + act(() => { + result.current.handleInput({ sequence: '.' }); + }); + expect(testBuffer.vimDeleteChar).toHaveBeenCalledWith(1); + }); + + it('should repeat dd command from current position', () => { + const testBuffer = createMockBuffer('line1\nline2\nline3', [1, 0]); + const { result } = renderVimHook(testBuffer); + + act(() => { + result.current.handleInput({ sequence: 'd' }); + }); + act(() => { + result.current.handleInput({ sequence: 'd' }); + }); + expect(testBuffer.vimDeleteLine).toHaveBeenCalledTimes(1); + + testBuffer.cursor = [0, 0]; + + act(() => { + result.current.handleInput({ sequence: '.' }); + }); + + expect(testBuffer.vimDeleteLine).toHaveBeenCalledTimes(2); + }); + + it('should repeat ce command from current position', () => { + const testBuffer = createMockBuffer('word', [0, 0]); + const { result } = renderVimHook(testBuffer); + + act(() => { + result.current.handleInput({ sequence: 'c' }); + }); + act(() => { + result.current.handleInput({ sequence: 'e' }); + }); + expect(testBuffer.vimChangeWordEnd).toHaveBeenCalledTimes(1); + + // Exit INSERT mode to complete the command + exitInsertMode(result); + + testBuffer.cursor = [0, 2]; + + act(() => { + result.current.handleInput({ sequence: '.' }); + }); + + expect(testBuffer.vimChangeWordEnd).toHaveBeenCalledTimes(2); + }); + + it('should repeat cc command from current position', () => { + const testBuffer = createMockBuffer('line1\nline2\nline3', [1, 2]); + const { result } = renderVimHook(testBuffer); + + act(() => { + result.current.handleInput({ sequence: 'c' }); + }); + act(() => { + result.current.handleInput({ sequence: 'c' }); + }); + expect(testBuffer.vimChangeLine).toHaveBeenCalledTimes(1); + + // Exit INSERT mode to complete the command + exitInsertMode(result); + + testBuffer.cursor = [0, 1]; + + act(() => { + result.current.handleInput({ sequence: '.' }); + }); + + expect(testBuffer.vimChangeLine).toHaveBeenCalledTimes(2); + }); + + it('should repeat cw command from current position', () => { + const testBuffer = createMockBuffer('hello world test', [0, 6]); + const { result } = renderVimHook(testBuffer); + + act(() => { + result.current.handleInput({ sequence: 'c' }); + }); + act(() => { + result.current.handleInput({ sequence: 'w' }); + }); + expect(testBuffer.vimChangeWordForward).toHaveBeenCalledTimes(1); + + // Exit INSERT mode to complete the command + exitInsertMode(result); + + testBuffer.cursor = [0, 0]; + + act(() => { + result.current.handleInput({ sequence: '.' }); + }); + + expect(testBuffer.vimChangeWordForward).toHaveBeenCalledTimes(2); + }); + + it('should repeat D command from current position', () => { + const testBuffer = createMockBuffer('hello world test', [0, 6]); + const { result } = renderVimHook(testBuffer); + + act(() => { + result.current.handleInput({ sequence: 'D' }); + }); + expect(testBuffer.vimDeleteToEndOfLine).toHaveBeenCalledTimes(1); + + testBuffer.cursor = [0, 2]; + vi.clearAllMocks(); // Clear all mocks instead of just one method + + act(() => { + result.current.handleInput({ sequence: '.' }); + }); + + expect(testBuffer.vimDeleteToEndOfLine).toHaveBeenCalledTimes(1); + }); + + it('should repeat C command from current position', () => { + const testBuffer = createMockBuffer('hello world test', [0, 6]); + const { result } = renderVimHook(testBuffer); + + act(() => { + result.current.handleInput({ sequence: 'C' }); + }); + expect(testBuffer.vimChangeToEndOfLine).toHaveBeenCalledTimes(1); + + // Exit INSERT mode to complete the command + exitInsertMode(result); + + testBuffer.cursor = [0, 2]; + + act(() => { + result.current.handleInput({ sequence: '.' }); + }); + + expect(testBuffer.vimChangeToEndOfLine).toHaveBeenCalledTimes(2); + }); + + it('should repeat command after cursor movement', () => { + const testBuffer = createMockBuffer('test text', [0, 0]); + const { result } = renderVimHook(testBuffer); + + act(() => { + result.current.handleInput({ sequence: 'x' }); + }); + expect(testBuffer.vimDeleteChar).toHaveBeenCalledWith(1); + + testBuffer.cursor = [0, 2]; + + act(() => { + result.current.handleInput({ sequence: '.' }); + }); + expect(testBuffer.vimDeleteChar).toHaveBeenCalledWith(1); + }); + + it('should move cursor to the correct position after exiting INSERT mode with "a"', () => { + const testBuffer = createMockBuffer('hello world', [0, 10]); + const { result } = renderVimHook(testBuffer); + + act(() => { + result.current.handleInput({ sequence: 'a' }); + }); + expect(result.current.mode).toBe('INSERT'); + expect(testBuffer.cursor).toEqual([0, 11]); + + exitInsertMode(result); + expect(result.current.mode).toBe('NORMAL'); + expect(testBuffer.cursor).toEqual([0, 10]); + }); + }); + + describe('Special characters and edge cases', () => { + it('should handle ^ (move to first non-whitespace character)', () => { + const testBuffer = createMockBuffer(' hello world', [0, 5]); + const { result } = renderVimHook(testBuffer); + + act(() => { + result.current.handleInput({ sequence: '^' }); + }); + + expect(testBuffer.vimMoveToFirstNonWhitespace).toHaveBeenCalled(); + }); + + it('should handle G without count (go to last line)', () => { + const testBuffer = createMockBuffer('line1\nline2\nline3', [0, 0]); + const { result } = renderVimHook(testBuffer); + + act(() => { + result.current.handleInput({ sequence: 'G' }); + }); + + expect(testBuffer.vimMoveToLastLine).toHaveBeenCalled(); + }); + + it('should handle gg (go to first line)', () => { + const testBuffer = createMockBuffer('line1\nline2\nline3', [2, 0]); + const { result } = renderVimHook(testBuffer); + + // First 'g' sets pending state + act(() => { + result.current.handleInput({ sequence: 'g' }); + }); + + // Second 'g' executes the command + act(() => { + result.current.handleInput({ sequence: 'g' }); + }); + + expect(testBuffer.vimMoveToFirstLine).toHaveBeenCalled(); + }); + + it('should handle count with movement commands', () => { + const testBuffer = createMockBuffer('hello world test', [0, 0]); + const { result } = renderVimHook(testBuffer); + + act(() => { + result.current.handleInput({ sequence: '3' }); + }); + + act(() => { + result.current.handleInput(TEST_SEQUENCES.WORD_FORWARD); + }); + + expect(testBuffer.vimMoveWordForward).toHaveBeenCalledWith(3); + }); + }); + + describe('Vim word operations', () => { + describe('dw (delete word forward)', () => { + it('should delete from cursor to start of next word', () => { + const testBuffer = createMockBuffer('hello world test', [0, 0]); + const { result } = renderVimHook(testBuffer); + + act(() => { + result.current.handleInput({ sequence: 'd' }); + }); + act(() => { + result.current.handleInput({ sequence: 'w' }); + }); + + expect(testBuffer.vimDeleteWordForward).toHaveBeenCalledWith(1); + }); + + it('should actually delete the complete word including trailing space', () => { + // This test uses the real text-buffer reducer instead of mocks + const initialState = { + lines: ['hello world test'], + cursorRow: 0, + cursorCol: 0, + preferredCol: null, + undoStack: [], + redoStack: [], + clipboard: null, + selectionAnchor: null, + }; + + const result = textBufferReducer(initialState, { + type: 'vim_delete_word_forward', + payload: { count: 1 }, + }); + + // Should delete "hello " (word + space), leaving "world test" + expect(result.lines).toEqual(['world test']); + expect(result.cursorRow).toBe(0); + expect(result.cursorCol).toBe(0); + }); + + it('should delete word from middle of word correctly', () => { + const initialState = { + lines: ['hello world test'], + cursorRow: 0, + cursorCol: 2, // cursor on 'l' in "hello" + preferredCol: null, + undoStack: [], + redoStack: [], + clipboard: null, + selectionAnchor: null, + }; + + const result = textBufferReducer(initialState, { + type: 'vim_delete_word_forward', + payload: { count: 1 }, + }); + + // Should delete "llo " (rest of word + space), leaving "he world test" + expect(result.lines).toEqual(['heworld test']); + expect(result.cursorRow).toBe(0); + expect(result.cursorCol).toBe(2); + }); + + it('should handle dw at end of line', () => { + const initialState = { + lines: ['hello world'], + cursorRow: 0, + cursorCol: 6, // cursor on 'w' in "world" + preferredCol: null, + undoStack: [], + redoStack: [], + clipboard: null, + selectionAnchor: null, + }; + + const result = textBufferReducer(initialState, { + type: 'vim_delete_word_forward', + payload: { count: 1 }, + }); + + // Should delete "world" (no trailing space at end), leaving "hello " + expect(result.lines).toEqual(['hello ']); + expect(result.cursorRow).toBe(0); + expect(result.cursorCol).toBe(6); + }); + + it('should delete multiple words with count', () => { + const testBuffer = createMockBuffer('one two three four', [0, 0]); + const { result } = renderVimHook(testBuffer); + + act(() => { + result.current.handleInput({ sequence: '2' }); + }); + act(() => { + result.current.handleInput({ sequence: 'd' }); + }); + act(() => { + result.current.handleInput({ sequence: 'w' }); + }); + + expect(testBuffer.vimDeleteWordForward).toHaveBeenCalledWith(2); + }); + + it('should record command for repeat with dot', () => { + const testBuffer = createMockBuffer('hello world test', [0, 0]); + const { result } = renderVimHook(testBuffer); + + // Execute dw + act(() => { + result.current.handleInput({ sequence: 'd' }); + }); + act(() => { + result.current.handleInput({ sequence: 'w' }); + }); + + vi.clearAllMocks(); + + // Execute dot repeat + act(() => { + result.current.handleInput({ sequence: '.' }); + }); + + expect(testBuffer.vimDeleteWordForward).toHaveBeenCalledWith(1); + }); + }); + + describe('de (delete word end)', () => { + it('should delete from cursor to end of current word', () => { + const testBuffer = createMockBuffer('hello world test', [0, 1]); + const { result } = renderVimHook(testBuffer); + + act(() => { + result.current.handleInput({ sequence: 'd' }); + }); + act(() => { + result.current.handleInput({ sequence: 'e' }); + }); + + expect(testBuffer.vimDeleteWordEnd).toHaveBeenCalledWith(1); + }); + + it('should handle count with de', () => { + const testBuffer = createMockBuffer('one two three four', [0, 0]); + const { result } = renderVimHook(testBuffer); + + act(() => { + result.current.handleInput({ sequence: '3' }); + }); + act(() => { + result.current.handleInput({ sequence: 'd' }); + }); + act(() => { + result.current.handleInput({ sequence: 'e' }); + }); + + expect(testBuffer.vimDeleteWordEnd).toHaveBeenCalledWith(3); + }); + }); + + describe('cw (change word forward)', () => { + it('should change from cursor to start of next word and enter INSERT mode', () => { + const testBuffer = createMockBuffer('hello world test', [0, 0]); + const { result } = renderVimHook(testBuffer); + + act(() => { + result.current.handleInput({ sequence: 'c' }); + }); + act(() => { + result.current.handleInput({ sequence: 'w' }); + }); + + expect(testBuffer.vimChangeWordForward).toHaveBeenCalledWith(1); + expect(result.current.mode).toBe('INSERT'); + expect(mockVimContext.setVimMode).toHaveBeenCalledWith('INSERT'); + }); + + it('should handle count with cw', () => { + const testBuffer = createMockBuffer('one two three four', [0, 0]); + const { result } = renderVimHook(testBuffer); + + act(() => { + result.current.handleInput({ sequence: '2' }); + }); + act(() => { + result.current.handleInput({ sequence: 'c' }); + }); + act(() => { + result.current.handleInput({ sequence: 'w' }); + }); + + expect(testBuffer.vimChangeWordForward).toHaveBeenCalledWith(2); + expect(result.current.mode).toBe('INSERT'); + }); + + it('should be repeatable with dot', () => { + const testBuffer = createMockBuffer('hello world test more', [0, 0]); + const { result } = renderVimHook(testBuffer); + + // Execute cw + act(() => { + result.current.handleInput({ sequence: 'c' }); + }); + act(() => { + result.current.handleInput({ sequence: 'w' }); + }); + + // Exit INSERT mode + exitInsertMode(result); + + vi.clearAllMocks(); + mockVimContext.setVimMode.mockClear(); + + // Execute dot repeat + act(() => { + result.current.handleInput({ sequence: '.' }); + }); + + expect(testBuffer.vimChangeWordForward).toHaveBeenCalledWith(1); + expect(result.current.mode).toBe('INSERT'); + }); + }); + + describe('ce (change word end)', () => { + it('should change from cursor to end of word and enter INSERT mode', () => { + const testBuffer = createMockBuffer('hello world test', [0, 1]); + const { result } = renderVimHook(testBuffer); + + act(() => { + result.current.handleInput({ sequence: 'c' }); + }); + act(() => { + result.current.handleInput({ sequence: 'e' }); + }); + + expect(testBuffer.vimChangeWordEnd).toHaveBeenCalledWith(1); + expect(result.current.mode).toBe('INSERT'); + }); + + it('should handle count with ce', () => { + const testBuffer = createMockBuffer('one two three four', [0, 0]); + const { result } = renderVimHook(testBuffer); + + act(() => { + result.current.handleInput({ sequence: '2' }); + }); + act(() => { + result.current.handleInput({ sequence: 'c' }); + }); + act(() => { + result.current.handleInput({ sequence: 'e' }); + }); + + expect(testBuffer.vimChangeWordEnd).toHaveBeenCalledWith(2); + expect(result.current.mode).toBe('INSERT'); + }); + }); + + describe('cc (change line)', () => { + it('should change entire line and enter INSERT mode', () => { + const testBuffer = createMockBuffer('hello world\nsecond line', [0, 5]); + const { result } = renderVimHook(testBuffer); + + act(() => { + result.current.handleInput({ sequence: 'c' }); + }); + act(() => { + result.current.handleInput({ sequence: 'c' }); + }); + + expect(testBuffer.vimChangeLine).toHaveBeenCalledWith(1); + expect(result.current.mode).toBe('INSERT'); + }); + + it('should change multiple lines with count', () => { + const testBuffer = createMockBuffer( + 'line1\nline2\nline3\nline4', + [1, 0], + ); + const { result } = renderVimHook(testBuffer); + + act(() => { + result.current.handleInput({ sequence: '3' }); + }); + act(() => { + result.current.handleInput({ sequence: 'c' }); + }); + act(() => { + result.current.handleInput({ sequence: 'c' }); + }); + + expect(testBuffer.vimChangeLine).toHaveBeenCalledWith(3); + expect(result.current.mode).toBe('INSERT'); + }); + + it('should be repeatable with dot', () => { + const testBuffer = createMockBuffer('line1\nline2\nline3', [0, 0]); + const { result } = renderVimHook(testBuffer); + + // Execute cc + act(() => { + result.current.handleInput({ sequence: 'c' }); + }); + act(() => { + result.current.handleInput({ sequence: 'c' }); + }); + + // Exit INSERT mode + exitInsertMode(result); + + vi.clearAllMocks(); + mockVimContext.setVimMode.mockClear(); + + // Execute dot repeat + act(() => { + result.current.handleInput({ sequence: '.' }); + }); + + expect(testBuffer.vimChangeLine).toHaveBeenCalledWith(1); + expect(result.current.mode).toBe('INSERT'); + }); + }); + + describe('db (delete word backward)', () => { + it('should delete from cursor to start of previous word', () => { + const testBuffer = createMockBuffer('hello world test', [0, 11]); + const { result } = renderVimHook(testBuffer); + + act(() => { + result.current.handleInput({ sequence: 'd' }); + }); + act(() => { + result.current.handleInput({ sequence: 'b' }); + }); + + expect(testBuffer.vimDeleteWordBackward).toHaveBeenCalledWith(1); + }); + + it('should handle count with db', () => { + const testBuffer = createMockBuffer('one two three four', [0, 18]); + const { result } = renderVimHook(testBuffer); + + act(() => { + result.current.handleInput({ sequence: '2' }); + }); + act(() => { + result.current.handleInput({ sequence: 'd' }); + }); + act(() => { + result.current.handleInput({ sequence: 'b' }); + }); + + expect(testBuffer.vimDeleteWordBackward).toHaveBeenCalledWith(2); + }); + }); + + describe('cb (change word backward)', () => { + it('should change from cursor to start of previous word and enter INSERT mode', () => { + const testBuffer = createMockBuffer('hello world test', [0, 11]); + const { result } = renderVimHook(testBuffer); + + act(() => { + result.current.handleInput({ sequence: 'c' }); + }); + act(() => { + result.current.handleInput({ sequence: 'b' }); + }); + + expect(testBuffer.vimChangeWordBackward).toHaveBeenCalledWith(1); + expect(result.current.mode).toBe('INSERT'); + }); + + it('should handle count with cb', () => { + const testBuffer = createMockBuffer('one two three four', [0, 18]); + const { result } = renderVimHook(testBuffer); + + act(() => { + result.current.handleInput({ sequence: '3' }); + }); + act(() => { + result.current.handleInput({ sequence: 'c' }); + }); + act(() => { + result.current.handleInput({ sequence: 'b' }); + }); + + expect(testBuffer.vimChangeWordBackward).toHaveBeenCalledWith(3); + expect(result.current.mode).toBe('INSERT'); + }); + }); + + describe('Pending state handling', () => { + it('should clear pending delete state after dw', () => { + const testBuffer = createMockBuffer('hello world', [0, 0]); + const { result } = renderVimHook(testBuffer); + + // Press 'd' to enter pending delete state + act(() => { + result.current.handleInput({ sequence: 'd' }); + }); + + // Complete with 'w' + act(() => { + result.current.handleInput({ sequence: 'w' }); + }); + + // Next 'd' should start a new pending state, not continue the previous one + act(() => { + result.current.handleInput({ sequence: 'd' }); + }); + + // This should trigger dd (delete line), not an error + act(() => { + result.current.handleInput({ sequence: 'd' }); + }); + + expect(testBuffer.vimDeleteLine).toHaveBeenCalledWith(1); + }); + + it('should clear pending change state after cw', () => { + const testBuffer = createMockBuffer('hello world', [0, 0]); + const { result } = renderVimHook(testBuffer); + + // Execute cw + act(() => { + result.current.handleInput({ sequence: 'c' }); + }); + act(() => { + result.current.handleInput({ sequence: 'w' }); + }); + + // Exit INSERT mode + exitInsertMode(result); + + // Next 'c' should start a new pending state + act(() => { + result.current.handleInput({ sequence: 'c' }); + }); + act(() => { + result.current.handleInput({ sequence: 'c' }); + }); + + expect(testBuffer.vimChangeLine).toHaveBeenCalledWith(1); + }); + + it('should clear pending state with escape', () => { + const testBuffer = createMockBuffer('hello world', [0, 0]); + const { result } = renderVimHook(testBuffer); + + // Enter pending delete state + act(() => { + result.current.handleInput({ sequence: 'd' }); + }); + + // Press escape to clear pending state + exitInsertMode(result); + + // Now 'w' should just move cursor, not delete + act(() => { + result.current.handleInput({ sequence: 'w' }); + }); + + expect(testBuffer.vimDeleteWordForward).not.toHaveBeenCalled(); + // w should move to next word after clearing pending state + expect(testBuffer.vimMoveWordForward).toHaveBeenCalledWith(1); + }); + }); + }); + + // Line operations (dd, cc) are tested in text-buffer.test.ts + + describe('Reducer-based integration tests', () => { + describe('de (delete word end)', () => { + it('should delete from cursor to end of current word', () => { + const initialState = { + lines: ['hello world test'], + cursorRow: 0, + cursorCol: 1, // cursor on 'e' in "hello" + preferredCol: null, + undoStack: [], + redoStack: [], + clipboard: null, + selectionAnchor: null, + }; + + const result = textBufferReducer(initialState, { + type: 'vim_delete_word_end', + payload: { count: 1 }, + }); + + // Should delete "ello" (from cursor to end of word), leaving "h world test" + expect(result.lines).toEqual(['h world test']); + expect(result.cursorRow).toBe(0); + expect(result.cursorCol).toBe(1); + }); + + it('should delete multiple word ends with count', () => { + const initialState = { + lines: ['hello world test more'], + cursorRow: 0, + cursorCol: 1, // cursor on 'e' in "hello" + preferredCol: null, + undoStack: [], + redoStack: [], + clipboard: null, + selectionAnchor: null, + }; + + const result = textBufferReducer(initialState, { + type: 'vim_delete_word_end', + payload: { count: 2 }, + }); + + // Should delete "ello world" (to end of second word), leaving "h test more" + expect(result.lines).toEqual(['h test more']); + expect(result.cursorRow).toBe(0); + expect(result.cursorCol).toBe(1); + }); + }); + + describe('db (delete word backward)', () => { + it('should delete from cursor to start of previous word', () => { + const initialState = { + lines: ['hello world test'], + cursorRow: 0, + cursorCol: 11, // cursor on 't' in "test" + preferredCol: null, + undoStack: [], + redoStack: [], + clipboard: null, + selectionAnchor: null, + }; + + const result = textBufferReducer(initialState, { + type: 'vim_delete_word_backward', + payload: { count: 1 }, + }); + + // Should delete "world" (previous word only), leaving "hello test" + expect(result.lines).toEqual(['hello test']); + expect(result.cursorRow).toBe(0); + expect(result.cursorCol).toBe(6); + }); + + it('should delete multiple words backward with count', () => { + const initialState = { + lines: ['hello world test more'], + cursorRow: 0, + cursorCol: 17, // cursor on 'm' in "more" + preferredCol: null, + undoStack: [], + redoStack: [], + clipboard: null, + selectionAnchor: null, + }; + + const result = textBufferReducer(initialState, { + type: 'vim_delete_word_backward', + payload: { count: 2 }, + }); + + // Should delete "world test " (two words backward), leaving "hello more" + expect(result.lines).toEqual(['hello more']); + expect(result.cursorRow).toBe(0); + expect(result.cursorCol).toBe(6); + }); + }); + + describe('cw (change word forward)', () => { + it('should delete from cursor to start of next word', () => { + const initialState = { + lines: ['hello world test'], + cursorRow: 0, + cursorCol: 0, // cursor on 'h' in "hello" + preferredCol: null, + undoStack: [], + redoStack: [], + clipboard: null, + selectionAnchor: null, + }; + + const result = textBufferReducer(initialState, { + type: 'vim_change_word_forward', + payload: { count: 1 }, + }); + + // Should delete "hello " (word + space), leaving "world test" + expect(result.lines).toEqual(['world test']); + expect(result.cursorRow).toBe(0); + expect(result.cursorCol).toBe(0); + }); + + it('should change multiple words with count', () => { + const initialState = { + lines: ['hello world test more'], + cursorRow: 0, + cursorCol: 0, + preferredCol: null, + undoStack: [], + redoStack: [], + clipboard: null, + selectionAnchor: null, + }; + + const result = textBufferReducer(initialState, { + type: 'vim_change_word_forward', + payload: { count: 2 }, + }); + + // Should delete "hello world " (two words), leaving "test more" + expect(result.lines).toEqual(['test more']); + expect(result.cursorRow).toBe(0); + expect(result.cursorCol).toBe(0); + }); + }); + + describe('ce (change word end)', () => { + it('should change from cursor to end of current word', () => { + const initialState = { + lines: ['hello world test'], + cursorRow: 0, + cursorCol: 1, // cursor on 'e' in "hello" + preferredCol: null, + undoStack: [], + redoStack: [], + clipboard: null, + selectionAnchor: null, + }; + + const result = textBufferReducer(initialState, { + type: 'vim_change_word_end', + payload: { count: 1 }, + }); + + // Should delete "ello" (from cursor to end of word), leaving "h world test" + expect(result.lines).toEqual(['h world test']); + expect(result.cursorRow).toBe(0); + expect(result.cursorCol).toBe(1); + }); + + it('should change multiple word ends with count', () => { + const initialState = { + lines: ['hello world test'], + cursorRow: 0, + cursorCol: 1, // cursor on 'e' in "hello" + preferredCol: null, + undoStack: [], + redoStack: [], + clipboard: null, + selectionAnchor: null, + }; + + const result = textBufferReducer(initialState, { + type: 'vim_change_word_end', + payload: { count: 2 }, + }); + + // Should delete "ello world" (to end of second word), leaving "h test" + expect(result.lines).toEqual(['h test']); + expect(result.cursorRow).toBe(0); + expect(result.cursorCol).toBe(1); + }); + }); + + describe('cb (change word backward)', () => { + it('should change from cursor to start of previous word', () => { + const initialState = { + lines: ['hello world test'], + cursorRow: 0, + cursorCol: 11, // cursor on 't' in "test" + preferredCol: null, + undoStack: [], + redoStack: [], + clipboard: null, + selectionAnchor: null, + }; + + const result = textBufferReducer(initialState, { + type: 'vim_change_word_backward', + payload: { count: 1 }, + }); + + // Should delete "world" (previous word only), leaving "hello test" + expect(result.lines).toEqual(['hello test']); + expect(result.cursorRow).toBe(0); + expect(result.cursorCol).toBe(6); + }); + }); + + describe('cc (change line)', () => { + it('should clear the line and place cursor at the start', () => { + const initialState = { + lines: [' hello world'], + cursorRow: 0, + cursorCol: 5, // cursor on 'o' + preferredCol: null, + undoStack: [], + redoStack: [], + clipboard: null, + selectionAnchor: null, + }; + + const result = textBufferReducer(initialState, { + type: 'vim_change_line', + payload: { count: 1 }, + }); + + expect(result.lines).toEqual(['']); + expect(result.cursorRow).toBe(0); + expect(result.cursorCol).toBe(0); + }); + }); + + describe('dd (delete line)', () => { + it('should delete the current line', () => { + const initialState = { + lines: ['line1', 'line2', 'line3'], + cursorRow: 1, + cursorCol: 2, + preferredCol: null, + undoStack: [], + redoStack: [], + clipboard: null, + selectionAnchor: null, + }; + + const result = textBufferReducer(initialState, { + type: 'vim_delete_line', + payload: { count: 1 }, + }); + + expect(result.lines).toEqual(['line1', 'line3']); + expect(result.cursorRow).toBe(1); + expect(result.cursorCol).toBe(0); + }); + + it('should delete multiple lines with count', () => { + const initialState = { + lines: ['line1', 'line2', 'line3', 'line4'], + cursorRow: 1, + cursorCol: 2, + preferredCol: null, + undoStack: [], + redoStack: [], + clipboard: null, + selectionAnchor: null, + }; + + const result = textBufferReducer(initialState, { + type: 'vim_delete_line', + payload: { count: 2 }, + }); + + // Should delete lines 1 and 2 + expect(result.lines).toEqual(['line1', 'line4']); + expect(result.cursorRow).toBe(1); + expect(result.cursorCol).toBe(0); + }); + + it('should handle deleting last line', () => { + const initialState = { + lines: ['only line'], + cursorRow: 0, + cursorCol: 3, + preferredCol: null, + undoStack: [], + redoStack: [], + clipboard: null, + selectionAnchor: null, + }; + + const result = textBufferReducer(initialState, { + type: 'vim_delete_line', + payload: { count: 1 }, + }); + + // Should leave an empty line when deleting the only line + expect(result.lines).toEqual(['']); + expect(result.cursorRow).toBe(0); + expect(result.cursorCol).toBe(0); + }); + }); + + describe('D (delete to end of line)', () => { + it('should delete from cursor to end of line', () => { + const initialState = { + lines: ['hello world test'], + cursorRow: 0, + cursorCol: 6, // cursor on 'w' in "world" + preferredCol: null, + undoStack: [], + redoStack: [], + clipboard: null, + selectionAnchor: null, + }; + + const result = textBufferReducer(initialState, { + type: 'vim_delete_to_end_of_line', + }); + + // Should delete "world test", leaving "hello " + expect(result.lines).toEqual(['hello ']); + expect(result.cursorRow).toBe(0); + expect(result.cursorCol).toBe(6); + }); + + it('should handle D at end of line', () => { + const initialState = { + lines: ['hello world'], + cursorRow: 0, + cursorCol: 11, // cursor at end + preferredCol: null, + undoStack: [], + redoStack: [], + clipboard: null, + selectionAnchor: null, + }; + + const result = textBufferReducer(initialState, { + type: 'vim_delete_to_end_of_line', + }); + + // Should not change anything when at end of line + expect(result.lines).toEqual(['hello world']); + expect(result.cursorRow).toBe(0); + expect(result.cursorCol).toBe(11); + }); + }); + + describe('C (change to end of line)', () => { + it('should change from cursor to end of line', () => { + const initialState = { + lines: ['hello world test'], + cursorRow: 0, + cursorCol: 6, // cursor on 'w' in "world" + preferredCol: null, + undoStack: [], + redoStack: [], + clipboard: null, + selectionAnchor: null, + }; + + const result = textBufferReducer(initialState, { + type: 'vim_change_to_end_of_line', + }); + + // Should delete "world test", leaving "hello " + expect(result.lines).toEqual(['hello ']); + expect(result.cursorRow).toBe(0); + expect(result.cursorCol).toBe(6); + }); + + it('should handle C at beginning of line', () => { + const initialState = { + lines: ['hello world'], + cursorRow: 0, + cursorCol: 0, + preferredCol: null, + undoStack: [], + redoStack: [], + clipboard: null, + selectionAnchor: null, + }; + + const result = textBufferReducer(initialState, { + type: 'vim_change_to_end_of_line', + }); + + // Should delete entire line content + expect(result.lines).toEqual(['']); + expect(result.cursorRow).toBe(0); + expect(result.cursorCol).toBe(0); + }); + }); + }); +}); 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 + }; +}