summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSijie Wang <[email protected]>2025-07-25 15:36:42 -0700
committerGitHub <[email protected]>2025-07-25 22:36:42 +0000
commitfbdc8d5ab3f76aef32af6a8f516d97771c56a7ac (patch)
tree2167cd9ab4c5a1378d466e735bc41e167ea4d904
parentaa71438684dd0350acf62fc01d1e6244fd4d3f51 (diff)
Vim mode (#3936)
-rw-r--r--docs/cli/commands.md11
-rw-r--r--docs/cli/configuration.md5
-rw-r--r--package-lock.json63
-rw-r--r--packages/cli/src/config/settings.ts3
-rw-r--r--packages/cli/src/services/BuiltinCommandLoader.ts2
-rw-r--r--packages/cli/src/ui/App.tsx162
-rw-r--r--packages/cli/src/ui/commands/types.ts1
-rw-r--r--packages/cli/src/ui/commands/vimCommand.ts25
-rw-r--r--packages/cli/src/ui/components/Footer.tsx3
-rw-r--r--packages/cli/src/ui/components/InputPrompt.test.tsx45
-rw-r--r--packages/cli/src/ui/components/InputPrompt.tsx7
-rw-r--r--packages/cli/src/ui/components/shared/text-buffer.test.ts214
-rw-r--r--packages/cli/src/ui/components/shared/text-buffer.ts799
-rw-r--r--packages/cli/src/ui/components/shared/vim-buffer-actions.test.ts796
-rw-r--r--packages/cli/src/ui/components/shared/vim-buffer-actions.ts887
-rw-r--r--packages/cli/src/ui/contexts/VimModeContext.tsx79
-rw-r--r--packages/cli/src/ui/hooks/slashCommandProcessor.test.ts1
-rw-r--r--packages/cli/src/ui/hooks/slashCommandProcessor.ts3
-rw-r--r--packages/cli/src/ui/hooks/useKeypress.ts7
-rw-r--r--packages/cli/src/ui/hooks/vim.test.ts1626
-rw-r--r--packages/cli/src/ui/hooks/vim.ts774
21 files changed, 5323 insertions, 190 deletions
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<string, SummarizeToolOutputSettings>;
+ 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) => (
<SessionStatsProvider>
- <App {...props} />
+ <VimModeProvider settings={props.settings}>
+ <App {...props} />
+ </VimModeProvider>
</SessionStatsProvider>
);
@@ -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 {
+ streamingState,
+ submitQuery,
+ initError,
+ pendingHistoryItems: pendingGeminiHistoryItems,
+ thought,
+ } = useGeminiStream(
+ config.getGeminiClient(),
+ history,
+ addItem,
+ setShowHelp,
+ config,
+ setDebugMessage,
+ handleSlashCommand,
+ shellModeActive,
+ getPreferredEditor,
+ onAuthError,
+ performMemoryRefresh,
+ modelSwitchedFromQuotaError,
+ setModelSwitchedFromQuotaError,
+ );
- const widthFraction = 0.9;
- const inputWidth = Math.max(
- 20,
- Math.floor(terminalWidth * widthFraction) - 3,
+ // 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<string[]>([]);
@@ -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 (
<StreamingContext.Provider value={streamingState}>
<Box flexDirection="column" marginBottom={1} width="90%">
@@ -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}
/>
</Box>
</Box>
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<boolean>;
};
// 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<FooterProps> = ({
@@ -39,6 +40,7 @@ export const Footer: React.FC<FooterProps> = ({
showMemoryUsage,
promptTokenCount,
nightly,
+ vimMode,
}) => {
const limit = tokenLimit(model);
const percentage = promptTokenCount / limit;
@@ -46,6 +48,7 @@ export const Footer: React.FC<FooterProps> = ({
return (
<Box marginTop={1} justifyContent="space-between" width="100%">
<Box>
+ {vimMode && <Text color={Colors.Gray}>[{vimMode}] </Text>}
{nightly ? (
<Gradient colors={Colors.GradientColors}>
<Text>
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(<InputPrompt {...props} />);
+ 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(<InputPrompt {...props} />);
+ 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(<InputPrompt {...props} />);
+ 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<InputPromptProps> = ({
@@ -55,6 +56,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
suggestionsWidth,
shellModeActive,
setShellModeActive,
+ vimHandleInput,
}) => {
const [justNavigatedHistory, setJustNavigatedHistory] = useState(false);
@@ -169,6 +171,10 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
return;
}
+ if (vimHandleInput && vimHandleInput(key)) {
+ return;
+ }
+
if (
key.sequence === '!' &&
buffer.text === '' &&
@@ -347,6 +353,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
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<void> => {
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<boolean>;
+ setVimMode: (mode: VimMode) => void;
+}
+
+const VimModeContext = createContext<VimModeContextType | undefined>(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<VimMode>(
+ 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 (
+ <VimModeContext.Provider value={value}>{children}</VimModeContext.Provider>
+ );
+};
+
+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<boolean>,
) => {
const session = useSessionStats();
const [commands, setCommands] = useState<readonly SlashCommand[]>([]);
@@ -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<TextBuffer>;
+ 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<TextBuffer>) =>
+ 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<string, 'h' | 'j' | 'k' | 'l'> = {
+ [CMD_TYPES.CHANGE_MOVEMENT.LEFT]: 'h',
+ [CMD_TYPES.CHANGE_MOVEMENT.DOWN]: 'j',
+ [CMD_TYPES.CHANGE_MOVEMENT.UP]: 'k',
+ [CMD_TYPES.CHANGE_MOVEMENT.RIGHT]: 'l',
+ };
+ const movementType = movementMap[cmdType];
+ if (movementType) {
+ buffer.vimChangeMovement(movementType, count);
+ updateMode('INSERT');
+ }
+ break;
+ }
+
+ case CMD_TYPES.DELETE_TO_EOL: {
+ buffer.vimDeleteToEndOfLine();
+ break;
+ }
+
+ case CMD_TYPES.CHANGE_TO_EOL: {
+ buffer.vimChangeToEndOfLine();
+ updateMode('INSERT');
+ break;
+ }
+
+ default:
+ return false;
+ }
+ return true;
+ },
+ [buffer, updateMode],
+ );
+
+ /**
+ * Handles key input in INSERT mode
+ * @param normalizedKey - The normalized key input
+ * @returns boolean indicating if the key was handled
+ */
+ const handleInsertModeInput = useCallback(
+ (normalizedKey: Key): boolean => {
+ // Handle escape key immediately - switch to NORMAL mode on any escape
+ if (normalizedKey.name === 'escape') {
+ // Vim behavior: move cursor left when exiting insert mode (unless at beginning of line)
+ buffer.vimEscapeInsertMode();
+ dispatch({ type: 'ESCAPE_TO_NORMAL' });
+ updateMode('NORMAL');
+ return true;
+ }
+
+ // In INSERT mode, let InputPrompt handle completion keys and special commands
+ if (
+ normalizedKey.name === 'tab' ||
+ (normalizedKey.name === 'return' && !normalizedKey.ctrl) ||
+ normalizedKey.name === 'up' ||
+ normalizedKey.name === 'down'
+ ) {
+ 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
+ };
+}