From 680077631d1f1213cd4a01272ddbb5b6061bdda1 Mon Sep 17 00:00:00 2001
From: Vroot <99136557+ame2en@users.noreply.github.com>
Date: Tue, 10 Mar 2026 01:43:14 +0530
Subject: [PATCH 001/145] fix(docs): fix headless mode docs (#21287)
Co-authored-by: Sam Roberts <158088236+g-samroberts@users.noreply.github.com>
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
---
docs/cli/cli-reference.md | 11 ++++++-----
docs/cli/headless.md | 2 +-
docs/cli/tutorials/automation.md | 23 ++++++++++++-----------
3 files changed, 19 insertions(+), 17 deletions(-)
diff --git a/docs/cli/cli-reference.md b/docs/cli/cli-reference.md
index 6cafb7dd52..167801ca05 100644
--- a/docs/cli/cli-reference.md
+++ b/docs/cli/cli-reference.md
@@ -8,7 +8,8 @@ and parameters.
| Command | Description | Example |
| ---------------------------------- | ---------------------------------- | ------------------------------------------------------------ |
| `gemini` | Start interactive REPL | `gemini` |
-| `gemini "query"` | Query non-interactively, then exit | `gemini "explain this project"` |
+| `gemini -p "query"` | Query non-interactively | `gemini -p "summarize README.md"` |
+| `gemini "query"` | Query and continue interactively | `gemini "explain this project"` |
| `cat file \| gemini` | Process piped content | `cat logs.txt \| gemini`
`Get-Content logs.txt \| gemini` |
| `gemini -i "query"` | Execute and continue interactively | `gemini -i "What is the purpose of this project?"` |
| `gemini -r "latest"` | Continue most recent session | `gemini -r "latest"` |
@@ -20,9 +21,9 @@ and parameters.
### Positional arguments
-| Argument | Type | Description |
-| -------- | ----------------- | ------------------------------------------------------------------------------------------------------------------ |
-| `query` | string (variadic) | Positional prompt. Defaults to one-shot mode. Use `-i/--prompt-interactive` to execute and continue interactively. |
+| Argument | Type | Description |
+| -------- | ----------------- | ---------------------------------------------------------------------------------------------------------- |
+| `query` | string (variadic) | Positional prompt. Defaults to interactive mode in a TTY. Use `-p/--prompt` for non-interactive execution. |
## Interactive commands
@@ -47,7 +48,7 @@ These commands are available within the interactive REPL.
| `--version` | `-v` | - | - | Show CLI version number and exit |
| `--help` | `-h` | - | - | Show help information |
| `--model` | `-m` | string | `auto` | Model to use. See [Model Selection](#model-selection) for available values. |
-| `--prompt` | `-p` | string | - | Prompt text. Appended to stdin input if provided. **Deprecated:** Use positional arguments instead. |
+| `--prompt` | `-p` | string | - | Prompt text. Appended to stdin input if provided. Forces non-interactive mode. |
| `--prompt-interactive` | `-i` | string | - | Execute prompt and continue in interactive mode |
| `--sandbox` | `-s` | boolean | `false` | Run in a sandboxed environment for safer execution |
| `--approval-mode` | - | string | `default` | Approval mode for tool execution. Choices: `default`, `auto_edit`, `yolo` |
diff --git a/docs/cli/headless.md b/docs/cli/headless.md
index 7de3287639..dd9a385313 100644
--- a/docs/cli/headless.md
+++ b/docs/cli/headless.md
@@ -6,7 +6,7 @@ structured text or JSON output without an interactive terminal UI.
## Technical reference
Headless mode is triggered when the CLI is run in a non-TTY environment or when
-providing a query as a positional argument without the interactive flag.
+providing a query with the `-p` (or `--prompt`) flag.
### Output formats
diff --git a/docs/cli/tutorials/automation.md b/docs/cli/tutorials/automation.md
index fb1d8d48d2..4285cdcf3b 100644
--- a/docs/cli/tutorials/automation.md
+++ b/docs/cli/tutorials/automation.md
@@ -19,14 +19,15 @@ Headless mode runs Gemini CLI once and exits. It's perfect for:
## How to use headless mode
-Run Gemini CLI in headless mode by providing a prompt as a positional argument.
-This bypasses the interactive chat interface and prints the response to standard
-output (stdout).
+Run Gemini CLI in headless mode by providing a prompt with the `-p` (or
+`--prompt`) flag. This bypasses the interactive chat interface and prints the
+response to standard output (stdout). Positional arguments without the flag
+default to interactive mode, unless the input or output is piped or redirected.
Run a single command:
```bash
-gemini "Write a poem about TypeScript"
+gemini -p "Write a poem about TypeScript"
```
## How to pipe input to Gemini CLI
@@ -40,19 +41,19 @@ Pipe a file:
**macOS/Linux**
```bash
-cat error.log | gemini "Explain why this failed"
+cat error.log | gemini -p "Explain why this failed"
```
**Windows (PowerShell)**
```powershell
-Get-Content error.log | gemini "Explain why this failed"
+Get-Content error.log | gemini -p "Explain why this failed"
```
Pipe a command:
```bash
-git diff | gemini "Write a commit message for these changes"
+git diff | gemini -p "Write a commit message for these changes"
```
## Use Gemini CLI output in scripts
@@ -78,7 +79,7 @@ one.
echo "Generating docs for $file..."
# Ask Gemini CLI to generate the documentation and print it to stdout
- gemini "Generate a Markdown documentation summary for @$file. Print the
+ gemini -p "Generate a Markdown documentation summary for @$file. Print the
result to standard output." > "${file%.py}.md"
done
```
@@ -92,7 +93,7 @@ one.
$newName = $_.Name -replace '\.py$', '.md'
# Ask Gemini CLI to generate the documentation and print it to stdout
- gemini "Generate a Markdown documentation summary for @$($_.Name). Print the result to standard output." | Out-File -FilePath $newName -Encoding utf8
+ gemini -p "Generate a Markdown documentation summary for @$($_.Name). Print the result to standard output." | Out-File -FilePath $newName -Encoding utf8
}
```
@@ -214,7 +215,7 @@ wrapper that writes the message for you.
# Ask Gemini to write the message
echo "Generating commit message..."
- msg=$(echo "$diff" | gemini "Write a concise Conventional Commit message for this diff. Output ONLY the message.")
+ msg=$(echo "$diff" | gemini -p "Write a concise Conventional Commit message for this diff. Output ONLY the message.")
# Commit with the generated message
git commit -m "$msg"
@@ -251,7 +252,7 @@ wrapper that writes the message for you.
# Ask Gemini to write the message
Write-Host "Generating commit message..."
- $msg = $diff | gemini "Write a concise Conventional Commit message for this diff. Output ONLY the message."
+ $msg = $diff | gemini -p "Write a concise Conventional Commit message for this diff. Output ONLY the message."
# Commit with the generated message
git commit -m "$msg"
From e406dcc24907b293363320bd7b3dff59f319c449 Mon Sep 17 00:00:00 2001
From: Jacob Richman
Date: Mon, 9 Mar 2026 13:40:46 -0700
Subject: [PATCH 002/145] feat/redesign header compact (#20922)
---
packages/cli/src/ui/components/AppHeader.tsx | 18 ++++++-
.../src/ui/components/AppHeaderIcon.test.tsx | 49 +++++++++++++++++++
.../src/ui/components/UserIdentity.test.tsx | 18 +++++++
.../cli/src/ui/components/UserIdentity.tsx | 9 ++--
...efault-icon-in-standard-terminals.snap.svg | 30 ++++++++++++
...-symmetric-icon-in-Apple-Terminal.snap.svg | 31 ++++++++++++
.../__snapshots__/AppHeaderIcon.test.tsx.snap | 31 ++++++++++++
7 files changed, 180 insertions(+), 6 deletions(-)
create mode 100644 packages/cli/src/ui/components/AppHeaderIcon.test.tsx
create mode 100644 packages/cli/src/ui/components/__snapshots__/AppHeaderIcon-AppHeader-Icon-Rendering-renders-the-default-icon-in-standard-terminals.snap.svg
create mode 100644 packages/cli/src/ui/components/__snapshots__/AppHeaderIcon-AppHeader-Icon-Rendering-renders-the-symmetric-icon-in-Apple-Terminal.snap.svg
create mode 100644 packages/cli/src/ui/components/__snapshots__/AppHeaderIcon.test.tsx.snap
diff --git a/packages/cli/src/ui/components/AppHeader.tsx b/packages/cli/src/ui/components/AppHeader.tsx
index b9601e772a..0b15f917a6 100644
--- a/packages/cli/src/ui/components/AppHeader.tsx
+++ b/packages/cli/src/ui/components/AppHeader.tsx
@@ -17,16 +17,30 @@ import { theme } from '../semantic-colors.js';
import { ThemedGradient } from './ThemedGradient.js';
import { CliSpinner } from './CliSpinner.js';
+import { isAppleTerminal } from '@google/gemini-cli-core';
+
interface AppHeaderProps {
version: string;
showDetails?: boolean;
}
-const ICON = `▝▜▄
+const DEFAULT_ICON = `▝▜▄
▝▜▄
▗▟▀
▝▀ `;
+/**
+ * The default Apple Terminal.app adds significant line-height padding between
+ * rows. This breaks Unicode block-drawing characters that rely on vertical
+ * adjacency (like half-blocks). This version is perfectly symmetric vertically,
+ * which makes the padding gaps look like an intentional "scanline" design
+ * rather than a broken image.
+ */
+const MAC_TERMINAL_ICON = `▝▜▄
+ ▝▜▄
+ ▗▟▀
+▗▟▀ `;
+
export const AppHeader = ({ version, showDetails = true }: AppHeaderProps) => {
const settings = useSettings();
const config = useConfig();
@@ -39,6 +53,8 @@ export const AppHeader = ({ version, showDetails = true }: AppHeaderProps) => {
settings.merged.ui.hideBanner || config.getScreenReader()
);
+ const ICON = isAppleTerminal() ? MAC_TERMINAL_ICON : DEFAULT_ICON;
+
if (!showDetails) {
return (
diff --git a/packages/cli/src/ui/components/AppHeaderIcon.test.tsx b/packages/cli/src/ui/components/AppHeaderIcon.test.tsx
new file mode 100644
index 0000000000..c16febea66
--- /dev/null
+++ b/packages/cli/src/ui/components/AppHeaderIcon.test.tsx
@@ -0,0 +1,49 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { renderWithProviders } from '../../test-utils/render.js';
+import { AppHeader } from './AppHeader.js';
+
+// We mock the entire module to control the isAppleTerminal export
+vi.mock('@google/gemini-cli-core', async (importOriginal) => {
+ const actual =
+ await importOriginal();
+ return {
+ ...actual,
+ isAppleTerminal: vi.fn(),
+ };
+});
+
+import { isAppleTerminal } from '@google/gemini-cli-core';
+
+describe('AppHeader Icon Rendering', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.unstubAllEnvs();
+ });
+
+ it('renders the default icon in standard terminals', async () => {
+ vi.mocked(isAppleTerminal).mockReturnValue(false);
+
+ const result = renderWithProviders();
+ await result.waitUntilReady();
+
+ await expect(result).toMatchSvgSnapshot();
+ });
+
+ it('renders the symmetric icon in Apple Terminal', async () => {
+ vi.mocked(isAppleTerminal).mockReturnValue(true);
+
+ const result = renderWithProviders();
+ await result.waitUntilReady();
+
+ await expect(result).toMatchSvgSnapshot();
+ });
+});
diff --git a/packages/cli/src/ui/components/UserIdentity.test.tsx b/packages/cli/src/ui/components/UserIdentity.test.tsx
index 5391944d26..aa7f4d3da2 100644
--- a/packages/cli/src/ui/components/UserIdentity.test.tsx
+++ b/packages/cli/src/ui/components/UserIdentity.test.tsx
@@ -51,6 +51,24 @@ describe('', () => {
unmount();
});
+ it('should render the user email on the very first frame (regression test)', () => {
+ const mockConfig = makeFakeConfig();
+ vi.spyOn(mockConfig, 'getContentGeneratorConfig').mockReturnValue({
+ authType: AuthType.LOGIN_WITH_GOOGLE,
+ model: 'gemini-pro',
+ } as unknown as ContentGeneratorConfig);
+ vi.spyOn(mockConfig, 'getUserTierName').mockReturnValue(undefined);
+
+ const { lastFrameRaw, unmount } = renderWithProviders(
+ ,
+ );
+
+ // Assert immediately on the first available frame before any async ticks happen
+ const output = lastFrameRaw();
+ expect(output).toContain('test@example.com');
+ unmount();
+ });
+
it('should render login message if email is missing', async () => {
// Modify the mock for this specific test
vi.mocked(UserAccountManager).mockImplementationOnce(
diff --git a/packages/cli/src/ui/components/UserIdentity.tsx b/packages/cli/src/ui/components/UserIdentity.tsx
index 98c62ec68f..7b07a4f91c 100644
--- a/packages/cli/src/ui/components/UserIdentity.tsx
+++ b/packages/cli/src/ui/components/UserIdentity.tsx
@@ -5,7 +5,7 @@
*/
import type React from 'react';
-import { useMemo, useEffect, useState } from 'react';
+import { useMemo } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import {
@@ -20,13 +20,12 @@ interface UserIdentityProps {
export const UserIdentity: React.FC = ({ config }) => {
const authType = config.getContentGeneratorConfig()?.authType;
- const [email, setEmail] = useState();
-
- useEffect(() => {
+ const email = useMemo(() => {
if (authType) {
const userAccountManager = new UserAccountManager();
- setEmail(userAccountManager.getCachedGoogleAccount() ?? undefined);
+ return userAccountManager.getCachedGoogleAccount() ?? undefined;
}
+ return undefined;
}, [authType]);
const tierName = useMemo(
diff --git a/packages/cli/src/ui/components/__snapshots__/AppHeaderIcon-AppHeader-Icon-Rendering-renders-the-default-icon-in-standard-terminals.snap.svg b/packages/cli/src/ui/components/__snapshots__/AppHeaderIcon-AppHeader-Icon-Rendering-renders-the-default-icon-in-standard-terminals.snap.svg
new file mode 100644
index 0000000000..4e9d0e67a5
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/AppHeaderIcon-AppHeader-Icon-Rendering-renders-the-default-icon-in-standard-terminals.snap.svg
@@ -0,0 +1,30 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/AppHeaderIcon-AppHeader-Icon-Rendering-renders-the-symmetric-icon-in-Apple-Terminal.snap.svg b/packages/cli/src/ui/components/__snapshots__/AppHeaderIcon-AppHeader-Icon-Rendering-renders-the-symmetric-icon-in-Apple-Terminal.snap.svg
new file mode 100644
index 0000000000..fa8373acc7
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/AppHeaderIcon-AppHeader-Icon-Rendering-renders-the-symmetric-icon-in-Apple-Terminal.snap.svg
@@ -0,0 +1,31 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/ui/components/__snapshots__/AppHeaderIcon.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/AppHeaderIcon.test.tsx.snap
new file mode 100644
index 0000000000..2bb5276ee8
--- /dev/null
+++ b/packages/cli/src/ui/components/__snapshots__/AppHeaderIcon.test.tsx.snap
@@ -0,0 +1,31 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`AppHeader Icon Rendering > renders the default icon in standard terminals 1`] = `
+"
+ ▝▜▄ Gemini CLI v1.0.0
+ ▝▜▄
+ ▗▟▀
+ ▝▀
+
+
+Tips for getting started:
+1. Create GEMINI.md files to customize your interactions
+2. /help for more information
+3. Ask coding questions, edit code or run commands
+4. Be specific for the best results"
+`;
+
+exports[`AppHeader Icon Rendering > renders the symmetric icon in Apple Terminal 1`] = `
+"
+ ▝▜▄ Gemini CLI v1.0.0
+ ▝▜▄
+ ▗▟▀
+ ▗▟▀
+
+
+Tips for getting started:
+1. Create GEMINI.md files to customize your interactions
+2. /help for more information
+3. Ask coding questions, edit code or run commands
+4. Be specific for the best results"
+`;
From ab64b15d5137005af1f26f3d5187279a3b731874 Mon Sep 17 00:00:00 2001
From: Tommaso Sciortino
Date: Mon, 9 Mar 2026 20:48:09 +0000
Subject: [PATCH 003/145] refactor: migrate to useKeyMatchers hook (#21753)
---
packages/cli/src/ui/AppContainer.tsx | 11 +++++++---
packages/cli/src/ui/auth/ApiAuthDialog.tsx | 4 +++-
.../components/AdminSettingsChangedDialog.tsx | 4 +++-
.../cli/src/ui/components/AskUserDialog.tsx | 14 +++++++++----
.../ui/components/BackgroundShellDisplay.tsx | 4 +++-
.../ui/components/ExitPlanModeDialog.test.tsx | 4 +++-
.../src/ui/components/ExitPlanModeDialog.tsx | 4 +++-
.../src/ui/components/FooterConfigDialog.tsx | 4 +++-
.../cli/src/ui/components/HooksDialog.tsx | 4 +++-
.../src/ui/components/InputPrompt.test.tsx | 4 ++--
.../cli/src/ui/components/InputPrompt.tsx | 11 +++++++---
.../src/ui/components/PolicyUpdateDialog.tsx | 4 +++-
.../src/ui/components/RewindConfirmation.tsx | 4 +++-
.../cli/src/ui/components/RewindViewer.tsx | 4 +++-
.../src/ui/components/ShellInputPrompt.tsx | 12 +++++++++--
.../src/ui/components/ValidationDialog.tsx | 4 +++-
.../messages/ToolConfirmationMessage.tsx | 4 +++-
.../components/shared/BaseSettingsDialog.tsx | 4 +++-
.../src/ui/components/shared/Scrollable.tsx | 4 +++-
.../ui/components/shared/ScrollableList.tsx | 4 +++-
.../ui/components/shared/SearchableList.tsx | 4 +++-
.../src/ui/components/shared/TextInput.tsx | 6 ++++--
.../src/ui/components/shared/text-buffer.ts | 5 ++++-
.../ui/components/triage/TriageDuplicates.tsx | 4 +++-
.../src/ui/components/triage/TriageIssues.tsx | 4 +++-
.../src/ui/hooks/useApprovalModeIndicator.ts | 4 +++-
packages/cli/src/ui/hooks/useKeyMatchers.ts | 17 ++++++++++++++++
packages/cli/src/ui/hooks/useSelectionList.ts | 6 ++++--
.../src/ui/hooks/useTabbedNavigation.test.ts | 20 +++++++++++++------
.../cli/src/ui/hooks/useTabbedNavigation.ts | 5 ++++-
packages/cli/src/ui/hooks/vim.ts | 7 +++++--
packages/cli/src/ui/keyMatchers.test.ts | 10 +++++++---
packages/cli/src/ui/keyMatchers.ts | 3 ++-
packages/cli/src/ui/utils/shortcutsHelp.ts | 9 ++++++---
34 files changed, 162 insertions(+), 54 deletions(-)
create mode 100644 packages/cli/src/ui/hooks/useKeyMatchers.ts
diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx
index 67f2d5dd84..dfa2d4af86 100644
--- a/packages/cli/src/ui/AppContainer.tsx
+++ b/packages/cli/src/ui/AppContainer.tsx
@@ -119,7 +119,7 @@ import { type InitializationResult } from '../core/initializer.js';
import { useFocus } from './hooks/useFocus.js';
import { useKeypress, type Key } from './hooks/useKeypress.js';
import { KeypressPriority } from './contexts/KeypressContext.js';
-import { keyMatchers, Command } from './keyMatchers.js';
+import { Command } from './keyMatchers.js';
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js';
import { useFolderTrust } from './hooks/useFolderTrust.js';
@@ -164,7 +164,7 @@ import { NewAgentsChoice } from './components/NewAgentsNotification.js';
import { isSlashCommand } from './utils/commandUtils.js';
import { useTerminalTheme } from './hooks/useTerminalTheme.js';
import { useTimedMessage } from './hooks/useTimedMessage.js';
-import { shouldDismissShortcutsHelpOnHotkey } from './utils/shortcutsHelp.js';
+import { useIsHelpDismissKey } from './utils/shortcutsHelp.js';
import { useSuspend } from './hooks/useSuspend.js';
import { useRunEventNotifications } from './hooks/useRunEventNotifications.js';
import { isNotificationsEnabled } from '../utils/terminalNotifications.js';
@@ -205,6 +205,7 @@ import {
useVisibilityToggle,
APPROVAL_MODE_REVEAL_DURATION_MS,
} from './hooks/useVisibilityToggle.js';
+import { useKeyMatchers } from './hooks/useKeyMatchers.js';
/**
* The fraction of the terminal width to allocate to the shell.
@@ -219,6 +220,8 @@ const SHELL_WIDTH_FRACTION = 0.89;
const SHELL_HEIGHT_PADDING = 10;
export const AppContainer = (props: AppContainerProps) => {
+ const isHelpDismissKey = useIsHelpDismissKey();
+ const keyMatchers = useKeyMatchers();
const { config, initializationResult, resumedSessionData } = props;
const settings = useSettings();
const { reset } = useOverflowActions()!;
@@ -1654,7 +1657,7 @@ Logging in with Google... Restarting Gemini CLI to continue.
debugLogger.log('[DEBUG] Keystroke:', JSON.stringify(key));
}
- if (shortcutsHelpVisible && shouldDismissShortcutsHelpOnHotkey(key)) {
+ if (shortcutsHelpVisible && isHelpDismissKey(key)) {
setShortcutsHelpVisible(false);
}
@@ -1848,6 +1851,8 @@ Logging in with Google... Restarting Gemini CLI to continue.
settings.merged.general.devtools,
showErrorDetails,
triggerExpandHint,
+ keyMatchers,
+ isHelpDismissKey,
],
);
diff --git a/packages/cli/src/ui/auth/ApiAuthDialog.tsx b/packages/cli/src/ui/auth/ApiAuthDialog.tsx
index 2caad6fd27..a62d34c866 100644
--- a/packages/cli/src/ui/auth/ApiAuthDialog.tsx
+++ b/packages/cli/src/ui/auth/ApiAuthDialog.tsx
@@ -13,7 +13,8 @@ import { useTextBuffer } from '../components/shared/text-buffer.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { clearApiKey, debugLogger } from '@google/gemini-cli-core';
import { useKeypress } from '../hooks/useKeypress.js';
-import { keyMatchers, Command } from '../keyMatchers.js';
+import { Command } from '../keyMatchers.js';
+import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
interface ApiAuthDialogProps {
onSubmit: (apiKey: string) => void;
@@ -28,6 +29,7 @@ export function ApiAuthDialog({
error,
defaultValue = '',
}: ApiAuthDialogProps): React.JSX.Element {
+ const keyMatchers = useKeyMatchers();
const { terminalWidth } = useUIState();
const viewportWidth = terminalWidth - 8;
diff --git a/packages/cli/src/ui/components/AdminSettingsChangedDialog.tsx b/packages/cli/src/ui/components/AdminSettingsChangedDialog.tsx
index b697dc17c4..2507d31f2b 100644
--- a/packages/cli/src/ui/components/AdminSettingsChangedDialog.tsx
+++ b/packages/cli/src/ui/components/AdminSettingsChangedDialog.tsx
@@ -8,9 +8,11 @@ import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
-import { Command, keyMatchers } from '../keyMatchers.js';
+import { Command } from '../keyMatchers.js';
+import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
export const AdminSettingsChangedDialog = () => {
+ const keyMatchers = useKeyMatchers();
const { handleRestart } = useUIActions();
useKeypress(
diff --git a/packages/cli/src/ui/components/AskUserDialog.tsx b/packages/cli/src/ui/components/AskUserDialog.tsx
index 284e4e1df8..e55617a724 100644
--- a/packages/cli/src/ui/components/AskUserDialog.tsx
+++ b/packages/cli/src/ui/components/AskUserDialog.tsx
@@ -20,7 +20,7 @@ import { BaseSelectionList } from './shared/BaseSelectionList.js';
import type { SelectionListItem } from '../hooks/useSelectionList.js';
import { TabHeader, type Tab } from './shared/TabHeader.js';
import { useKeypress, type Key } from '../hooks/useKeypress.js';
-import { keyMatchers, Command } from '../keyMatchers.js';
+import { Command } from '../keyMatchers.js';
import { checkExhaustive } from '@google/gemini-cli-core';
import { TextInput } from './shared/TextInput.js';
import { formatCommand } from '../utils/keybindingUtils.js';
@@ -36,6 +36,7 @@ import { RenderInline } from '../utils/InlineMarkdownRenderer.js';
import { MaxSizedBox } from './shared/MaxSizedBox.js';
import { UIStateContext } from '../contexts/UIStateContext.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
+import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
/** Padding for dialog content to prevent text from touching edges. */
const DIALOG_PADDING = 4;
@@ -208,6 +209,7 @@ const ReviewView: React.FC = ({
progressHeader,
extraParts,
}) => {
+ const keyMatchers = useKeyMatchers();
const unansweredCount = questions.length - Object.keys(answers).length;
const hasUnanswered = unansweredCount > 0;
@@ -288,6 +290,7 @@ const TextQuestionView: React.FC = ({
progressHeader,
keyboardHints,
}) => {
+ const keyMatchers = useKeyMatchers();
const isAlternateBuffer = useAlternateBuffer();
const prefix = '> ';
const horizontalPadding = 1; // 1 for cursor
@@ -325,7 +328,7 @@ const TextQuestionView: React.FC = ({
}
return false;
},
- [buffer, textValue],
+ [buffer, textValue, keyMatchers],
);
useKeypress(handleExtraKeys, { isActive: true, priority: true });
@@ -487,6 +490,7 @@ const ChoiceQuestionView: React.FC = ({
progressHeader,
keyboardHints,
}) => {
+ const keyMatchers = useKeyMatchers();
const isAlternateBuffer = useAlternateBuffer();
const numOptions =
(question.options?.length ?? 0) + (question.type !== 'yesno' ? 1 : 0);
@@ -680,6 +684,7 @@ const ChoiceQuestionView: React.FC = ({
customBuffer,
onEditingCustomOption,
customOptionText,
+ keyMatchers,
],
);
@@ -950,6 +955,7 @@ export const AskUserDialog: React.FC = ({
availableHeight: availableHeightProp,
extraParts,
}) => {
+ const keyMatchers = useKeyMatchers();
const uiState = useContext(UIStateContext);
const availableHeight =
availableHeightProp ??
@@ -999,7 +1005,7 @@ export const AskUserDialog: React.FC = ({
}
return false;
},
- [onCancel, submitted, isEditingCustomOption],
+ [onCancel, submitted, isEditingCustomOption, keyMatchers],
);
useKeypress(handleCancel, {
@@ -1032,7 +1038,7 @@ export const AskUserDialog: React.FC = ({
}
return false;
},
- [questions.length, submitted, goToNextTab, goToPrevTab],
+ [questions.length, submitted, goToNextTab, goToPrevTab, keyMatchers],
);
useKeypress(handleNavigation, {
diff --git a/packages/cli/src/ui/components/BackgroundShellDisplay.tsx b/packages/cli/src/ui/components/BackgroundShellDisplay.tsx
index 16093ef0d7..946e062c19 100644
--- a/packages/cli/src/ui/components/BackgroundShellDisplay.tsx
+++ b/packages/cli/src/ui/components/BackgroundShellDisplay.tsx
@@ -16,7 +16,7 @@ import {
} from '@google/gemini-cli-core';
import { cpLen, cpSlice, getCachedStringWidth } from '../utils/textUtils.js';
import { type BackgroundShell } from '../hooks/shellCommandProcessor.js';
-import { Command, keyMatchers } from '../keyMatchers.js';
+import { Command } from '../keyMatchers.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { formatCommand } from '../utils/keybindingUtils.js';
import {
@@ -30,6 +30,7 @@ import {
RadioButtonSelect,
type RadioSelectItem,
} from './shared/RadioButtonSelect.js';
+import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
interface BackgroundShellDisplayProps {
shells: Map;
@@ -60,6 +61,7 @@ export const BackgroundShellDisplay = ({
isFocused,
isListOpenProp,
}: BackgroundShellDisplayProps) => {
+ const keyMatchers = useKeyMatchers();
const {
dismissBackgroundShell,
setActiveBackgroundShellPid,
diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx
index 2bf1f723a6..35d0d2e719 100644
--- a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx
+++ b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx
@@ -10,7 +10,7 @@ import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { ExitPlanModeDialog } from './ExitPlanModeDialog.js';
import { useKeypress } from '../hooks/useKeypress.js';
-import { keyMatchers, Command } from '../keyMatchers.js';
+import { Command } from '../keyMatchers.js';
import {
ApprovalMode,
validatePlanContent,
@@ -18,6 +18,7 @@ import {
type FileSystemService,
} from '@google/gemini-cli-core';
import * as fs from 'node:fs';
+import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
vi.mock('../utils/editorUtils.js', () => ({
openFileInEditor: vi.fn(),
@@ -402,6 +403,7 @@ Implement a comprehensive authentication system with multiple providers.
}: {
children: React.ReactNode;
}) => {
+ const keyMatchers = useKeyMatchers();
useKeypress(
(key) => {
if (keyMatchers[Command.QUIT](key)) {
diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.tsx
index 39e1b8a155..d5f1983c14 100644
--- a/packages/cli/src/ui/components/ExitPlanModeDialog.tsx
+++ b/packages/cli/src/ui/components/ExitPlanModeDialog.tsx
@@ -22,8 +22,9 @@ import { useConfig } from '../contexts/ConfigContext.js';
import { AskUserDialog } from './AskUserDialog.js';
import { openFileInEditor } from '../utils/editorUtils.js';
import { useKeypress } from '../hooks/useKeypress.js';
-import { keyMatchers, Command } from '../keyMatchers.js';
+import { Command } from '../keyMatchers.js';
import { formatCommand } from '../utils/keybindingUtils.js';
+import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
export interface ExitPlanModeDialogProps {
planPath: string;
@@ -147,6 +148,7 @@ export const ExitPlanModeDialog: React.FC = ({
width,
availableHeight,
}) => {
+ const keyMatchers = useKeyMatchers();
const config = useConfig();
const { stdin, setRawMode } = useStdin();
const planState = usePlanContent(planPath, config);
diff --git a/packages/cli/src/ui/components/FooterConfigDialog.tsx b/packages/cli/src/ui/components/FooterConfigDialog.tsx
index c31dc73e45..03560d4e21 100644
--- a/packages/cli/src/ui/components/FooterConfigDialog.tsx
+++ b/packages/cli/src/ui/components/FooterConfigDialog.tsx
@@ -11,13 +11,14 @@ import { theme } from '../semantic-colors.js';
import { useSettingsStore } from '../contexts/SettingsContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useKeypress, type Key } from '../hooks/useKeypress.js';
-import { keyMatchers, Command } from '../keyMatchers.js';
+import { Command } from '../keyMatchers.js';
import { FooterRow, type FooterRowItem } from './Footer.js';
import { ALL_ITEMS, resolveFooterState } from '../../config/footerItems.js';
import { SettingScope } from '../../config/settings.js';
import { BaseSelectionList } from './shared/BaseSelectionList.js';
import type { SelectionListItem } from '../hooks/useSelectionList.js';
import { DialogFooter } from './shared/DialogFooter.js';
+import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
interface FooterConfigDialogProps {
onClose?: () => void;
@@ -82,6 +83,7 @@ function footerConfigReducer(
export const FooterConfigDialog: React.FC = ({
onClose,
}) => {
+ const keyMatchers = useKeyMatchers();
const { settings, setSetting } = useSettingsStore();
const { constrainHeight, terminalHeight, staticExtraHeight } = useUIState();
const [state, dispatch] = useReducer(footerConfigReducer, undefined, () =>
diff --git a/packages/cli/src/ui/components/HooksDialog.tsx b/packages/cli/src/ui/components/HooksDialog.tsx
index d820aba6e7..4fd7b9ff9d 100644
--- a/packages/cli/src/ui/components/HooksDialog.tsx
+++ b/packages/cli/src/ui/components/HooksDialog.tsx
@@ -9,7 +9,8 @@ import { useState, useMemo } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
-import { keyMatchers, Command } from '../keyMatchers.js';
+import { Command } from '../keyMatchers.js';
+import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
/**
* Hook entry type matching HookRegistryEntry from core
@@ -49,6 +50,7 @@ export const HooksDialog: React.FC = ({
onClose,
maxVisibleHooks = DEFAULT_MAX_VISIBLE_HOOKS,
}) => {
+ const keyMatchers = useKeyMatchers();
const [scrollOffset, setScrollOffset] = useState(0);
// Flatten hooks with their event names for easier scrolling
diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx
index b8148b0bef..85e6b8d6aa 100644
--- a/packages/cli/src/ui/components/InputPrompt.test.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.test.tsx
@@ -44,7 +44,7 @@ import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js
import type { UIState } from '../contexts/UIStateContext.js';
import { isLowColorDepth } from '../utils/terminalUtils.js';
import { cpLen } from '../utils/textUtils.js';
-import { keyMatchers, Command } from '../keyMatchers.js';
+import { defaultKeyMatchers, Command } from '../keyMatchers.js';
import type { Key } from '../hooks/useKeypress.js';
import {
appEvents,
@@ -197,7 +197,7 @@ describe('InputPrompt', () => {
visualCursor: [0, 0],
visualScrollRow: 0,
handleInput: vi.fn((key: Key) => {
- if (keyMatchers[Command.CLEAR_INPUT](key)) {
+ if (defaultKeyMatchers[Command.CLEAR_INPUT](key)) {
if (mockBuffer.text.length > 0) {
mockBuffer.setText('');
return true;
diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx
index 373571f07d..1d82c87f70 100644
--- a/packages/cli/src/ui/components/InputPrompt.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.tsx
@@ -36,7 +36,7 @@ import {
} from '../hooks/useCommandCompletion.js';
import type { Key } from '../hooks/useKeypress.js';
import { useKeypress } from '../hooks/useKeypress.js';
-import { keyMatchers, Command } from '../keyMatchers.js';
+import { Command } from '../keyMatchers.js';
import { formatCommand } from '../utils/keybindingUtils.js';
import type { CommandContext, SlashCommand } from '../commands/types.js';
import type { Config } from '@google/gemini-cli-core';
@@ -72,8 +72,9 @@ import { useMouseClick } from '../hooks/useMouseClick.js';
import { useMouse, type MouseEvent } from '../contexts/MouseContext.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
import { useAlternateBuffer } from '../hooks/useAlternateBuffer.js';
-import { shouldDismissShortcutsHelpOnHotkey } from '../utils/shortcutsHelp.js';
+import { useIsHelpDismissKey } from '../utils/shortcutsHelp.js';
import { useRepeatedKeyPress } from '../hooks/useRepeatedKeyPress.js';
+import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
/**
* Returns if the terminal can be trusted to handle paste events atomically
@@ -207,6 +208,8 @@ export const InputPrompt: React.FC = ({
suggestionsPosition = 'below',
setBannerVisible,
}) => {
+ const isHelpDismissKey = useIsHelpDismissKey();
+ const keyMatchers = useKeyMatchers();
const { stdout } = useStdout();
const { merged: settings } = useSettings();
const kittyProtocol = useKittyKeyboardProtocol();
@@ -737,7 +740,7 @@ export const InputPrompt: React.FC = ({
return true;
}
- if (shortcutsHelpVisible && shouldDismissShortcutsHelpOnHotkey(key)) {
+ if (shortcutsHelpVisible && isHelpDismissKey(key)) {
setShortcutsHelpVisible(false);
}
@@ -1265,6 +1268,8 @@ export const InputPrompt: React.FC = ({
shouldShowSuggestions,
isShellSuggestionsVisible,
forceShowShellSuggestions,
+ keyMatchers,
+ isHelpDismissKey,
],
);
diff --git a/packages/cli/src/ui/components/PolicyUpdateDialog.tsx b/packages/cli/src/ui/components/PolicyUpdateDialog.tsx
index e6ed75c4db..ad48571fff 100644
--- a/packages/cli/src/ui/components/PolicyUpdateDialog.tsx
+++ b/packages/cli/src/ui/components/PolicyUpdateDialog.tsx
@@ -16,7 +16,8 @@ import { theme } from '../semantic-colors.js';
import type { RadioSelectItem } from './shared/RadioButtonSelect.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { useKeypress } from '../hooks/useKeypress.js';
-import { keyMatchers, Command } from '../keyMatchers.js';
+import { Command } from '../keyMatchers.js';
+import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
export enum PolicyUpdateChoice {
ACCEPT = 'accept',
@@ -34,6 +35,7 @@ export const PolicyUpdateDialog: React.FC = ({
request,
onClose,
}) => {
+ const keyMatchers = useKeyMatchers();
const isProcessing = useRef(false);
const handleSelect = useCallback(
diff --git a/packages/cli/src/ui/components/RewindConfirmation.tsx b/packages/cli/src/ui/components/RewindConfirmation.tsx
index bbfbf9dbee..fa58995731 100644
--- a/packages/cli/src/ui/components/RewindConfirmation.tsx
+++ b/packages/cli/src/ui/components/RewindConfirmation.tsx
@@ -13,7 +13,8 @@ import type { RadioSelectItem } from './shared/RadioButtonSelect.js';
import type { FileChangeStats } from '../utils/rewindFileOps.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { formatTimeAgo } from '../utils/formatters.js';
-import { keyMatchers, Command } from '../keyMatchers.js';
+import { Command } from '../keyMatchers.js';
+import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
export enum RewindOutcome {
RewindAndRevert = 'rewind_and_revert',
@@ -58,6 +59,7 @@ export const RewindConfirmation: React.FC = ({
terminalWidth,
timestamp,
}) => {
+ const keyMatchers = useKeyMatchers();
const isScreenReaderEnabled = useIsScreenReaderEnabled();
useKeypress(
(key) => {
diff --git a/packages/cli/src/ui/components/RewindViewer.tsx b/packages/cli/src/ui/components/RewindViewer.tsx
index 26f7282f61..0a9f858d3d 100644
--- a/packages/cli/src/ui/components/RewindViewer.tsx
+++ b/packages/cli/src/ui/components/RewindViewer.tsx
@@ -19,9 +19,10 @@ import { useKeypress } from '../hooks/useKeypress.js';
import { useRewind } from '../hooks/useRewind.js';
import { RewindConfirmation, RewindOutcome } from './RewindConfirmation.js';
import { stripReferenceContent } from '../utils/formatters.js';
-import { keyMatchers, Command } from '../keyMatchers.js';
+import { Command } from '../keyMatchers.js';
import { CliSpinner } from './CliSpinner.js';
import { ExpandableText } from './shared/ExpandableText.js';
+import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
interface RewindViewerProps {
conversation: ConversationRecord;
@@ -48,6 +49,7 @@ export const RewindViewer: React.FC = ({
onExit,
onRewind,
}) => {
+ const keyMatchers = useKeyMatchers();
const [isRewinding, setIsRewinding] = useState(false);
const { terminalWidth, terminalHeight } = useUIState();
const isScreenReaderEnabled = useIsScreenReaderEnabled();
diff --git a/packages/cli/src/ui/components/ShellInputPrompt.tsx b/packages/cli/src/ui/components/ShellInputPrompt.tsx
index 26e32d946f..dae0f65312 100644
--- a/packages/cli/src/ui/components/ShellInputPrompt.tsx
+++ b/packages/cli/src/ui/components/ShellInputPrompt.tsx
@@ -10,7 +10,8 @@ import { useKeypress } from '../hooks/useKeypress.js';
import { ShellExecutionService } from '@google/gemini-cli-core';
import { keyToAnsi, type Key } from '../hooks/keyToAnsi.js';
import { ACTIVE_SHELL_MAX_LINES } from '../constants.js';
-import { Command, keyMatchers } from '../keyMatchers.js';
+import { Command } from '../keyMatchers.js';
+import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
export interface ShellInputPromptProps {
activeShellPtyId: number | null;
@@ -23,6 +24,7 @@ export const ShellInputPrompt: React.FC = ({
focus = true,
scrollPageSize = ACTIVE_SHELL_MAX_LINES,
}) => {
+ const keyMatchers = useKeyMatchers();
const handleShellInputSubmit = useCallback(
(input: string) => {
if (activeShellPtyId) {
@@ -73,7 +75,13 @@ export const ShellInputPrompt: React.FC = ({
return false;
},
- [focus, handleShellInputSubmit, activeShellPtyId, scrollPageSize],
+ [
+ focus,
+ handleShellInputSubmit,
+ activeShellPtyId,
+ scrollPageSize,
+ keyMatchers,
+ ],
);
useKeypress(handleInput, { isActive: focus });
diff --git a/packages/cli/src/ui/components/ValidationDialog.tsx b/packages/cli/src/ui/components/ValidationDialog.tsx
index 6e126ea4ef..f94de6b86d 100644
--- a/packages/cli/src/ui/components/ValidationDialog.tsx
+++ b/packages/cli/src/ui/components/ValidationDialog.tsx
@@ -16,7 +16,8 @@ import {
type ValidationIntent,
} from '@google/gemini-cli-core';
import { useKeypress } from '../hooks/useKeypress.js';
-import { keyMatchers, Command } from '../keyMatchers.js';
+import { Command } from '../keyMatchers.js';
+import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
interface ValidationDialogProps {
validationLink?: string;
@@ -32,6 +33,7 @@ export function ValidationDialog({
learnMoreUrl,
onChoice,
}: ValidationDialogProps): React.JSX.Element {
+ const keyMatchers = useKeyMatchers();
const [state, setState] = useState('choosing');
const [errorMessage, setErrorMessage] = useState('');
diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
index b97a29565b..1ace75633c 100644
--- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
+++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
@@ -29,7 +29,7 @@ import {
import { useKeypress } from '../../hooks/useKeypress.js';
import { theme } from '../../semantic-colors.js';
import { useSettings } from '../../contexts/SettingsContext.js';
-import { keyMatchers, Command } from '../../keyMatchers.js';
+import { Command } from '../../keyMatchers.js';
import { formatCommand } from '../../utils/keybindingUtils.js';
import { AskUserDialog } from '../AskUserDialog.js';
import { ExitPlanModeDialog } from '../ExitPlanModeDialog.js';
@@ -40,6 +40,7 @@ import {
toUnicodeUrl,
type DeceptiveUrlDetails,
} from '../../utils/urlSecurityUtils.js';
+import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
export interface ToolConfirmationMessageProps {
callId: string;
@@ -67,6 +68,7 @@ export const ToolConfirmationMessage: React.FC<
availableTerminalHeight,
terminalWidth,
}) => {
+ const keyMatchers = useKeyMatchers();
const { confirm, isDiffingEnabled } = useToolActions();
const [mcpDetailsExpansionState, setMcpDetailsExpansionState] = useState<{
callId: string;
diff --git a/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx b/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx
index bccde9766d..45dda8b38c 100644
--- a/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx
+++ b/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx
@@ -19,10 +19,11 @@ import { TextInput } from './TextInput.js';
import type { TextBuffer } from './text-buffer.js';
import { cpSlice, cpLen, cpIndexToOffset } from '../../utils/textUtils.js';
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
-import { keyMatchers, Command } from '../../keyMatchers.js';
+import { Command } from '../../keyMatchers.js';
import { useSettingsNavigation } from '../../hooks/useSettingsNavigation.js';
import { useInlineEditBuffer } from '../../hooks/useInlineEditBuffer.js';
import { formatCommand } from '../../utils/keybindingUtils.js';
+import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
/**
* Represents a single item in the settings dialog.
@@ -136,6 +137,7 @@ export function BaseSettingsDialog({
availableHeight,
footer,
}: BaseSettingsDialogProps): React.JSX.Element {
+ const keyMatchers = useKeyMatchers();
// Calculate effective max items and scope visibility based on terminal height
const { effectiveMaxItemsToShow, finalShowScopeSelector } = useMemo(() => {
const initialShowScope = showScopeSelector;
diff --git a/packages/cli/src/ui/components/shared/Scrollable.tsx b/packages/cli/src/ui/components/shared/Scrollable.tsx
index a7227c7087..a1f9be0b7c 100644
--- a/packages/cli/src/ui/components/shared/Scrollable.tsx
+++ b/packages/cli/src/ui/components/shared/Scrollable.tsx
@@ -19,8 +19,9 @@ import { useKeypress, type Key } from '../../hooks/useKeypress.js';
import { useScrollable } from '../../contexts/ScrollProvider.js';
import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js';
import { useBatchedScroll } from '../../hooks/useBatchedScroll.js';
-import { keyMatchers, Command } from '../../keyMatchers.js';
+import { Command } from '../../keyMatchers.js';
import { useOverflowActions } from '../../contexts/OverflowContext.js';
+import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
interface ScrollableProps {
children?: React.ReactNode;
@@ -45,6 +46,7 @@ export const Scrollable: React.FC = ({
flexGrow,
reportOverflow = false,
}) => {
+ const keyMatchers = useKeyMatchers();
const [scrollTop, setScrollTop] = useState(0);
const viewportRef = useRef(null);
const contentRef = useRef(null);
diff --git a/packages/cli/src/ui/components/shared/ScrollableList.tsx b/packages/cli/src/ui/components/shared/ScrollableList.tsx
index b7085329a3..33a3f72310 100644
--- a/packages/cli/src/ui/components/shared/ScrollableList.tsx
+++ b/packages/cli/src/ui/components/shared/ScrollableList.tsx
@@ -22,7 +22,8 @@ import { useScrollable } from '../../contexts/ScrollProvider.js';
import { Box, type DOMElement } from 'ink';
import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js';
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
-import { keyMatchers, Command } from '../../keyMatchers.js';
+import { Command } from '../../keyMatchers.js';
+import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
const ANIMATION_FRAME_DURATION_MS = 33;
@@ -46,6 +47,7 @@ function ScrollableList(
props: ScrollableListProps,
ref: React.Ref>,
) {
+ const keyMatchers = useKeyMatchers();
const { hasFocus, width } = props;
const virtualizedListRef = useRef>(null);
const containerRef = useRef(null);
diff --git a/packages/cli/src/ui/components/shared/SearchableList.tsx b/packages/cli/src/ui/components/shared/SearchableList.tsx
index 1611bc2842..046040af90 100644
--- a/packages/cli/src/ui/components/shared/SearchableList.tsx
+++ b/packages/cli/src/ui/components/shared/SearchableList.tsx
@@ -11,7 +11,8 @@ import { useSelectionList } from '../../hooks/useSelectionList.js';
import { TextInput } from './TextInput.js';
import type { TextBuffer } from './text-buffer.js';
import { useKeypress } from '../../hooks/useKeypress.js';
-import { keyMatchers, Command } from '../../keyMatchers.js';
+import { Command } from '../../keyMatchers.js';
+import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
/**
* Generic interface for items in a searchable list.
@@ -85,6 +86,7 @@ export function SearchableList({
onSearch,
resetSelectionOnItemsChange = false,
}: SearchableListProps): React.JSX.Element {
+ const keyMatchers = useKeyMatchers();
const { filteredItems, searchBuffer, maxLabelWidth } = useSearch({
items,
onSearch,
diff --git a/packages/cli/src/ui/components/shared/TextInput.tsx b/packages/cli/src/ui/components/shared/TextInput.tsx
index 8a4745eea7..cc3fcaeb8d 100644
--- a/packages/cli/src/ui/components/shared/TextInput.tsx
+++ b/packages/cli/src/ui/components/shared/TextInput.tsx
@@ -14,7 +14,8 @@ import { theme } from '../../semantic-colors.js';
import type { TextBuffer } from './text-buffer.js';
import { expandPastePlaceholders } from './text-buffer.js';
import { cpSlice, cpIndexToOffset } from '../../utils/textUtils.js';
-import { keyMatchers, Command } from '../../keyMatchers.js';
+import { Command } from '../../keyMatchers.js';
+import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
export interface TextInputProps {
buffer: TextBuffer;
@@ -31,6 +32,7 @@ export function TextInput({
onCancel,
focus = true,
}: TextInputProps): React.JSX.Element {
+ const keyMatchers = useKeyMatchers();
const {
text,
handleInput,
@@ -55,7 +57,7 @@ export function TextInput({
const handled = handleInput(key);
return handled;
},
- [handleInput, onCancel, onSubmit, text, buffer.pastedContent],
+ [handleInput, onCancel, onSubmit, text, buffer.pastedContent, keyMatchers],
);
useKeypress(handleKeyPress, { isActive: focus, priority: true });
diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts
index 34d757a61b..808fc8a554 100644
--- a/packages/cli/src/ui/components/shared/text-buffer.ts
+++ b/packages/cli/src/ui/components/shared/text-buffer.ts
@@ -25,11 +25,12 @@ import {
} from '../../utils/textUtils.js';
import { parsePastedPaths } from '../../utils/clipboardUtils.js';
import type { Key } from '../../contexts/KeypressContext.js';
-import { keyMatchers, Command } from '../../keyMatchers.js';
+import { Command } from '../../keyMatchers.js';
import type { VimAction } from './vim-buffer-actions.js';
import { handleVimAction } from './vim-buffer-actions.js';
import { LRU_BUFFER_PERF_CACHE_LIMIT } from '../../constants.js';
import { openFileInEditor } from '../../utils/editorUtils.js';
+import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
export const LARGE_PASTE_LINE_THRESHOLD = 5;
export const LARGE_PASTE_CHAR_THRESHOLD = 500;
@@ -2708,6 +2709,7 @@ export function useTextBuffer({
singleLine = false,
getPreferredEditor,
}: UseTextBufferProps): TextBuffer {
+ const keyMatchers = useKeyMatchers();
const initialState = useMemo((): TextBufferState => {
const lines = initialText.split('\n');
const [initialCursorRow, initialCursorCol] = calculateInitialCursorPosition(
@@ -3270,6 +3272,7 @@ export function useTextBuffer({
text,
visualCursor,
visualLines,
+ keyMatchers,
],
);
diff --git a/packages/cli/src/ui/components/triage/TriageDuplicates.tsx b/packages/cli/src/ui/components/triage/TriageDuplicates.tsx
index 878cacfed0..4de6568189 100644
--- a/packages/cli/src/ui/components/triage/TriageDuplicates.tsx
+++ b/packages/cli/src/ui/components/triage/TriageDuplicates.tsx
@@ -10,7 +10,8 @@ import Spinner from 'ink-spinner';
import type { Config } from '@google/gemini-cli-core';
import { debugLogger, spawnAsync, LlmRole } from '@google/gemini-cli-core';
import { useKeypress } from '../../hooks/useKeypress.js';
-import { keyMatchers, Command } from '../../keyMatchers.js';
+import { Command } from '../../keyMatchers.js';
+import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
interface Issue {
number: number;
@@ -106,6 +107,7 @@ export const TriageDuplicates = ({
onExit: () => void;
initialLimit?: number;
}) => {
+ const keyMatchers = useKeyMatchers();
const [state, setState] = useState({
status: 'loading',
issues: [],
diff --git a/packages/cli/src/ui/components/triage/TriageIssues.tsx b/packages/cli/src/ui/components/triage/TriageIssues.tsx
index 595384a124..e6779d6c02 100644
--- a/packages/cli/src/ui/components/triage/TriageIssues.tsx
+++ b/packages/cli/src/ui/components/triage/TriageIssues.tsx
@@ -10,9 +10,10 @@ import Spinner from 'ink-spinner';
import type { Config } from '@google/gemini-cli-core';
import { debugLogger, spawnAsync, LlmRole } from '@google/gemini-cli-core';
import { useKeypress } from '../../hooks/useKeypress.js';
-import { keyMatchers, Command } from '../../keyMatchers.js';
+import { Command } from '../../keyMatchers.js';
import { TextInput } from '../shared/TextInput.js';
import { useTextBuffer } from '../shared/text-buffer.js';
+import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
interface Issue {
number: number;
@@ -69,6 +70,7 @@ export const TriageIssues = ({
initialLimit?: number;
until?: string;
}) => {
+ const keyMatchers = useKeyMatchers();
const [state, setState] = useState({
status: 'loading',
issues: [],
diff --git a/packages/cli/src/ui/hooks/useApprovalModeIndicator.ts b/packages/cli/src/ui/hooks/useApprovalModeIndicator.ts
index 1b5076027f..84e465106f 100644
--- a/packages/cli/src/ui/hooks/useApprovalModeIndicator.ts
+++ b/packages/cli/src/ui/hooks/useApprovalModeIndicator.ts
@@ -11,7 +11,8 @@ import {
getAdminErrorMessage,
} from '@google/gemini-cli-core';
import { useKeypress } from './useKeypress.js';
-import { keyMatchers, Command } from '../keyMatchers.js';
+import { Command } from '../keyMatchers.js';
+import { useKeyMatchers } from './useKeyMatchers.js';
import type { HistoryItemWithoutId } from '../types.js';
import { MessageType } from '../types.js';
@@ -30,6 +31,7 @@ export function useApprovalModeIndicator({
isActive = true,
allowPlanMode = false,
}: UseApprovalModeIndicatorArgs): ApprovalMode {
+ const keyMatchers = useKeyMatchers();
const currentConfigValue = config.getApprovalMode();
const [showApprovalMode, setApprovalMode] = useState(currentConfigValue);
diff --git a/packages/cli/src/ui/hooks/useKeyMatchers.ts b/packages/cli/src/ui/hooks/useKeyMatchers.ts
new file mode 100644
index 0000000000..a42a066ee0
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useKeyMatchers.ts
@@ -0,0 +1,17 @@
+/**
+ * @license
+ * Copyright 2026 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { useMemo } from 'react';
+import type { KeyMatchers } from '../keyMatchers.js';
+import { defaultKeyMatchers } from '../keyMatchers.js';
+
+/**
+ * Hook to retrieve the currently active key matchers.
+ * This prepares the codebase for dynamic or custom key bindings in the future.
+ */
+export function useKeyMatchers(): KeyMatchers {
+ return useMemo(() => defaultKeyMatchers, []);
+}
diff --git a/packages/cli/src/ui/hooks/useSelectionList.ts b/packages/cli/src/ui/hooks/useSelectionList.ts
index f74c1b1dc2..9f73c54da4 100644
--- a/packages/cli/src/ui/hooks/useSelectionList.ts
+++ b/packages/cli/src/ui/hooks/useSelectionList.ts
@@ -6,8 +6,9 @@
import { useReducer, useRef, useEffect, useCallback } from 'react';
import { useKeypress, type Key } from './useKeypress.js';
-import { keyMatchers, Command } from '../keyMatchers.js';
+import { Command } from '../keyMatchers.js';
import { debugLogger } from '@google/gemini-cli-core';
+import { useKeyMatchers } from './useKeyMatchers.js';
export interface SelectionListItem {
key: string;
@@ -290,6 +291,7 @@ export function useSelectionList({
focusKey,
priority,
}: UseSelectionListOptions): UseSelectionListResult {
+ const keyMatchers = useKeyMatchers();
const baseItems = toBaseItems(items);
const [state, dispatch] = useReducer(selectionListReducer, {
@@ -460,7 +462,7 @@ export function useSelectionList({
}
return false;
},
- [dispatch, itemsLength, showNumbers],
+ [dispatch, itemsLength, showNumbers, keyMatchers],
);
useKeypress(handleKeypress, {
diff --git a/packages/cli/src/ui/hooks/useTabbedNavigation.test.ts b/packages/cli/src/ui/hooks/useTabbedNavigation.test.ts
index 5eb1107a4d..e41a89d66d 100644
--- a/packages/cli/src/ui/hooks/useTabbedNavigation.test.ts
+++ b/packages/cli/src/ui/hooks/useTabbedNavigation.test.ts
@@ -9,12 +9,18 @@ import { act } from 'react';
import { renderHook } from '../../test-utils/render.js';
import { useTabbedNavigation } from './useTabbedNavigation.js';
import { useKeypress } from './useKeypress.js';
+import { useKeyMatchers } from './useKeyMatchers.js';
+import type { KeyMatchers } from '../keyMatchers.js';
import type { Key, KeypressHandler } from '../contexts/KeypressContext.js';
vi.mock('./useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
+vi.mock('./useKeyMatchers.js', () => ({
+ useKeyMatchers: vi.fn(),
+}));
+
const createKey = (partial: Partial): Key => ({
name: partial.name || '',
sequence: partial.sequence || '',
@@ -26,13 +32,14 @@ const createKey = (partial: Partial): Key => ({
...partial,
});
+const mockKeyMatchers = {
+ 'cursor.left': vi.fn((key) => key.name === 'left'),
+ 'cursor.right': vi.fn((key) => key.name === 'right'),
+ 'dialog.next': vi.fn((key) => key.name === 'tab' && !key.shift),
+ 'dialog.previous': vi.fn((key) => key.name === 'tab' && key.shift),
+} as unknown as KeyMatchers;
+
vi.mock('../keyMatchers.js', () => ({
- keyMatchers: {
- 'cursor.left': vi.fn((key) => key.name === 'left'),
- 'cursor.right': vi.fn((key) => key.name === 'right'),
- 'dialog.next': vi.fn((key) => key.name === 'tab' && !key.shift),
- 'dialog.previous': vi.fn((key) => key.name === 'tab' && key.shift),
- },
Command: {
MOVE_LEFT: 'cursor.left',
MOVE_RIGHT: 'cursor.right',
@@ -45,6 +52,7 @@ describe('useTabbedNavigation', () => {
let capturedHandler: KeypressHandler;
beforeEach(() => {
+ vi.mocked(useKeyMatchers).mockReturnValue(mockKeyMatchers);
vi.mocked(useKeypress).mockImplementation((handler) => {
capturedHandler = handler;
});
diff --git a/packages/cli/src/ui/hooks/useTabbedNavigation.ts b/packages/cli/src/ui/hooks/useTabbedNavigation.ts
index b4ed73264c..d7e406ce6b 100644
--- a/packages/cli/src/ui/hooks/useTabbedNavigation.ts
+++ b/packages/cli/src/ui/hooks/useTabbedNavigation.ts
@@ -6,7 +6,8 @@
import { useReducer, useCallback, useEffect, useRef } from 'react';
import { useKeypress, type Key } from './useKeypress.js';
-import { keyMatchers, Command } from '../keyMatchers.js';
+import { Command } from '../keyMatchers.js';
+import { useKeyMatchers } from './useKeyMatchers.js';
/**
* Options for the useTabbedNavigation hook.
@@ -147,6 +148,7 @@ export function useTabbedNavigation({
isActive = true,
onTabChange,
}: UseTabbedNavigationOptions): UseTabbedNavigationResult {
+ const keyMatchers = useKeyMatchers();
const [state, dispatch] = useReducer(tabbedNavigationReducer, {
currentIndex: Math.max(0, Math.min(initialIndex, tabCount - 1)),
tabCount,
@@ -231,6 +233,7 @@ export function useTabbedNavigation({
goToNextTab,
goToPrevTab,
isNavigationBlocked,
+ keyMatchers,
],
);
diff --git a/packages/cli/src/ui/hooks/vim.ts b/packages/cli/src/ui/hooks/vim.ts
index 9de771564c..1fcc0c61ca 100644
--- a/packages/cli/src/ui/hooks/vim.ts
+++ b/packages/cli/src/ui/hooks/vim.ts
@@ -9,7 +9,8 @@ import type { Key } from './useKeypress.js';
import type { TextBuffer } from '../components/shared/text-buffer.js';
import { useVimMode } from '../contexts/VimModeContext.js';
import { debugLogger } from '@google/gemini-cli-core';
-import { keyMatchers, Command } from '../keyMatchers.js';
+import { Command } from '../keyMatchers.js';
+import { useKeyMatchers } from './useKeyMatchers.js';
export type VimMode = 'NORMAL' | 'INSERT';
@@ -152,6 +153,7 @@ const vimReducer = (state: VimState, action: VimAction): VimState => {
* @returns Object with vim state and input handler
*/
export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
+ const keyMatchers = useKeyMatchers();
const { vimEnabled, vimMode, setVimMode } = useVimMode();
const [state, dispatch] = useReducer(vimReducer, initialVimState);
@@ -439,7 +441,7 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
return buffer.handleInput(normalizedKey);
},
- [buffer, dispatch, updateMode, onSubmit, checkDoubleEscape],
+ [buffer, dispatch, updateMode, onSubmit, checkDoubleEscape, keyMatchers],
);
/**
@@ -1202,6 +1204,7 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) {
executeCommand,
updateMode,
checkDoubleEscape,
+ keyMatchers,
],
);
diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/keyMatchers.test.ts
index 888393be83..e90f6334be 100644
--- a/packages/cli/src/ui/keyMatchers.test.ts
+++ b/packages/cli/src/ui/keyMatchers.test.ts
@@ -5,7 +5,11 @@
*/
import { describe, it, expect } from 'vitest';
-import { keyMatchers, Command, createKeyMatchers } from './keyMatchers.js';
+import {
+ defaultKeyMatchers,
+ Command,
+ createKeyMatchers,
+} from './keyMatchers.js';
import type { KeyBindingConfig } from '../config/keyBindings.js';
import { defaultKeyBindings } from '../config/keyBindings.js';
import type { Key } from './hooks/useKeypress.js';
@@ -422,14 +426,14 @@ describe('keyMatchers', () => {
it(`should match ${command} correctly`, () => {
positive.forEach((key) => {
expect(
- keyMatchers[command](key),
+ defaultKeyMatchers[command](key),
`Expected ${command} to match ${JSON.stringify(key)}`,
).toBe(true);
});
negative.forEach((key) => {
expect(
- keyMatchers[command](key),
+ defaultKeyMatchers[command](key),
`Expected ${command} to NOT match ${JSON.stringify(key)}`,
).toBe(false);
});
diff --git a/packages/cli/src/ui/keyMatchers.ts b/packages/cli/src/ui/keyMatchers.ts
index f833e5ee09..259f1edd9e 100644
--- a/packages/cli/src/ui/keyMatchers.ts
+++ b/packages/cli/src/ui/keyMatchers.ts
@@ -68,7 +68,8 @@ export function createKeyMatchers(
/**
* Default key binding matchers using the default configuration
*/
-export const keyMatchers: KeyMatchers = createKeyMatchers(defaultKeyBindings);
+export const defaultKeyMatchers: KeyMatchers =
+ createKeyMatchers(defaultKeyBindings);
// Re-export Command for convenience
export { Command };
diff --git a/packages/cli/src/ui/utils/shortcutsHelp.ts b/packages/cli/src/ui/utils/shortcutsHelp.ts
index 65ab8f2a13..a5f6d22e19 100644
--- a/packages/cli/src/ui/utils/shortcutsHelp.ts
+++ b/packages/cli/src/ui/utils/shortcutsHelp.ts
@@ -4,9 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { Command, keyMatchers } from '../keyMatchers.js';
+import { Command } from '../keyMatchers.js';
import type { Key } from '../hooks/useKeypress.js';
+import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
-export function shouldDismissShortcutsHelpOnHotkey(key: Key): boolean {
- return Object.values(Command).some((command) => keyMatchers[command](key));
+export function useIsHelpDismissKey(): (key: Key) => boolean {
+ const keyMatchers = useKeyMatchers();
+ return (key: Key) =>
+ Object.values(Command).some((command) => keyMatchers[command](key));
}
From 1fd42802be781c79e08f62da8056fef0863a7c50 Mon Sep 17 00:00:00 2001
From: Sehoon Shon
Date: Mon, 9 Mar 2026 17:33:16 -0400
Subject: [PATCH 004/145] perf(cli): cache loadSettings to reduce redundant
disk I/O at startup (#21521)
---
packages/cli/src/config/extension.test.ts | 2 +
packages/cli/src/config/settings.test.ts | 381 ++++++++++++------
packages/cli/src/config/settings.ts | 28 ++
.../settings_validation_warning.test.ts | 2 +
packages/cli/src/test-utils/AppRig.tsx | 6 +-
.../src/ui/hooks/useExtensionUpdates.test.tsx | 6 +-
6 files changed, 295 insertions(+), 130 deletions(-)
diff --git a/packages/cli/src/config/extension.test.ts b/packages/cli/src/config/extension.test.ts
index f8e66bf8e2..38264b285a 100644
--- a/packages/cli/src/config/extension.test.ts
+++ b/packages/cli/src/config/extension.test.ts
@@ -31,6 +31,7 @@ import {
loadSettings,
createTestMergedSettings,
SettingScope,
+ resetSettingsCacheForTesting,
} from './settings.js';
import {
isWorkspaceTrusted,
@@ -161,6 +162,7 @@ describe('extension tests', () => {
beforeEach(() => {
vi.clearAllMocks();
+ resetSettingsCacheForTesting();
keychainData = {};
mockKeychainStorage = {
getSecret: vi
diff --git a/packages/cli/src/config/settings.test.ts b/packages/cli/src/config/settings.test.ts
index 5589ef11ba..7092f26a99 100644
--- a/packages/cli/src/config/settings.test.ts
+++ b/packages/cli/src/config/settings.test.ts
@@ -13,7 +13,7 @@ vi.mock('os', async (importOriginal) => {
const actualOs = await importOriginal();
return {
...actualOs,
- homedir: vi.fn(() => '/mock/home/user'),
+ homedir: vi.fn(() => path.resolve('/mock/home/user')),
platform: vi.fn(() => 'linux'),
};
});
@@ -76,6 +76,7 @@ import {
LoadedSettings,
sanitizeEnvVar,
createTestMergedSettings,
+ resetSettingsCacheForTesting,
} from './settings.js';
import {
FatalConfigError,
@@ -91,7 +92,7 @@ import {
} from './settingsSchema.js';
import { createMockSettings } from '../test-utils/settings.js';
-const MOCK_WORKSPACE_DIR = '/mock/workspace';
+const MOCK_WORKSPACE_DIR = path.resolve(path.resolve('/mock/workspace'));
// Use the (mocked) GEMINI_DIR for consistency
const MOCK_WORKSPACE_SETTINGS_PATH = path.join(
MOCK_WORKSPACE_DIR,
@@ -102,6 +103,10 @@ const MOCK_WORKSPACE_SETTINGS_PATH = path.join(
// A more flexible type for test data that allows arbitrary properties.
type TestSettings = Settings & { [key: string]: unknown };
+// Helper to normalize paths for test assertions, making them OS-agnostic
+const normalizePath = (p: string | fs.PathOrFileDescriptor) =>
+ path.normalize(p.toString());
+
vi.mock('fs', async (importOriginal) => {
// Get all the functions from the real 'fs' module
const actualFs = await importOriginal();
@@ -174,12 +179,15 @@ describe('Settings Loading and Merging', () => {
beforeEach(() => {
vi.resetAllMocks();
+ resetSettingsCacheForTesting();
mockFsExistsSync = vi.mocked(fs.existsSync);
mockFsMkdirSync = vi.mocked(fs.mkdirSync);
mockStripJsonComments = vi.mocked(stripJsonComments);
- vi.mocked(osActual.homedir).mockReturnValue('/mock/home/user');
+ vi.mocked(osActual.homedir).mockReturnValue(
+ path.resolve('/mock/home/user'),
+ );
(mockStripJsonComments as unknown as Mock).mockImplementation(
(jsonString: string) => jsonString,
);
@@ -224,20 +232,25 @@ describe('Settings Loading and Merging', () => {
},
])(
'should load $scope settings if only $scope file exists',
- ({ scope, path, content }) => {
+ ({ scope, path: p, content }) => {
(mockFsExistsSync as Mock).mockImplementation(
- (p: fs.PathLike) => p === path,
+ (pathLike: fs.PathLike) =>
+ path.normalize(pathLike.toString()) === path.normalize(p),
);
(fs.readFileSync as Mock).mockImplementation(
- (p: fs.PathOrFileDescriptor) => {
- if (p === path) return JSON.stringify(content);
+ (pathDesc: fs.PathOrFileDescriptor) => {
+ if (path.normalize(pathDesc.toString()) === path.normalize(p))
+ return JSON.stringify(content);
return '{}';
},
);
const settings = loadSettings(MOCK_WORKSPACE_DIR);
- expect(fs.readFileSync).toHaveBeenCalledWith(path, 'utf-8');
+ expect(fs.readFileSync).toHaveBeenCalledWith(
+ expect.stringContaining(path.basename(p)),
+ 'utf-8',
+ );
expect(
settings[scope as 'system' | 'user' | 'workspace'].settings,
).toEqual(content);
@@ -246,12 +259,14 @@ describe('Settings Loading and Merging', () => {
);
it('should merge system, user and workspace settings, with system taking precedence over workspace, and workspace over user', () => {
- (mockFsExistsSync as Mock).mockImplementation(
- (p: fs.PathLike) =>
- p === getSystemSettingsPath() ||
- p === USER_SETTINGS_PATH ||
- p === MOCK_WORKSPACE_SETTINGS_PATH,
- );
+ (mockFsExistsSync as Mock).mockImplementation((p: fs.PathLike) => {
+ const normP = path.normalize(p.toString());
+ return (
+ normP === path.normalize(getSystemSettingsPath()) ||
+ normP === path.normalize(USER_SETTINGS_PATH) ||
+ normP === path.normalize(MOCK_WORKSPACE_SETTINGS_PATH)
+ );
+ });
const systemSettingsContent = {
ui: {
theme: 'system-theme',
@@ -290,11 +305,12 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
- if (p === getSystemSettingsPath())
+ const normP = path.normalize(p.toString());
+ if (normP === path.normalize(getSystemSettingsPath()))
return JSON.stringify(systemSettingsContent);
- if (p === USER_SETTINGS_PATH)
+ if (normP === path.normalize(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
- if (p === MOCK_WORKSPACE_SETTINGS_PATH)
+ if (normP === path.normalize(MOCK_WORKSPACE_SETTINGS_PATH))
return JSON.stringify(workspaceSettingsContent);
return '';
},
@@ -390,13 +406,13 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
- if (p === getSystemDefaultsPath())
+ if (normalizePath(p) === normalizePath(getSystemDefaultsPath()))
return JSON.stringify(systemDefaultsContent);
- if (p === getSystemSettingsPath())
+ if (normalizePath(p) === normalizePath(getSystemSettingsPath()))
return JSON.stringify(systemSettingsContent);
- if (p === USER_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
- if (p === MOCK_WORKSPACE_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
return JSON.stringify(workspaceSettingsContent);
return '';
},
@@ -449,11 +465,11 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
- if (p === getSystemSettingsPath())
+ if (normalizePath(p) === normalizePath(getSystemSettingsPath()))
return JSON.stringify(systemSettingsContent);
- if (p === USER_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
- if (p === MOCK_WORKSPACE_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
@@ -489,11 +505,11 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
- if (p === getSystemSettingsPath())
+ if (normalizePath(p) === normalizePath(getSystemSettingsPath()))
return JSON.stringify(systemSettingsContent);
- if (p === USER_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
- if (p === MOCK_WORKSPACE_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
@@ -523,11 +539,11 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
- if (p === getSystemSettingsPath())
+ if (normalizePath(p) === normalizePath(getSystemSettingsPath()))
return JSON.stringify(systemSettingsContent);
- if (p === USER_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
- if (p === MOCK_WORKSPACE_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
@@ -576,11 +592,12 @@ describe('Settings Loading and Merging', () => {
'should handle $description correctly',
({ path, content, expected }) => {
(mockFsExistsSync as Mock).mockImplementation(
- (p: fs.PathLike) => p === path,
+ (p: fs.PathLike) => normalizePath(p) === normalizePath(path),
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
- if (p === path) return JSON.stringify(content);
+ if (normalizePath(p) === normalizePath(path))
+ return JSON.stringify(content);
return '{}';
},
);
@@ -598,7 +615,8 @@ describe('Settings Loading and Merging', () => {
it('should merge excludedProjectEnvVars with workspace taking precedence over user', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) =>
- p === USER_SETTINGS_PATH || p === MOCK_WORKSPACE_SETTINGS_PATH,
+ normalizePath(p) === normalizePath(USER_SETTINGS_PATH) ||
+ normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH),
);
const userSettingsContent = {
general: {},
@@ -611,9 +629,9 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
- if (p === USER_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
- if (p === MOCK_WORKSPACE_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
return JSON.stringify(workspaceSettingsContent);
return '';
},
@@ -643,15 +661,16 @@ describe('Settings Loading and Merging', () => {
it('should default contextFileName to undefined if not in any settings file', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) =>
- p === USER_SETTINGS_PATH || p === MOCK_WORKSPACE_SETTINGS_PATH,
+ normalizePath(p) === normalizePath(USER_SETTINGS_PATH) ||
+ normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH),
);
const userSettingsContent = { ui: { theme: 'dark' } };
const workspaceSettingsContent = { tools: { sandbox: true } };
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
- if (p === USER_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
- if (p === MOCK_WORKSPACE_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
return JSON.stringify(workspaceSettingsContent);
return '';
},
@@ -678,11 +697,12 @@ describe('Settings Loading and Merging', () => {
'should load telemetry setting from $scope settings',
({ path, content, expected }) => {
(mockFsExistsSync as Mock).mockImplementation(
- (p: fs.PathLike) => p === path,
+ (p: fs.PathLike) => normalizePath(p) === normalizePath(path),
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
- if (p === path) return JSON.stringify(content);
+ if (normalizePath(p) === normalizePath(path))
+ return JSON.stringify(content);
return '{}';
},
);
@@ -697,9 +717,9 @@ describe('Settings Loading and Merging', () => {
const workspaceSettingsContent = { telemetry: { enabled: false } };
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
- if (p === USER_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
- if (p === MOCK_WORKSPACE_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
@@ -720,7 +740,8 @@ describe('Settings Loading and Merging', () => {
it('should merge MCP servers correctly, with workspace taking precedence', () => {
(mockFsExistsSync as Mock).mockImplementation(
(p: fs.PathLike) =>
- p === USER_SETTINGS_PATH || p === MOCK_WORKSPACE_SETTINGS_PATH,
+ normalizePath(p) === normalizePath(USER_SETTINGS_PATH) ||
+ normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH),
);
const userSettingsContent = {
mcpServers: {
@@ -751,9 +772,9 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
- if (p === USER_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
- if (p === MOCK_WORKSPACE_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
return JSON.stringify(workspaceSettingsContent);
return '';
},
@@ -822,11 +843,12 @@ describe('Settings Loading and Merging', () => {
'should handle MCP servers when only in $scope settings',
({ path, content, expected }) => {
(mockFsExistsSync as Mock).mockImplementation(
- (p: fs.PathLike) => p === path,
+ (p: fs.PathLike) => normalizePath(p) === normalizePath(path),
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
- if (p === path) return JSON.stringify(content);
+ if (normalizePath(p) === normalizePath(path))
+ return JSON.stringify(content);
return '{}';
},
);
@@ -881,11 +903,11 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
- if (p === getSystemSettingsPath())
+ if (normalizePath(p) === normalizePath(getSystemSettingsPath()))
return JSON.stringify(systemSettingsContent);
- if (p === USER_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
- if (p === MOCK_WORKSPACE_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
@@ -932,11 +954,11 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
- if (p === getSystemSettingsPath())
+ if (normalizePath(p) === normalizePath(getSystemSettingsPath()))
return JSON.stringify(systemSettingsContent);
- if (p === USER_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
- if (p === MOCK_WORKSPACE_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
@@ -983,8 +1005,11 @@ describe('Settings Loading and Merging', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
- if (p === USER_SETTINGS_PATH) return JSON.stringify(userContent);
- if (p === MOCK_WORKSPACE_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
+ return JSON.stringify(userContent);
+ if (
+ normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)
+ )
return JSON.stringify(workspaceContent);
return '{}';
},
@@ -1008,9 +1033,9 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
- if (p === USER_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
- if (p === MOCK_WORKSPACE_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
@@ -1038,13 +1063,13 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
- if (p === getSystemSettingsPath())
+ if (normalizePath(p) === normalizePath(getSystemSettingsPath()))
return JSON.stringify(systemSettingsContent);
- if (p === getSystemDefaultsPath())
+ if (normalizePath(p) === normalizePath(getSystemDefaultsPath()))
return JSON.stringify(systemDefaultsContent);
- if (p === USER_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
- if (p === MOCK_WORKSPACE_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
@@ -1073,14 +1098,16 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
- if (p === USER_SETTINGS_PATH) {
+ if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) {
// Simulate JSON.parse throwing for user settings
vi.spyOn(JSON, 'parse').mockImplementationOnce(() => {
throw userReadError;
});
return invalidJsonContent; // Content that would cause JSON.parse to throw
}
- if (p === MOCK_WORKSPACE_SETTINGS_PATH) {
+ if (
+ normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)
+ ) {
// Simulate JSON.parse throwing for workspace settings
vi.spyOn(JSON, 'parse').mockImplementationOnce(() => {
throw workspaceReadError;
@@ -1119,11 +1146,12 @@ describe('Settings Loading and Merging', () => {
someUrl: 'https://test.com/${TEST_API_KEY}',
};
(mockFsExistsSync as Mock).mockImplementation(
- (p: fs.PathLike) => p === USER_SETTINGS_PATH,
+ (p: fs.PathLike) =>
+ normalizePath(p) === normalizePath(USER_SETTINGS_PATH),
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
- if (p === USER_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
return '{}';
},
@@ -1149,11 +1177,12 @@ describe('Settings Loading and Merging', () => {
nested: { value: '$WORKSPACE_ENDPOINT' },
};
(mockFsExistsSync as Mock).mockImplementation(
- (p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH,
+ (p: fs.PathLike) =>
+ normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH),
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
- if (p === MOCK_WORKSPACE_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
@@ -1201,13 +1230,15 @@ describe('Settings Loading and Merging', () => {
(mockFsExistsSync as Mock).mockReturnValue(true);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
- if (p === getSystemSettingsPath()) {
+ if (normalizePath(p) === normalizePath(getSystemSettingsPath())) {
return JSON.stringify(systemSettingsContent);
}
- if (p === USER_SETTINGS_PATH) {
+ if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH)) {
return JSON.stringify(userSettingsContent);
}
- if (p === MOCK_WORKSPACE_SETTINGS_PATH) {
+ if (
+ normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)
+ ) {
return JSON.stringify(workspaceSettingsContent);
}
return '{}';
@@ -1266,9 +1297,9 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
- if (p === USER_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
- if (p === MOCK_WORKSPACE_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
@@ -1280,14 +1311,15 @@ describe('Settings Loading and Merging', () => {
it('should use user dnsResolutionOrder if workspace is not defined', () => {
(mockFsExistsSync as Mock).mockImplementation(
- (p: fs.PathLike) => p === USER_SETTINGS_PATH,
+ (p: fs.PathLike) =>
+ normalizePath(p) === normalizePath(USER_SETTINGS_PATH),
);
const userSettingsContent = {
advanced: { dnsResolutionOrder: 'verbatim' },
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
- if (p === USER_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
return '{}';
},
@@ -1300,11 +1332,12 @@ describe('Settings Loading and Merging', () => {
it('should leave unresolved environment variables as is', () => {
const userSettingsContent: TestSettings = { apiKey: '$UNDEFINED_VAR' };
(mockFsExistsSync as Mock).mockImplementation(
- (p: fs.PathLike) => p === USER_SETTINGS_PATH,
+ (p: fs.PathLike) =>
+ normalizePath(p) === normalizePath(USER_SETTINGS_PATH),
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
- if (p === USER_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
return '{}';
},
@@ -1326,11 +1359,12 @@ describe('Settings Loading and Merging', () => {
path: '/path/$VAR_A/${VAR_B}/end',
};
(mockFsExistsSync as Mock).mockImplementation(
- (p: fs.PathLike) => p === USER_SETTINGS_PATH,
+ (p: fs.PathLike) =>
+ normalizePath(p) === normalizePath(USER_SETTINGS_PATH),
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
- if (p === USER_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
return '{}';
},
@@ -1350,11 +1384,12 @@ describe('Settings Loading and Merging', () => {
list: ['$ITEM_1', '${ITEM_2}', 'literal'],
};
(mockFsExistsSync as Mock).mockImplementation(
- (p: fs.PathLike) => p === USER_SETTINGS_PATH,
+ (p: fs.PathLike) =>
+ normalizePath(p) === normalizePath(USER_SETTINGS_PATH),
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
- if (p === USER_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
return '{}';
},
@@ -1389,11 +1424,12 @@ describe('Settings Loading and Merging', () => {
};
(mockFsExistsSync as Mock).mockImplementation(
- (p: fs.PathLike) => p === USER_SETTINGS_PATH,
+ (p: fs.PathLike) =>
+ normalizePath(p) === normalizePath(USER_SETTINGS_PATH),
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
- if (p === USER_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
return '{}';
},
@@ -1434,11 +1470,12 @@ describe('Settings Loading and Merging', () => {
serverAddress: '${TEST_HOST}:${TEST_PORT}/api',
};
(mockFsExistsSync as Mock).mockImplementation(
- (p: fs.PathLike) => p === USER_SETTINGS_PATH,
+ (p: fs.PathLike) =>
+ normalizePath(p) === normalizePath(USER_SETTINGS_PATH),
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
- if (p === USER_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
return '{}';
},
@@ -1454,7 +1491,9 @@ describe('Settings Loading and Merging', () => {
});
describe('when GEMINI_CLI_SYSTEM_SETTINGS_PATH is set', () => {
- const MOCK_ENV_SYSTEM_SETTINGS_PATH = '/mock/env/system/settings.json';
+ const MOCK_ENV_SYSTEM_SETTINGS_PATH = path.resolve(
+ '/mock/env/system/settings.json',
+ );
beforeEach(() => {
process.env['GEMINI_CLI_SYSTEM_SETTINGS_PATH'] =
@@ -1496,8 +1535,8 @@ describe('Settings Loading and Merging', () => {
});
it('should correctly skip workspace-level loading if workspaceDir is a symlink to home', () => {
- const mockHomeDir = '/mock/home/user';
- const mockSymlinkDir = '/mock/symlink/to/home';
+ const mockHomeDir = path.resolve('/mock/home/user');
+ const mockSymlinkDir = path.resolve('/mock/symlink/to/home');
const mockWorkspaceSettingsPath = path.join(
mockSymlinkDir,
GEMINI_DIR,
@@ -1541,6 +1580,79 @@ describe('Settings Loading and Merging', () => {
isWorkspaceHomeDirSpy.mockRestore();
}
});
+
+ describe('caching', () => {
+ it('should cache loadSettings results', () => {
+ const mockedRead = vi.mocked(fs.readFileSync);
+ mockedRead.mockClear();
+ mockedRead.mockReturnValue('{}');
+ (mockFsExistsSync as Mock).mockReturnValue(true);
+
+ const settings1 = loadSettings(MOCK_WORKSPACE_DIR);
+ const settings2 = loadSettings(MOCK_WORKSPACE_DIR);
+
+ expect(mockedRead).toHaveBeenCalledTimes(5); // system, systemDefaults, user, workspace, and potentially an env file
+ expect(settings1).toBe(settings2);
+ });
+
+ it('should use separate cache for different workspace directories', () => {
+ const mockedRead = vi.mocked(fs.readFileSync);
+ mockedRead.mockClear();
+ mockedRead.mockReturnValue('{}');
+ (mockFsExistsSync as Mock).mockReturnValue(true);
+
+ const workspace1 = path.resolve('/mock/workspace1');
+ const workspace2 = path.resolve('/mock/workspace2');
+
+ const settings1 = loadSettings(workspace1);
+ const settings2 = loadSettings(workspace2);
+
+ expect(mockedRead).toHaveBeenCalledTimes(10); // 5 for each workspace
+ expect(settings1).not.toBe(settings2);
+ });
+
+ it('should clear cache when saveSettings is called for user settings', () => {
+ const mockedRead = vi.mocked(fs.readFileSync);
+ mockedRead.mockClear();
+ mockedRead.mockReturnValue('{}');
+ (mockFsExistsSync as Mock).mockReturnValue(true);
+
+ const settings1 = loadSettings(MOCK_WORKSPACE_DIR);
+ expect(mockedRead).toHaveBeenCalledTimes(5);
+
+ saveSettings(settings1.user);
+
+ const settings2 = loadSettings(MOCK_WORKSPACE_DIR);
+ expect(mockedRead).toHaveBeenCalledTimes(10); // Should have re-read from disk
+ expect(settings1).not.toBe(settings2);
+ });
+
+ it('should clear all caches when saveSettings is called for workspace settings', () => {
+ const mockedRead = vi.mocked(fs.readFileSync);
+ mockedRead.mockClear();
+ mockedRead.mockReturnValue('{}');
+ (mockFsExistsSync as Mock).mockReturnValue(true);
+
+ const workspace1 = path.resolve('/mock/workspace1');
+ const workspace2 = path.resolve('/mock/workspace2');
+
+ const settings1W1 = loadSettings(workspace1);
+ const settings1W2 = loadSettings(workspace2);
+
+ expect(mockedRead).toHaveBeenCalledTimes(10);
+
+ // Save settings for workspace 1
+ saveSettings(settings1W1.workspace);
+
+ const settings2W1 = loadSettings(workspace1);
+ const settings2W2 = loadSettings(workspace2);
+
+ // Both workspace caches should have been cleared and re-read from disk (+10 reads)
+ expect(mockedRead).toHaveBeenCalledTimes(20);
+ expect(settings1W1).not.toBe(settings2W1);
+ expect(settings1W2).not.toBe(settings2W2);
+ });
+ });
});
describe('excludedProjectEnvVars integration', () => {
@@ -1562,12 +1674,13 @@ describe('Settings Loading and Merging', () => {
};
(mockFsExistsSync as Mock).mockImplementation(
- (p: fs.PathLike) => p === MOCK_WORKSPACE_SETTINGS_PATH,
+ (p: fs.PathLike) =>
+ normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH),
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
- if (p === MOCK_WORKSPACE_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
@@ -1578,16 +1691,18 @@ describe('Settings Loading and Merging', () => {
loadSettings as unknown as { findEnvFile: () => string }
).findEnvFile;
(loadSettings as unknown as { findEnvFile: () => string }).findEnvFile =
- () => '/mock/project/.env';
+ () => path.resolve('/mock/project/.env');
// Mock fs.readFileSync for .env file content
const originalReadFileSync = fs.readFileSync;
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
- if (p === '/mock/project/.env') {
+ if (p === path.resolve('/mock/project/.env')) {
return 'DEBUG=true\nDEBUG_MODE=1\nGEMINI_API_KEY=test-key';
}
- if (p === MOCK_WORKSPACE_SETTINGS_PATH) {
+ if (
+ normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH)
+ ) {
return JSON.stringify(workspaceSettingsContent);
}
return '{}';
@@ -1621,12 +1736,13 @@ describe('Settings Loading and Merging', () => {
};
(mockFsExistsSync as Mock).mockImplementation(
- (p: fs.PathLike) => p === USER_SETTINGS_PATH,
+ (p: fs.PathLike) =>
+ normalizePath(p) === normalizePath(USER_SETTINGS_PATH),
);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
- if (p === USER_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
return '{}';
},
@@ -1658,9 +1774,9 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
- if (p === USER_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
- if (p === MOCK_WORKSPACE_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
@@ -1702,9 +1818,9 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
- if (p === USER_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
- if (p === MOCK_WORKSPACE_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
@@ -1734,9 +1850,9 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
- if (p === USER_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
- if (p === MOCK_WORKSPACE_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
@@ -1767,9 +1883,9 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
- if (p === USER_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
- if (p === MOCK_WORKSPACE_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
@@ -1940,9 +2056,9 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
- if (p === USER_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
- if (p === MOCK_WORKSPACE_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(MOCK_WORKSPACE_SETTINGS_PATH))
return JSON.stringify(workspaceSettingsContent);
return '{}';
},
@@ -1966,7 +2082,7 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
- if (p === USER_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
return '{}';
},
@@ -1994,7 +2110,7 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
- if (p === USER_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
return '{}';
},
@@ -2039,7 +2155,7 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
- if (p === USER_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
return '{}';
},
@@ -2226,7 +2342,8 @@ describe('Settings Loading and Merging', () => {
it('should trigger migration automatically during loadSettings', () => {
mockFsExistsSync.mockImplementation(
- (p: fs.PathLike) => p === USER_SETTINGS_PATH,
+ (p: fs.PathLike) =>
+ normalizePath(p) === normalizePath(USER_SETTINGS_PATH),
);
const userSettingsContent = {
general: {
@@ -2235,7 +2352,7 @@ describe('Settings Loading and Merging', () => {
};
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
- if (p === USER_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
return '{}';
},
@@ -2270,10 +2387,10 @@ describe('Settings Loading and Merging', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
- if (p === getSystemSettingsPath()) {
+ if (normalizePath(p) === normalizePath(getSystemSettingsPath())) {
return JSON.stringify(systemSettingsContent);
}
- if (p === getSystemDefaultsPath()) {
+ if (normalizePath(p) === normalizePath(getSystemDefaultsPath())) {
return JSON.stringify(systemDefaultsContent);
}
return '{}';
@@ -2343,7 +2460,7 @@ describe('Settings Loading and Merging', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
- if (p === getSystemSettingsPath()) {
+ if (normalizePath(p) === normalizePath(getSystemSettingsPath())) {
return JSON.stringify(systemSettingsContent);
}
return '{}';
@@ -2394,7 +2511,7 @@ describe('Settings Loading and Merging', () => {
vi.mocked(fs.existsSync).mockReturnValue(true);
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
- if (p === USER_SETTINGS_PATH)
+ if (normalizePath(p) === normalizePath(USER_SETTINGS_PATH))
return JSON.stringify(userSettingsContent);
return '{}';
},
@@ -2430,13 +2547,16 @@ describe('Settings Loading and Merging', () => {
it('should save settings using updateSettingsFilePreservingFormat', () => {
const mockUpdateSettings = vi.mocked(updateSettingsFilePreservingFormat);
const settingsFile = createMockSettings({ ui: { theme: 'dark' } }).user;
- settingsFile.path = '/mock/settings.json';
+ settingsFile.path = path.resolve('/mock/settings.json');
saveSettings(settingsFile);
- expect(mockUpdateSettings).toHaveBeenCalledWith('/mock/settings.json', {
- ui: { theme: 'dark' },
- });
+ expect(mockUpdateSettings).toHaveBeenCalledWith(
+ path.resolve('/mock/settings.json'),
+ {
+ ui: { theme: 'dark' },
+ },
+ );
});
it('should create directory if it does not exist', () => {
@@ -2445,14 +2565,19 @@ describe('Settings Loading and Merging', () => {
mockFsExistsSync.mockReturnValue(false);
const settingsFile = createMockSettings({}).user;
- settingsFile.path = '/mock/new/dir/settings.json';
+ settingsFile.path = path.resolve('/mock/new/dir/settings.json');
saveSettings(settingsFile);
- expect(mockFsExistsSync).toHaveBeenCalledWith('/mock/new/dir');
- expect(mockFsMkdirSync).toHaveBeenCalledWith('/mock/new/dir', {
- recursive: true,
- });
+ expect(mockFsExistsSync).toHaveBeenCalledWith(
+ path.resolve('/mock/new/dir'),
+ );
+ expect(mockFsMkdirSync).toHaveBeenCalledWith(
+ path.resolve('/mock/new/dir'),
+ {
+ recursive: true,
+ },
+ );
});
it('should emit error feedback if saving fails', () => {
@@ -2463,7 +2588,7 @@ describe('Settings Loading and Merging', () => {
});
const settingsFile = createMockSettings({}).user;
- settingsFile.path = '/mock/settings.json';
+ settingsFile.path = path.resolve('/mock/settings.json');
saveSettings(settingsFile);
@@ -2491,7 +2616,7 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
- if (p === getSystemSettingsPath()) {
+ if (normalizePath(p) === normalizePath(getSystemSettingsPath())) {
return JSON.stringify(systemSettingsContent);
}
return '{}';
@@ -2538,7 +2663,7 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
- if (p === getSystemSettingsPath()) {
+ if (normalizePath(p) === normalizePath(getSystemSettingsPath())) {
return JSON.stringify(systemSettingsContent);
}
return '{}';
@@ -2579,7 +2704,7 @@ describe('Settings Loading and Merging', () => {
(fs.readFileSync as Mock).mockImplementation(
(p: fs.PathOrFileDescriptor) => {
- if (p === getSystemSettingsPath()) {
+ if (normalizePath(p) === normalizePath(getSystemSettingsPath())) {
return JSON.stringify(systemSettingsContent);
}
return '{}';
@@ -2694,7 +2819,7 @@ describe('Settings Loading and Merging', () => {
beforeEach(() => {
const emptySettingsFile: SettingsFile = {
- path: '/mock/path',
+ path: path.resolve('/mock/path'),
settings: {},
originalSettings: {},
};
@@ -3019,7 +3144,7 @@ describe('LoadedSettings Isolation and Serializability', () => {
// Create a minimal LoadedSettings instance
const emptyScope = {
- path: '/mock/settings.json',
+ path: path.resolve('/mock/settings.json'),
settings: {},
originalSettings: {},
} as unknown as SettingsFile;
diff --git a/packages/cli/src/config/settings.ts b/packages/cli/src/config/settings.ts
index 422dda6115..a195931803 100644
--- a/packages/cli/src/config/settings.ts
+++ b/packages/cli/src/config/settings.ts
@@ -18,6 +18,7 @@ import {
coreEvents,
homedir,
type AdminControlsSettings,
+ createCache,
} from '@google/gemini-cli-core';
import stripJsonComments from 'strip-json-comments';
import { DefaultLight } from '../ui/themes/builtin/light/default-light.js';
@@ -615,6 +616,20 @@ export function loadEnvironment(
}
}
+// Cache to store the results of loadSettings to avoid redundant disk I/O.
+const settingsCache = createCache({
+ storage: 'map',
+ defaultTtl: 10000, // 10 seconds
+});
+
+/**
+ * Resets the settings cache. Used exclusively for test isolation.
+ * @internal
+ */
+export function resetSettingsCacheForTesting() {
+ settingsCache.clear();
+}
+
/**
* Loads settings from user and workspace directories.
* Project settings override user settings.
@@ -622,6 +637,16 @@ export function loadEnvironment(
export function loadSettings(
workspaceDir: string = process.cwd(),
): LoadedSettings {
+ const normalizedWorkspaceDir = path.resolve(workspaceDir);
+ return settingsCache.getOrCreate(normalizedWorkspaceDir, () =>
+ _doLoadSettings(normalizedWorkspaceDir),
+ );
+}
+
+/**
+ * Internal implementation of the settings loading logic.
+ */
+function _doLoadSettings(workspaceDir: string): LoadedSettings {
let systemSettings: Settings = {};
let systemDefaultSettings: Settings = {};
let userSettings: Settings = {};
@@ -1029,6 +1054,9 @@ export function migrateDeprecatedSettings(
}
export function saveSettings(settingsFile: SettingsFile): void {
+ // Clear the entire cache on any save.
+ settingsCache.clear();
+
try {
// Ensure the directory exists
const dirPath = path.dirname(settingsFile.path);
diff --git a/packages/cli/src/config/settings_validation_warning.test.ts b/packages/cli/src/config/settings_validation_warning.test.ts
index 498f803dd9..435c797d81 100644
--- a/packages/cli/src/config/settings_validation_warning.test.ts
+++ b/packages/cli/src/config/settings_validation_warning.test.ts
@@ -81,6 +81,7 @@ import {
loadSettings,
USER_SETTINGS_PATH,
type LoadedSettings,
+ resetSettingsCacheForTesting,
} from './settings.js';
const MOCK_WORKSPACE_DIR = '/mock/workspace';
@@ -88,6 +89,7 @@ const MOCK_WORKSPACE_DIR = '/mock/workspace';
describe('Settings Validation Warning', () => {
beforeEach(() => {
vi.clearAllMocks();
+ resetSettingsCacheForTesting();
(fs.readFileSync as Mock).mockReturnValue('{}');
(fs.existsSync as Mock).mockReturnValue(false);
});
diff --git a/packages/cli/src/test-utils/AppRig.tsx b/packages/cli/src/test-utils/AppRig.tsx
index 3ff65c4067..a9aea95376 100644
--- a/packages/cli/src/test-utils/AppRig.tsx
+++ b/packages/cli/src/test-utils/AppRig.tsx
@@ -36,7 +36,10 @@ import {
MockShellExecutionService,
} from './MockShellExecutionService.js';
import { createMockSettings } from './settings.js';
-import { type LoadedSettings } from '../config/settings.js';
+import {
+ type LoadedSettings,
+ resetSettingsCacheForTesting,
+} from '../config/settings.js';
import { AuthState, StreamingState } from '../ui/types.js';
import { randomUUID } from 'node:crypto';
import type {
@@ -171,6 +174,7 @@ export class AppRig {
async initialize() {
this.setupEnvironment();
+ resetSettingsCacheForTesting();
this.settings = this.createRigSettings();
const approvalMode =
diff --git a/packages/cli/src/ui/hooks/useExtensionUpdates.test.tsx b/packages/cli/src/ui/hooks/useExtensionUpdates.test.tsx
index a558686bd8..95212b023c 100644
--- a/packages/cli/src/ui/hooks/useExtensionUpdates.test.tsx
+++ b/packages/cli/src/ui/hooks/useExtensionUpdates.test.tsx
@@ -24,7 +24,10 @@ import {
} from '../../config/extensions/update.js';
import { ExtensionUpdateState } from '../state/extensions.js';
import { ExtensionManager } from '../../config/extension-manager.js';
-import { loadSettings } from '../../config/settings.js';
+import {
+ loadSettings,
+ resetSettingsCacheForTesting,
+} from '../../config/settings.js';
vi.mock('os', async (importOriginal) => {
const mockedOs = await importOriginal();
@@ -59,6 +62,7 @@ describe('useExtensionUpdates', () => {
let extensionManager: ExtensionManager;
beforeEach(() => {
+ resetSettingsCacheForTesting();
vi.mocked(loadAgentsFromDirectory).mockResolvedValue({
agents: [],
errors: [],
From f88488d1f90c3649947099d19004a506a41d6e82 Mon Sep 17 00:00:00 2001
From: Muhammad Usman <146759960+muhammadusman586@users.noreply.github.com>
Date: Tue, 10 Mar 2026 03:40:22 +0500
Subject: [PATCH 005/145] fix(core): resolve Windows line ending and path
separation bugs across CLI (#21068)
---
packages/cli/src/ui/components/messages/DiffRenderer.tsx | 2 +-
packages/cli/src/ui/utils/CodeColorizer.tsx | 4 ++--
packages/core/src/tools/read-many-files.test.ts | 2 +-
packages/core/src/utils/fileUtils.ts | 3 +--
4 files changed, 5 insertions(+), 6 deletions(-)
diff --git a/packages/cli/src/ui/components/messages/DiffRenderer.tsx b/packages/cli/src/ui/components/messages/DiffRenderer.tsx
index 83b205ac76..0859bc13f3 100644
--- a/packages/cli/src/ui/components/messages/DiffRenderer.tsx
+++ b/packages/cli/src/ui/components/messages/DiffRenderer.tsx
@@ -22,7 +22,7 @@ interface DiffLine {
}
function parseDiffWithLineNumbers(diffContent: string): DiffLine[] {
- const lines = diffContent.split('\n');
+ const lines = diffContent.split(/\r?\n/);
const result: DiffLine[] = [];
let currentOldLine = 0;
let currentNewLine = 0;
diff --git a/packages/cli/src/ui/utils/CodeColorizer.tsx b/packages/cli/src/ui/utils/CodeColorizer.tsx
index e5ce2562af..948a5f8988 100644
--- a/packages/cli/src/ui/utils/CodeColorizer.tsx
+++ b/packages/cli/src/ui/utils/CodeColorizer.tsx
@@ -156,7 +156,7 @@ export function colorizeCode({
try {
// Render the HAST tree using the adapted theme
// Apply the theme's default foreground color to the top-level Text element
- let lines = codeToHighlight.split('\n');
+ let lines = codeToHighlight.split(/\r?\n/);
const padWidth = String(lines.length).length; // Calculate padding width based on number of lines
let hiddenLinesCount = 0;
@@ -225,7 +225,7 @@ export function colorizeCode({
);
// Fall back to plain text with default color on error
// Also display line numbers in fallback
- const lines = codeToHighlight.split('\n');
+ const lines = codeToHighlight.split(/\r?\n/);
const padWidth = String(lines.length).length; // Calculate padding width based on number of lines
const fallbackLines = lines.map((line, index) => (
diff --git a/packages/core/src/tools/read-many-files.test.ts b/packages/core/src/tools/read-many-files.test.ts
index 875ccf0bd5..0b8e3a1745 100644
--- a/packages/core/src/tools/read-many-files.test.ts
+++ b/packages/core/src/tools/read-many-files.test.ts
@@ -776,7 +776,7 @@ Content of file[1]
// Mock to track concurrent vs sequential execution
detectFileTypeSpy.mockImplementation(async (filePath: string) => {
- const fileName = filePath.split('/').pop() || '';
+ const fileName = path.basename(filePath);
executionOrder.push(`start:${fileName}`);
// Add delay to make timing differences visible
diff --git a/packages/core/src/utils/fileUtils.ts b/packages/core/src/utils/fileUtils.ts
index 2497439a63..6bb89df83c 100644
--- a/packages/core/src/utils/fileUtils.ts
+++ b/packages/core/src/utils/fileUtils.ts
@@ -8,7 +8,6 @@ import fs from 'node:fs';
import fsPromises from 'node:fs/promises';
import path from 'node:path';
import type { PartUnion } from '@google/genai';
-
import mime from 'mime/lite';
import type { FileSystemService } from '../services/fileSystemService.js';
import { ToolErrorType } from '../tools/tool-error.js';
@@ -473,7 +472,7 @@ export async function processSingleFileContent(
case 'text': {
// Use BOM-aware reader to avoid leaving a BOM character in content and to support UTF-16/32 transparently
const content = await readFileWithEncoding(filePath);
- const lines = content.split('\n');
+ const lines = content.split(/\r?\n/);
const originalLineCount = lines.length;
let sliceStart = 0;
From b89944c3a3c5c1626f8afd4a196f5280340a2760 Mon Sep 17 00:00:00 2001
From: Francesco Camporeale
Date: Tue, 10 Mar 2026 00:26:04 +0100
Subject: [PATCH 006/145] docs: fix heading formatting in commands.md and
phrasing in tools-api.md (#20679)
Co-authored-by: Sam Roberts <158088236+g-samroberts@users.noreply.github.com>
---
docs/reference/commands.md | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/docs/reference/commands.md b/docs/reference/commands.md
index e5e39cf875..c7c25cba1e 100644
--- a/docs/reference/commands.md
+++ b/docs/reference/commands.md
@@ -71,9 +71,9 @@ Slash commands provide meta-level control over the CLI itself.
[Checkpointing documentation](../cli/checkpointing.md).
- **Equivalent:** `/resume save `
- **`share [filename]`**
- - **Description** Writes the current conversation to a provided Markdown or
+ - **Description:** Writes the current conversation to a provided Markdown or
JSON file. If no filename is provided, then the CLI will generate one.
- - **Usage** `/chat share file.md` or `/chat share file.json`.
+ - **Usage:** `/chat share file.md` or `/chat share file.json`.
- **Equivalent:** `/resume share [filename]`
### `/clear`
From 215f8f3f15711ef9a66a32a11f1f0c92ffd685d4 Mon Sep 17 00:00:00 2001
From: Tommaso Sciortino
Date: Mon, 9 Mar 2026 23:26:33 +0000
Subject: [PATCH 007/145] refactor(ui): unify keybinding infrastructure and
support string initialization (#21776)
---
.gemini/commands/strict-development-rules.md | 2 +-
docs/reference/keyboard-shortcuts.md | 15 +-
packages/cli/src/config/keyBindings.test.ts | 93 -----
packages/cli/src/ui/AppContainer.tsx | 2 +-
packages/cli/src/ui/auth/ApiAuthDialog.tsx | 2 +-
.../components/AdminSettingsChangedDialog.tsx | 2 +-
.../ui/components/ApprovalModeIndicator.tsx | 4 +-
.../cli/src/ui/components/AskUserDialog.tsx | 4 +-
.../ui/components/BackgroundShellDisplay.tsx | 4 +-
.../ui/components/ExitPlanModeDialog.test.tsx | 2 +-
.../src/ui/components/ExitPlanModeDialog.tsx | 4 +-
.../src/ui/components/FooterConfigDialog.tsx | 2 +-
packages/cli/src/ui/components/Help.tsx | 4 +-
.../cli/src/ui/components/HooksDialog.tsx | 2 +-
.../src/ui/components/InputPrompt.test.tsx | 2 +-
.../cli/src/ui/components/InputPrompt.tsx | 4 +-
.../src/ui/components/PolicyUpdateDialog.tsx | 2 +-
.../ui/components/RawMarkdownIndicator.tsx | 4 +-
.../src/ui/components/RewindConfirmation.tsx | 2 +-
.../cli/src/ui/components/RewindViewer.tsx | 2 +-
.../src/ui/components/ShellInputPrompt.tsx | 4 +-
.../cli/src/ui/components/ShortcutsHelp.tsx | 6 +-
.../src/ui/components/ValidationDialog.tsx | 2 +-
.../cli/src/ui/components/messages/Todo.tsx | 4 +-
.../messages/ToolConfirmationMessage.tsx | 4 +-
.../src/ui/components/messages/ToolShared.tsx | 4 +-
.../components/shared/BaseSettingsDialog.tsx | 4 +-
.../src/ui/components/shared/MaxSizedBox.tsx | 4 +-
.../src/ui/components/shared/Scrollable.tsx | 2 +-
.../ui/components/shared/ScrollableList.tsx | 2 +-
.../ui/components/shared/SearchableList.tsx | 2 +-
.../src/ui/components/shared/TextInput.tsx | 2 +-
.../src/ui/components/shared/text-buffer.ts | 2 +-
.../ui/components/triage/TriageDuplicates.tsx | 2 +-
.../src/ui/components/triage/TriageIssues.tsx | 2 +-
packages/cli/src/ui/hooks/keyToAnsi.ts | 77 -----
.../src/ui/hooks/useApprovalModeIndicator.ts | 2 +-
packages/cli/src/ui/hooks/useKeyMatchers.ts | 4 +-
packages/cli/src/ui/hooks/useSelectionList.ts | 2 +-
packages/cli/src/ui/hooks/useSuspend.test.ts | 4 +-
packages/cli/src/ui/hooks/useSuspend.ts | 4 +-
.../src/ui/hooks/useTabbedNavigation.test.ts | 23 --
.../cli/src/ui/hooks/useTabbedNavigation.ts | 2 +-
packages/cli/src/ui/hooks/vim.ts | 2 +-
packages/cli/src/ui/key/keyBindings.test.ts | 159 +++++++++
.../cli/src/{config => ui/key}/keyBindings.ts | 324 ++++++++++++------
.../cli/src/ui/{ => key}/keyMatchers.test.ts | 13 +-
packages/cli/src/ui/{ => key}/keyMatchers.ts | 26 +-
packages/cli/src/ui/key/keyToAnsi.ts | 55 +++
.../ui/{utils => key}/keybindingUtils.test.ts | 21 +-
.../src/ui/{utils => key}/keybindingUtils.ts | 3 +-
packages/cli/src/ui/utils/shortcutsHelp.ts | 2 +-
scripts/generate-keybindings-doc.ts | 6 +-
53 files changed, 523 insertions(+), 410 deletions(-)
delete mode 100644 packages/cli/src/config/keyBindings.test.ts
delete mode 100644 packages/cli/src/ui/hooks/keyToAnsi.ts
create mode 100644 packages/cli/src/ui/key/keyBindings.test.ts
rename packages/cli/src/{config => ui/key}/keyBindings.ts (62%)
rename packages/cli/src/ui/{ => key}/keyMatchers.test.ts (97%)
rename packages/cli/src/ui/{ => key}/keyMatchers.ts (59%)
create mode 100644 packages/cli/src/ui/key/keyToAnsi.ts
rename packages/cli/src/ui/{utils => key}/keybindingUtils.test.ts (86%)
rename packages/cli/src/ui/{utils => key}/keybindingUtils.ts (96%)
diff --git a/.gemini/commands/strict-development-rules.md b/.gemini/commands/strict-development-rules.md
index 9c01860091..6620c024ae 100644
--- a/.gemini/commands/strict-development-rules.md
+++ b/.gemini/commands/strict-development-rules.md
@@ -107,7 +107,7 @@ Gemini CLI project.
set.
- **Logging**: Use `debugLogger` for rethrown errors to avoid duplicate logging.
- **Keyboard Shortcuts**: Define all new keyboard shortcuts in
- `packages/cli/src/config/keyBindings.ts` and document them in
+ `packages/cli/src/ui/key/keyBindings.ts` and document them in
`docs/cli/keyboard-shortcuts.md`. Be careful of keybindings that require the
`Meta` key, as only certain meta key shortcuts are supported on Mac. Avoid
function keys and shortcuts commonly bound in VSCode.
diff --git a/docs/reference/keyboard-shortcuts.md b/docs/reference/keyboard-shortcuts.md
index 7b396b73d4..097b380268 100644
--- a/docs/reference/keyboard-shortcuts.md
+++ b/docs/reference/keyboard-shortcuts.md
@@ -55,14 +55,13 @@ available combinations.
#### History & Search
-| Action | Keys |
-| -------------------------------------------- | ------------ |
-| Show the previous entry in history. | `Ctrl+P` |
-| Show the next entry in history. | `Ctrl+N` |
-| Start reverse search through history. | `Ctrl+R` |
-| Submit the selected reverse-search match. | `Enter` |
-| Accept a suggestion while reverse searching. | `Tab` |
-| Browse and rewind previous interactions. | `Double Esc` |
+| Action | Keys |
+| -------------------------------------------- | -------- |
+| Show the previous entry in history. | `Ctrl+P` |
+| Show the next entry in history. | `Ctrl+N` |
+| Start reverse search through history. | `Ctrl+R` |
+| Submit the selected reverse-search match. | `Enter` |
+| Accept a suggestion while reverse searching. | `Tab` |
#### Navigation
diff --git a/packages/cli/src/config/keyBindings.test.ts b/packages/cli/src/config/keyBindings.test.ts
deleted file mode 100644
index e450e68b71..0000000000
--- a/packages/cli/src/config/keyBindings.test.ts
+++ /dev/null
@@ -1,93 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import { describe, it, expect } from 'vitest';
-import type { KeyBindingConfig } from './keyBindings.js';
-import {
- Command,
- commandCategories,
- commandDescriptions,
- defaultKeyBindings,
-} from './keyBindings.js';
-
-describe('keyBindings config', () => {
- describe('defaultKeyBindings', () => {
- it('should have bindings for all commands', () => {
- const commands = Object.values(Command);
-
- for (const command of commands) {
- expect(defaultKeyBindings[command]).toBeDefined();
- expect(Array.isArray(defaultKeyBindings[command])).toBe(true);
- expect(defaultKeyBindings[command]?.length).toBeGreaterThan(0);
- }
- });
-
- it('should have valid key binding structures', () => {
- for (const [_, bindings] of Object.entries(defaultKeyBindings)) {
- for (const binding of bindings) {
- // Each binding must have a key name
- expect(typeof binding.key).toBe('string');
- expect(binding.key.length).toBeGreaterThan(0);
-
- // Modifier properties should be boolean or undefined
- if (binding.shift !== undefined) {
- expect(typeof binding.shift).toBe('boolean');
- }
- if (binding.alt !== undefined) {
- expect(typeof binding.alt).toBe('boolean');
- }
- if (binding.ctrl !== undefined) {
- expect(typeof binding.ctrl).toBe('boolean');
- }
- if (binding.cmd !== undefined) {
- expect(typeof binding.cmd).toBe('boolean');
- }
- }
- }
- });
-
- it('should export all required types', () => {
- // Basic type checks
- expect(typeof Command.HOME).toBe('string');
- expect(typeof Command.END).toBe('string');
-
- // Config should be readonly
- const config: KeyBindingConfig = defaultKeyBindings;
- expect(config[Command.HOME]).toBeDefined();
- });
- });
-
- describe('command metadata', () => {
- const commandValues = Object.values(Command);
-
- it('has a description entry for every command', () => {
- const describedCommands = Object.keys(commandDescriptions);
- expect(describedCommands.sort()).toEqual([...commandValues].sort());
-
- for (const command of commandValues) {
- expect(typeof commandDescriptions[command]).toBe('string');
- expect(commandDescriptions[command]?.trim()).not.toHaveLength(0);
- }
- });
-
- it('categorizes each command exactly once', () => {
- const seen = new Set();
-
- for (const category of commandCategories) {
- expect(typeof category.title).toBe('string');
- expect(Array.isArray(category.commands)).toBe(true);
-
- for (const command of category.commands) {
- expect(commandValues).toContain(command);
- expect(seen.has(command)).toBe(false);
- seen.add(command);
- }
- }
-
- expect(seen.size).toBe(commandValues.length);
- });
- });
-});
diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx
index dfa2d4af86..42d40ec73a 100644
--- a/packages/cli/src/ui/AppContainer.tsx
+++ b/packages/cli/src/ui/AppContainer.tsx
@@ -119,7 +119,7 @@ import { type InitializationResult } from '../core/initializer.js';
import { useFocus } from './hooks/useFocus.js';
import { useKeypress, type Key } from './hooks/useKeypress.js';
import { KeypressPriority } from './contexts/KeypressContext.js';
-import { Command } from './keyMatchers.js';
+import { Command } from './key/keyMatchers.js';
import { useLoadingIndicator } from './hooks/useLoadingIndicator.js';
import { useShellInactivityStatus } from './hooks/useShellInactivityStatus.js';
import { useFolderTrust } from './hooks/useFolderTrust.js';
diff --git a/packages/cli/src/ui/auth/ApiAuthDialog.tsx b/packages/cli/src/ui/auth/ApiAuthDialog.tsx
index a62d34c866..b96a9ece57 100644
--- a/packages/cli/src/ui/auth/ApiAuthDialog.tsx
+++ b/packages/cli/src/ui/auth/ApiAuthDialog.tsx
@@ -13,7 +13,7 @@ import { useTextBuffer } from '../components/shared/text-buffer.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { clearApiKey, debugLogger } from '@google/gemini-cli-core';
import { useKeypress } from '../hooks/useKeypress.js';
-import { Command } from '../keyMatchers.js';
+import { Command } from '../key/keyMatchers.js';
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
interface ApiAuthDialogProps {
diff --git a/packages/cli/src/ui/components/AdminSettingsChangedDialog.tsx b/packages/cli/src/ui/components/AdminSettingsChangedDialog.tsx
index 2507d31f2b..dda4141294 100644
--- a/packages/cli/src/ui/components/AdminSettingsChangedDialog.tsx
+++ b/packages/cli/src/ui/components/AdminSettingsChangedDialog.tsx
@@ -8,7 +8,7 @@ import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { useUIActions } from '../contexts/UIActionsContext.js';
-import { Command } from '../keyMatchers.js';
+import { Command } from '../key/keyMatchers.js';
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
export const AdminSettingsChangedDialog = () => {
diff --git a/packages/cli/src/ui/components/ApprovalModeIndicator.tsx b/packages/cli/src/ui/components/ApprovalModeIndicator.tsx
index 4eaf3f18a4..7e8f388c82 100644
--- a/packages/cli/src/ui/components/ApprovalModeIndicator.tsx
+++ b/packages/cli/src/ui/components/ApprovalModeIndicator.tsx
@@ -8,8 +8,8 @@ import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { ApprovalMode } from '@google/gemini-cli-core';
-import { formatCommand } from '../utils/keybindingUtils.js';
-import { Command } from '../../config/keyBindings.js';
+import { formatCommand } from '../key/keybindingUtils.js';
+import { Command } from '../key/keyBindings.js';
interface ApprovalModeIndicatorProps {
approvalMode: ApprovalMode;
diff --git a/packages/cli/src/ui/components/AskUserDialog.tsx b/packages/cli/src/ui/components/AskUserDialog.tsx
index e55617a724..3c8ccbfb34 100644
--- a/packages/cli/src/ui/components/AskUserDialog.tsx
+++ b/packages/cli/src/ui/components/AskUserDialog.tsx
@@ -20,10 +20,10 @@ import { BaseSelectionList } from './shared/BaseSelectionList.js';
import type { SelectionListItem } from '../hooks/useSelectionList.js';
import { TabHeader, type Tab } from './shared/TabHeader.js';
import { useKeypress, type Key } from '../hooks/useKeypress.js';
-import { Command } from '../keyMatchers.js';
+import { Command } from '../key/keyMatchers.js';
import { checkExhaustive } from '@google/gemini-cli-core';
import { TextInput } from './shared/TextInput.js';
-import { formatCommand } from '../utils/keybindingUtils.js';
+import { formatCommand } from '../key/keybindingUtils.js';
import {
useTextBuffer,
expandPastePlaceholders,
diff --git a/packages/cli/src/ui/components/BackgroundShellDisplay.tsx b/packages/cli/src/ui/components/BackgroundShellDisplay.tsx
index 946e062c19..a2187fc2f3 100644
--- a/packages/cli/src/ui/components/BackgroundShellDisplay.tsx
+++ b/packages/cli/src/ui/components/BackgroundShellDisplay.tsx
@@ -16,9 +16,9 @@ import {
} from '@google/gemini-cli-core';
import { cpLen, cpSlice, getCachedStringWidth } from '../utils/textUtils.js';
import { type BackgroundShell } from '../hooks/shellCommandProcessor.js';
-import { Command } from '../keyMatchers.js';
+import { Command } from '../key/keyMatchers.js';
import { useKeypress } from '../hooks/useKeypress.js';
-import { formatCommand } from '../utils/keybindingUtils.js';
+import { formatCommand } from '../key/keybindingUtils.js';
import {
ScrollableList,
type ScrollableListRef,
diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx
index 35d0d2e719..33daca1e33 100644
--- a/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx
+++ b/packages/cli/src/ui/components/ExitPlanModeDialog.test.tsx
@@ -10,7 +10,7 @@ import { renderWithProviders } from '../../test-utils/render.js';
import { waitFor } from '../../test-utils/async.js';
import { ExitPlanModeDialog } from './ExitPlanModeDialog.js';
import { useKeypress } from '../hooks/useKeypress.js';
-import { Command } from '../keyMatchers.js';
+import { Command } from '../key/keyMatchers.js';
import {
ApprovalMode,
validatePlanContent,
diff --git a/packages/cli/src/ui/components/ExitPlanModeDialog.tsx b/packages/cli/src/ui/components/ExitPlanModeDialog.tsx
index d5f1983c14..ec5a4c2a9b 100644
--- a/packages/cli/src/ui/components/ExitPlanModeDialog.tsx
+++ b/packages/cli/src/ui/components/ExitPlanModeDialog.tsx
@@ -22,8 +22,8 @@ import { useConfig } from '../contexts/ConfigContext.js';
import { AskUserDialog } from './AskUserDialog.js';
import { openFileInEditor } from '../utils/editorUtils.js';
import { useKeypress } from '../hooks/useKeypress.js';
-import { Command } from '../keyMatchers.js';
-import { formatCommand } from '../utils/keybindingUtils.js';
+import { Command } from '../key/keyMatchers.js';
+import { formatCommand } from '../key/keybindingUtils.js';
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
export interface ExitPlanModeDialogProps {
diff --git a/packages/cli/src/ui/components/FooterConfigDialog.tsx b/packages/cli/src/ui/components/FooterConfigDialog.tsx
index 03560d4e21..cda58574a3 100644
--- a/packages/cli/src/ui/components/FooterConfigDialog.tsx
+++ b/packages/cli/src/ui/components/FooterConfigDialog.tsx
@@ -11,7 +11,7 @@ import { theme } from '../semantic-colors.js';
import { useSettingsStore } from '../contexts/SettingsContext.js';
import { useUIState } from '../contexts/UIStateContext.js';
import { useKeypress, type Key } from '../hooks/useKeypress.js';
-import { Command } from '../keyMatchers.js';
+import { Command } from '../key/keyMatchers.js';
import { FooterRow, type FooterRowItem } from './Footer.js';
import { ALL_ITEMS, resolveFooterState } from '../../config/footerItems.js';
import { SettingScope } from '../../config/settings.js';
diff --git a/packages/cli/src/ui/components/Help.tsx b/packages/cli/src/ui/components/Help.tsx
index 7f032b4e47..2569623c80 100644
--- a/packages/cli/src/ui/components/Help.tsx
+++ b/packages/cli/src/ui/components/Help.tsx
@@ -10,8 +10,8 @@ import { theme } from '../semantic-colors.js';
import { type SlashCommand, CommandKind } from '../commands/types.js';
import { KEYBOARD_SHORTCUTS_URL } from '../constants.js';
import { sanitizeForDisplay } from '../utils/textUtils.js';
-import { formatCommand } from '../utils/keybindingUtils.js';
-import { Command } from '../../config/keyBindings.js';
+import { formatCommand } from '../key/keybindingUtils.js';
+import { Command } from '../key/keyBindings.js';
interface Help {
commands: readonly SlashCommand[];
diff --git a/packages/cli/src/ui/components/HooksDialog.tsx b/packages/cli/src/ui/components/HooksDialog.tsx
index 4fd7b9ff9d..0421f7d9eb 100644
--- a/packages/cli/src/ui/components/HooksDialog.tsx
+++ b/packages/cli/src/ui/components/HooksDialog.tsx
@@ -9,7 +9,7 @@ import { useState, useMemo } from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
import { useKeypress } from '../hooks/useKeypress.js';
-import { Command } from '../keyMatchers.js';
+import { Command } from '../key/keyMatchers.js';
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
/**
diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx
index 85e6b8d6aa..260455c782 100644
--- a/packages/cli/src/ui/components/InputPrompt.test.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.test.tsx
@@ -44,7 +44,7 @@ import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js
import type { UIState } from '../contexts/UIStateContext.js';
import { isLowColorDepth } from '../utils/terminalUtils.js';
import { cpLen } from '../utils/textUtils.js';
-import { defaultKeyMatchers, Command } from '../keyMatchers.js';
+import { defaultKeyMatchers, Command } from '../key/keyMatchers.js';
import type { Key } from '../hooks/useKeypress.js';
import {
appEvents,
diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx
index 1d82c87f70..785641a556 100644
--- a/packages/cli/src/ui/components/InputPrompt.tsx
+++ b/packages/cli/src/ui/components/InputPrompt.tsx
@@ -36,8 +36,8 @@ import {
} from '../hooks/useCommandCompletion.js';
import type { Key } from '../hooks/useKeypress.js';
import { useKeypress } from '../hooks/useKeypress.js';
-import { Command } from '../keyMatchers.js';
-import { formatCommand } from '../utils/keybindingUtils.js';
+import { Command } from '../key/keyMatchers.js';
+import { formatCommand } from '../key/keybindingUtils.js';
import type { CommandContext, SlashCommand } from '../commands/types.js';
import type { Config } from '@google/gemini-cli-core';
import { ApprovalMode, coreEvents, debugLogger } from '@google/gemini-cli-core';
diff --git a/packages/cli/src/ui/components/PolicyUpdateDialog.tsx b/packages/cli/src/ui/components/PolicyUpdateDialog.tsx
index ad48571fff..6b24908560 100644
--- a/packages/cli/src/ui/components/PolicyUpdateDialog.tsx
+++ b/packages/cli/src/ui/components/PolicyUpdateDialog.tsx
@@ -16,7 +16,7 @@ import { theme } from '../semantic-colors.js';
import type { RadioSelectItem } from './shared/RadioButtonSelect.js';
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
import { useKeypress } from '../hooks/useKeypress.js';
-import { Command } from '../keyMatchers.js';
+import { Command } from '../key/keyMatchers.js';
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
export enum PolicyUpdateChoice {
diff --git a/packages/cli/src/ui/components/RawMarkdownIndicator.tsx b/packages/cli/src/ui/components/RawMarkdownIndicator.tsx
index 922c30a36d..3a88c7ff34 100644
--- a/packages/cli/src/ui/components/RawMarkdownIndicator.tsx
+++ b/packages/cli/src/ui/components/RawMarkdownIndicator.tsx
@@ -7,8 +7,8 @@
import type React from 'react';
import { Box, Text } from 'ink';
import { theme } from '../semantic-colors.js';
-import { formatCommand } from '../utils/keybindingUtils.js';
-import { Command } from '../../config/keyBindings.js';
+import { formatCommand } from '../key/keybindingUtils.js';
+import { Command } from '../key/keyBindings.js';
export const RawMarkdownIndicator: React.FC = () => {
const modKey = formatCommand(Command.TOGGLE_MARKDOWN);
diff --git a/packages/cli/src/ui/components/RewindConfirmation.tsx b/packages/cli/src/ui/components/RewindConfirmation.tsx
index fa58995731..a3a58db6f9 100644
--- a/packages/cli/src/ui/components/RewindConfirmation.tsx
+++ b/packages/cli/src/ui/components/RewindConfirmation.tsx
@@ -13,7 +13,7 @@ import type { RadioSelectItem } from './shared/RadioButtonSelect.js';
import type { FileChangeStats } from '../utils/rewindFileOps.js';
import { useKeypress } from '../hooks/useKeypress.js';
import { formatTimeAgo } from '../utils/formatters.js';
-import { Command } from '../keyMatchers.js';
+import { Command } from '../key/keyMatchers.js';
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
export enum RewindOutcome {
diff --git a/packages/cli/src/ui/components/RewindViewer.tsx b/packages/cli/src/ui/components/RewindViewer.tsx
index 0a9f858d3d..e77b17db32 100644
--- a/packages/cli/src/ui/components/RewindViewer.tsx
+++ b/packages/cli/src/ui/components/RewindViewer.tsx
@@ -19,7 +19,7 @@ import { useKeypress } from '../hooks/useKeypress.js';
import { useRewind } from '../hooks/useRewind.js';
import { RewindConfirmation, RewindOutcome } from './RewindConfirmation.js';
import { stripReferenceContent } from '../utils/formatters.js';
-import { Command } from '../keyMatchers.js';
+import { Command } from '../key/keyMatchers.js';
import { CliSpinner } from './CliSpinner.js';
import { ExpandableText } from './shared/ExpandableText.js';
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
diff --git a/packages/cli/src/ui/components/ShellInputPrompt.tsx b/packages/cli/src/ui/components/ShellInputPrompt.tsx
index dae0f65312..8f5831c1ef 100644
--- a/packages/cli/src/ui/components/ShellInputPrompt.tsx
+++ b/packages/cli/src/ui/components/ShellInputPrompt.tsx
@@ -8,9 +8,9 @@ import { useCallback } from 'react';
import type React from 'react';
import { useKeypress } from '../hooks/useKeypress.js';
import { ShellExecutionService } from '@google/gemini-cli-core';
-import { keyToAnsi, type Key } from '../hooks/keyToAnsi.js';
+import { keyToAnsi, type Key } from '../key/keyToAnsi.js';
import { ACTIVE_SHELL_MAX_LINES } from '../constants.js';
-import { Command } from '../keyMatchers.js';
+import { Command } from '../key/keyMatchers.js';
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
export interface ShellInputPromptProps {
diff --git a/packages/cli/src/ui/components/ShortcutsHelp.tsx b/packages/cli/src/ui/components/ShortcutsHelp.tsx
index 149e4ddea9..d94bf2b1d4 100644
--- a/packages/cli/src/ui/components/ShortcutsHelp.tsx
+++ b/packages/cli/src/ui/components/ShortcutsHelp.tsx
@@ -10,8 +10,8 @@ import { theme } from '../semantic-colors.js';
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
import { SectionHeader } from './shared/SectionHeader.js';
import { useUIState } from '../contexts/UIStateContext.js';
-import { Command } from '../../config/keyBindings.js';
-import { formatCommand } from '../utils/keybindingUtils.js';
+import { Command } from '../key/keyBindings.js';
+import { formatCommand } from '../key/keybindingUtils.js';
type ShortcutItem = {
key: string;
@@ -21,7 +21,7 @@ type ShortcutItem = {
const buildShortcutItems = (): ShortcutItem[] => [
{ key: '!', description: 'shell mode' },
{ key: '@', description: 'select file or folder' },
- { key: formatCommand(Command.REWIND), description: 'clear & rewind' },
+ { key: 'Double Esc', description: 'clear & rewind' },
{ key: formatCommand(Command.FOCUS_SHELL_INPUT), description: 'focus UI' },
{ key: formatCommand(Command.TOGGLE_YOLO), description: 'YOLO mode' },
{
diff --git a/packages/cli/src/ui/components/ValidationDialog.tsx b/packages/cli/src/ui/components/ValidationDialog.tsx
index f94de6b86d..f03e09c963 100644
--- a/packages/cli/src/ui/components/ValidationDialog.tsx
+++ b/packages/cli/src/ui/components/ValidationDialog.tsx
@@ -16,7 +16,7 @@ import {
type ValidationIntent,
} from '@google/gemini-cli-core';
import { useKeypress } from '../hooks/useKeypress.js';
-import { Command } from '../keyMatchers.js';
+import { Command } from '../key/keyMatchers.js';
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
interface ValidationDialogProps {
diff --git a/packages/cli/src/ui/components/messages/Todo.tsx b/packages/cli/src/ui/components/messages/Todo.tsx
index 786fe5e2f1..cbc2405ac0 100644
--- a/packages/cli/src/ui/components/messages/Todo.tsx
+++ b/packages/cli/src/ui/components/messages/Todo.tsx
@@ -11,8 +11,8 @@ import { useMemo } from 'react';
import type { HistoryItemToolGroup } from '../../types.js';
import { Checklist } from '../Checklist.js';
import type { ChecklistItemData } from '../ChecklistItem.js';
-import { formatCommand } from '../../utils/keybindingUtils.js';
-import { Command } from '../../../config/keyBindings.js';
+import { formatCommand } from '../../key/keybindingUtils.js';
+import { Command } from '../../key/keyBindings.js';
export const TodoTray: React.FC = () => {
const uiState = useUIState();
diff --git a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
index 1ace75633c..329d8e6262 100644
--- a/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
+++ b/packages/cli/src/ui/components/messages/ToolConfirmationMessage.tsx
@@ -29,8 +29,8 @@ import {
import { useKeypress } from '../../hooks/useKeypress.js';
import { theme } from '../../semantic-colors.js';
import { useSettings } from '../../contexts/SettingsContext.js';
-import { Command } from '../../keyMatchers.js';
-import { formatCommand } from '../../utils/keybindingUtils.js';
+import { Command } from '../../key/keyMatchers.js';
+import { formatCommand } from '../../key/keybindingUtils.js';
import { AskUserDialog } from '../AskUserDialog.js';
import { ExitPlanModeDialog } from '../ExitPlanModeDialog.js';
import { WarningMessage } from './WarningMessage.js';
diff --git a/packages/cli/src/ui/components/messages/ToolShared.tsx b/packages/cli/src/ui/components/messages/ToolShared.tsx
index 0e072cfd13..2aa5ed992a 100644
--- a/packages/cli/src/ui/components/messages/ToolShared.tsx
+++ b/packages/cli/src/ui/components/messages/ToolShared.tsx
@@ -23,8 +23,8 @@ import {
CoreToolCallStatus,
} from '@google/gemini-cli-core';
import { useInactivityTimer } from '../../hooks/useInactivityTimer.js';
-import { formatCommand } from '../../utils/keybindingUtils.js';
-import { Command } from '../../../config/keyBindings.js';
+import { formatCommand } from '../../key/keybindingUtils.js';
+import { Command } from '../../key/keyBindings.js';
export const STATUS_INDICATOR_WIDTH = 3;
diff --git a/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx b/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx
index 45dda8b38c..1434a28c52 100644
--- a/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx
+++ b/packages/cli/src/ui/components/shared/BaseSettingsDialog.tsx
@@ -19,10 +19,10 @@ import { TextInput } from './TextInput.js';
import type { TextBuffer } from './text-buffer.js';
import { cpSlice, cpLen, cpIndexToOffset } from '../../utils/textUtils.js';
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
-import { Command } from '../../keyMatchers.js';
+import { Command } from '../../key/keyMatchers.js';
import { useSettingsNavigation } from '../../hooks/useSettingsNavigation.js';
import { useInlineEditBuffer } from '../../hooks/useInlineEditBuffer.js';
-import { formatCommand } from '../../utils/keybindingUtils.js';
+import { formatCommand } from '../../key/keybindingUtils.js';
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
/**
diff --git a/packages/cli/src/ui/components/shared/MaxSizedBox.tsx b/packages/cli/src/ui/components/shared/MaxSizedBox.tsx
index e88dcd4b76..0e3869a3f0 100644
--- a/packages/cli/src/ui/components/shared/MaxSizedBox.tsx
+++ b/packages/cli/src/ui/components/shared/MaxSizedBox.tsx
@@ -10,8 +10,8 @@ import { Box, Text, ResizeObserver, type DOMElement } from 'ink';
import { theme } from '../../semantic-colors.js';
import { useOverflowActions } from '../../contexts/OverflowContext.js';
import { isNarrowWidth } from '../../utils/isNarrowWidth.js';
-import { Command } from '../../../config/keyBindings.js';
-import { formatCommand } from '../../utils/keybindingUtils.js';
+import { Command } from '../../key/keyBindings.js';
+import { formatCommand } from '../../key/keybindingUtils.js';
/**
* Minimum height for the MaxSizedBox component.
diff --git a/packages/cli/src/ui/components/shared/Scrollable.tsx b/packages/cli/src/ui/components/shared/Scrollable.tsx
index a1f9be0b7c..a95d2ff112 100644
--- a/packages/cli/src/ui/components/shared/Scrollable.tsx
+++ b/packages/cli/src/ui/components/shared/Scrollable.tsx
@@ -19,7 +19,7 @@ import { useKeypress, type Key } from '../../hooks/useKeypress.js';
import { useScrollable } from '../../contexts/ScrollProvider.js';
import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js';
import { useBatchedScroll } from '../../hooks/useBatchedScroll.js';
-import { Command } from '../../keyMatchers.js';
+import { Command } from '../../key/keyMatchers.js';
import { useOverflowActions } from '../../contexts/OverflowContext.js';
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
diff --git a/packages/cli/src/ui/components/shared/ScrollableList.tsx b/packages/cli/src/ui/components/shared/ScrollableList.tsx
index 33a3f72310..fd7eaeb8e3 100644
--- a/packages/cli/src/ui/components/shared/ScrollableList.tsx
+++ b/packages/cli/src/ui/components/shared/ScrollableList.tsx
@@ -22,7 +22,7 @@ import { useScrollable } from '../../contexts/ScrollProvider.js';
import { Box, type DOMElement } from 'ink';
import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js';
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
-import { Command } from '../../keyMatchers.js';
+import { Command } from '../../key/keyMatchers.js';
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
const ANIMATION_FRAME_DURATION_MS = 33;
diff --git a/packages/cli/src/ui/components/shared/SearchableList.tsx b/packages/cli/src/ui/components/shared/SearchableList.tsx
index 046040af90..d43409bf67 100644
--- a/packages/cli/src/ui/components/shared/SearchableList.tsx
+++ b/packages/cli/src/ui/components/shared/SearchableList.tsx
@@ -11,7 +11,7 @@ import { useSelectionList } from '../../hooks/useSelectionList.js';
import { TextInput } from './TextInput.js';
import type { TextBuffer } from './text-buffer.js';
import { useKeypress } from '../../hooks/useKeypress.js';
-import { Command } from '../../keyMatchers.js';
+import { Command } from '../../key/keyMatchers.js';
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
/**
diff --git a/packages/cli/src/ui/components/shared/TextInput.tsx b/packages/cli/src/ui/components/shared/TextInput.tsx
index cc3fcaeb8d..277d5e9723 100644
--- a/packages/cli/src/ui/components/shared/TextInput.tsx
+++ b/packages/cli/src/ui/components/shared/TextInput.tsx
@@ -14,7 +14,7 @@ import { theme } from '../../semantic-colors.js';
import type { TextBuffer } from './text-buffer.js';
import { expandPastePlaceholders } from './text-buffer.js';
import { cpSlice, cpIndexToOffset } from '../../utils/textUtils.js';
-import { Command } from '../../keyMatchers.js';
+import { Command } from '../../key/keyMatchers.js';
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
export interface TextInputProps {
diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts
index 808fc8a554..46abe7a361 100644
--- a/packages/cli/src/ui/components/shared/text-buffer.ts
+++ b/packages/cli/src/ui/components/shared/text-buffer.ts
@@ -25,7 +25,7 @@ import {
} from '../../utils/textUtils.js';
import { parsePastedPaths } from '../../utils/clipboardUtils.js';
import type { Key } from '../../contexts/KeypressContext.js';
-import { Command } from '../../keyMatchers.js';
+import { Command } from '../../key/keyMatchers.js';
import type { VimAction } from './vim-buffer-actions.js';
import { handleVimAction } from './vim-buffer-actions.js';
import { LRU_BUFFER_PERF_CACHE_LIMIT } from '../../constants.js';
diff --git a/packages/cli/src/ui/components/triage/TriageDuplicates.tsx b/packages/cli/src/ui/components/triage/TriageDuplicates.tsx
index 4de6568189..73d0ae701f 100644
--- a/packages/cli/src/ui/components/triage/TriageDuplicates.tsx
+++ b/packages/cli/src/ui/components/triage/TriageDuplicates.tsx
@@ -10,7 +10,7 @@ import Spinner from 'ink-spinner';
import type { Config } from '@google/gemini-cli-core';
import { debugLogger, spawnAsync, LlmRole } from '@google/gemini-cli-core';
import { useKeypress } from '../../hooks/useKeypress.js';
-import { Command } from '../../keyMatchers.js';
+import { Command } from '../../key/keyMatchers.js';
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
interface Issue {
diff --git a/packages/cli/src/ui/components/triage/TriageIssues.tsx b/packages/cli/src/ui/components/triage/TriageIssues.tsx
index e6779d6c02..477be8a363 100644
--- a/packages/cli/src/ui/components/triage/TriageIssues.tsx
+++ b/packages/cli/src/ui/components/triage/TriageIssues.tsx
@@ -10,7 +10,7 @@ import Spinner from 'ink-spinner';
import type { Config } from '@google/gemini-cli-core';
import { debugLogger, spawnAsync, LlmRole } from '@google/gemini-cli-core';
import { useKeypress } from '../../hooks/useKeypress.js';
-import { Command } from '../../keyMatchers.js';
+import { Command } from '../../key/keyMatchers.js';
import { TextInput } from '../shared/TextInput.js';
import { useTextBuffer } from '../shared/text-buffer.js';
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
diff --git a/packages/cli/src/ui/hooks/keyToAnsi.ts b/packages/cli/src/ui/hooks/keyToAnsi.ts
deleted file mode 100644
index 56d8466a0e..0000000000
--- a/packages/cli/src/ui/hooks/keyToAnsi.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import type { Key } from '../contexts/KeypressContext.js';
-
-export type { Key };
-
-/**
- * Translates a Key object into its corresponding ANSI escape sequence.
- * This is useful for sending control characters to a pseudo-terminal.
- *
- * @param key The Key object to translate.
- * @returns The ANSI escape sequence as a string, or null if no mapping exists.
- */
-export function keyToAnsi(key: Key): string | null {
- if (key.ctrl) {
- // Ctrl + letter
- if (key.name >= 'a' && key.name <= 'z') {
- return String.fromCharCode(
- key.name.charCodeAt(0) - 'a'.charCodeAt(0) + 1,
- );
- }
- // Other Ctrl combinations might need specific handling
- switch (key.name) {
- case 'c':
- return '\x03'; // ETX (End of Text), commonly used for interrupt
- // Add other special ctrl cases if needed
- default:
- break;
- }
- }
-
- // Arrow keys and other special keys
- switch (key.name) {
- case 'up':
- return '\x1b[A';
- case 'down':
- return '\x1b[B';
- case 'right':
- return '\x1b[C';
- case 'left':
- return '\x1b[D';
- case 'escape':
- return '\x1b';
- case 'tab':
- return '\t';
- case 'backspace':
- return '\x7f';
- case 'delete':
- return '\x1b[3~';
- case 'home':
- return '\x1b[H';
- case 'end':
- return '\x1b[F';
- case 'pageup':
- return '\x1b[5~';
- case 'pagedown':
- return '\x1b[6~';
- default:
- break;
- }
-
- // Enter/Return
- if (key.name === 'return') {
- return '\r';
- }
-
- // If it's a simple character, return it.
- if (!key.ctrl && !key.cmd && key.sequence) {
- return key.sequence;
- }
-
- return null;
-}
diff --git a/packages/cli/src/ui/hooks/useApprovalModeIndicator.ts b/packages/cli/src/ui/hooks/useApprovalModeIndicator.ts
index 84e465106f..a9b9faf4eb 100644
--- a/packages/cli/src/ui/hooks/useApprovalModeIndicator.ts
+++ b/packages/cli/src/ui/hooks/useApprovalModeIndicator.ts
@@ -11,7 +11,7 @@ import {
getAdminErrorMessage,
} from '@google/gemini-cli-core';
import { useKeypress } from './useKeypress.js';
-import { Command } from '../keyMatchers.js';
+import { Command } from '../key/keyMatchers.js';
import { useKeyMatchers } from './useKeyMatchers.js';
import type { HistoryItemWithoutId } from '../types.js';
import { MessageType } from '../types.js';
diff --git a/packages/cli/src/ui/hooks/useKeyMatchers.ts b/packages/cli/src/ui/hooks/useKeyMatchers.ts
index a42a066ee0..b14ab67eda 100644
--- a/packages/cli/src/ui/hooks/useKeyMatchers.ts
+++ b/packages/cli/src/ui/hooks/useKeyMatchers.ts
@@ -5,8 +5,8 @@
*/
import { useMemo } from 'react';
-import type { KeyMatchers } from '../keyMatchers.js';
-import { defaultKeyMatchers } from '../keyMatchers.js';
+import type { KeyMatchers } from '../key/keyMatchers.js';
+import { defaultKeyMatchers } from '../key/keyMatchers.js';
/**
* Hook to retrieve the currently active key matchers.
diff --git a/packages/cli/src/ui/hooks/useSelectionList.ts b/packages/cli/src/ui/hooks/useSelectionList.ts
index 9f73c54da4..c184d12d05 100644
--- a/packages/cli/src/ui/hooks/useSelectionList.ts
+++ b/packages/cli/src/ui/hooks/useSelectionList.ts
@@ -6,7 +6,7 @@
import { useReducer, useRef, useEffect, useCallback } from 'react';
import { useKeypress, type Key } from './useKeypress.js';
-import { Command } from '../keyMatchers.js';
+import { Command } from '../key/keyMatchers.js';
import { debugLogger } from '@google/gemini-cli-core';
import { useKeyMatchers } from './useKeyMatchers.js';
diff --git a/packages/cli/src/ui/hooks/useSuspend.test.ts b/packages/cli/src/ui/hooks/useSuspend.test.ts
index 1d0b34b1a3..941bfd44b9 100644
--- a/packages/cli/src/ui/hooks/useSuspend.test.ts
+++ b/packages/cli/src/ui/hooks/useSuspend.test.ts
@@ -29,8 +29,8 @@ import {
cleanupTerminalOnExit,
terminalCapabilityManager,
} from '../utils/terminalCapabilityManager.js';
-import { formatCommand } from '../utils/keybindingUtils.js';
-import { Command } from '../../config/keyBindings.js';
+import { formatCommand } from '../key/keybindingUtils.js';
+import { Command } from '../key/keyBindings.js';
vi.mock('@google/gemini-cli-core', async () => {
const actual = await vi.importActual('@google/gemini-cli-core');
diff --git a/packages/cli/src/ui/hooks/useSuspend.ts b/packages/cli/src/ui/hooks/useSuspend.ts
index 7d295b4450..b5e92fb80b 100644
--- a/packages/cli/src/ui/hooks/useSuspend.ts
+++ b/packages/cli/src/ui/hooks/useSuspend.ts
@@ -20,8 +20,8 @@ import {
terminalCapabilityManager,
} from '../utils/terminalCapabilityManager.js';
import { WARNING_PROMPT_DURATION_MS } from '../constants.js';
-import { formatCommand } from '../utils/keybindingUtils.js';
-import { Command } from '../../config/keyBindings.js';
+import { formatCommand } from '../key/keybindingUtils.js';
+import { Command } from '../key/keyBindings.js';
interface UseSuspendProps {
handleWarning: (message: string) => void;
diff --git a/packages/cli/src/ui/hooks/useTabbedNavigation.test.ts b/packages/cli/src/ui/hooks/useTabbedNavigation.test.ts
index e41a89d66d..20e1c13fb8 100644
--- a/packages/cli/src/ui/hooks/useTabbedNavigation.test.ts
+++ b/packages/cli/src/ui/hooks/useTabbedNavigation.test.ts
@@ -9,18 +9,12 @@ import { act } from 'react';
import { renderHook } from '../../test-utils/render.js';
import { useTabbedNavigation } from './useTabbedNavigation.js';
import { useKeypress } from './useKeypress.js';
-import { useKeyMatchers } from './useKeyMatchers.js';
-import type { KeyMatchers } from '../keyMatchers.js';
import type { Key, KeypressHandler } from '../contexts/KeypressContext.js';
vi.mock('./useKeypress.js', () => ({
useKeypress: vi.fn(),
}));
-vi.mock('./useKeyMatchers.js', () => ({
- useKeyMatchers: vi.fn(),
-}));
-
const createKey = (partial: Partial): Key => ({
name: partial.name || '',
sequence: partial.sequence || '',
@@ -32,27 +26,10 @@ const createKey = (partial: Partial): Key => ({
...partial,
});
-const mockKeyMatchers = {
- 'cursor.left': vi.fn((key) => key.name === 'left'),
- 'cursor.right': vi.fn((key) => key.name === 'right'),
- 'dialog.next': vi.fn((key) => key.name === 'tab' && !key.shift),
- 'dialog.previous': vi.fn((key) => key.name === 'tab' && key.shift),
-} as unknown as KeyMatchers;
-
-vi.mock('../keyMatchers.js', () => ({
- Command: {
- MOVE_LEFT: 'cursor.left',
- MOVE_RIGHT: 'cursor.right',
- DIALOG_NEXT: 'dialog.next',
- DIALOG_PREV: 'dialog.previous',
- },
-}));
-
describe('useTabbedNavigation', () => {
let capturedHandler: KeypressHandler;
beforeEach(() => {
- vi.mocked(useKeyMatchers).mockReturnValue(mockKeyMatchers);
vi.mocked(useKeypress).mockImplementation((handler) => {
capturedHandler = handler;
});
diff --git a/packages/cli/src/ui/hooks/useTabbedNavigation.ts b/packages/cli/src/ui/hooks/useTabbedNavigation.ts
index d7e406ce6b..bd300f0faf 100644
--- a/packages/cli/src/ui/hooks/useTabbedNavigation.ts
+++ b/packages/cli/src/ui/hooks/useTabbedNavigation.ts
@@ -6,7 +6,7 @@
import { useReducer, useCallback, useEffect, useRef } from 'react';
import { useKeypress, type Key } from './useKeypress.js';
-import { Command } from '../keyMatchers.js';
+import { Command } from '../key/keyMatchers.js';
import { useKeyMatchers } from './useKeyMatchers.js';
/**
diff --git a/packages/cli/src/ui/hooks/vim.ts b/packages/cli/src/ui/hooks/vim.ts
index 1fcc0c61ca..54de27496f 100644
--- a/packages/cli/src/ui/hooks/vim.ts
+++ b/packages/cli/src/ui/hooks/vim.ts
@@ -9,7 +9,7 @@ import type { Key } from './useKeypress.js';
import type { TextBuffer } from '../components/shared/text-buffer.js';
import { useVimMode } from '../contexts/VimModeContext.js';
import { debugLogger } from '@google/gemini-cli-core';
-import { Command } from '../keyMatchers.js';
+import { Command } from '../key/keyMatchers.js';
import { useKeyMatchers } from './useKeyMatchers.js';
export type VimMode = 'NORMAL' | 'INSERT';
diff --git a/packages/cli/src/ui/key/keyBindings.test.ts b/packages/cli/src/ui/key/keyBindings.test.ts
new file mode 100644
index 0000000000..b47e8d56b8
--- /dev/null
+++ b/packages/cli/src/ui/key/keyBindings.test.ts
@@ -0,0 +1,159 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, it, expect } from 'vitest';
+import type { KeyBindingConfig } from './keyBindings.js';
+import {
+ Command,
+ commandCategories,
+ commandDescriptions,
+ defaultKeyBindings,
+ KeyBinding,
+} from './keyBindings.js';
+
+describe('KeyBinding', () => {
+ describe('constructor', () => {
+ it('should parse a simple key', () => {
+ const binding = new KeyBinding('a');
+ expect(binding.key).toBe('a');
+ expect(binding.ctrl).toBe(false);
+ expect(binding.shift).toBe(false);
+ expect(binding.alt).toBe(false);
+ expect(binding.cmd).toBe(false);
+ });
+
+ it('should parse ctrl+key', () => {
+ const binding = new KeyBinding('ctrl+c');
+ expect(binding.key).toBe('c');
+ expect(binding.ctrl).toBe(true);
+ });
+
+ it('should parse shift+key', () => {
+ const binding = new KeyBinding('shift+z');
+ expect(binding.key).toBe('z');
+ expect(binding.shift).toBe(true);
+ });
+
+ it('should parse alt+key', () => {
+ const binding = new KeyBinding('alt+left');
+ expect(binding.key).toBe('left');
+ expect(binding.alt).toBe(true);
+ });
+
+ it('should parse cmd+key', () => {
+ const binding = new KeyBinding('cmd+f');
+ expect(binding.key).toBe('f');
+ expect(binding.cmd).toBe(true);
+ });
+
+ it('should handle aliases (option/opt/meta)', () => {
+ const optionBinding = new KeyBinding('option+b');
+ expect(optionBinding.key).toBe('b');
+ expect(optionBinding.alt).toBe(true);
+
+ const optBinding = new KeyBinding('opt+b');
+ expect(optBinding.key).toBe('b');
+ expect(optBinding.alt).toBe(true);
+
+ const metaBinding = new KeyBinding('meta+enter');
+ expect(metaBinding.key).toBe('enter');
+ expect(metaBinding.cmd).toBe(true);
+ });
+
+ it('should parse multiple modifiers', () => {
+ const binding = new KeyBinding('ctrl+shift+alt+cmd+x');
+ expect(binding.key).toBe('x');
+ expect(binding.ctrl).toBe(true);
+ expect(binding.shift).toBe(true);
+ expect(binding.alt).toBe(true);
+ expect(binding.cmd).toBe(true);
+ });
+
+ it('should be case-insensitive', () => {
+ const binding = new KeyBinding('CTRL+Shift+F');
+ expect(binding.key).toBe('f');
+ expect(binding.ctrl).toBe(true);
+ expect(binding.shift).toBe(true);
+ });
+
+ it('should handle named keys with modifiers', () => {
+ const binding = new KeyBinding('ctrl+return');
+ expect(binding.key).toBe('return');
+ expect(binding.ctrl).toBe(true);
+ });
+
+ it('should throw an error for invalid keys or typos in modifiers', () => {
+ expect(() => new KeyBinding('ctrl+unknown')).toThrow(
+ 'Invalid keybinding key: "unknown" in "ctrl+unknown"',
+ );
+ expect(() => new KeyBinding('ctlr+a')).toThrow(
+ 'Invalid keybinding key: "ctlr+a" in "ctlr+a"',
+ );
+ });
+
+ it('should throw an error for literal "+" as key (must use "=")', () => {
+ // VS Code style peeling logic results in "+" as the remains
+ expect(() => new KeyBinding('alt++')).toThrow(
+ 'Invalid keybinding key: "+" in "alt++"',
+ );
+ });
+ });
+});
+
+describe('keyBindings config', () => {
+ describe('defaultKeyBindings', () => {
+ it('should have bindings for all commands', () => {
+ const commands = Object.values(Command);
+
+ for (const command of commands) {
+ expect(defaultKeyBindings[command]).toBeDefined();
+ expect(Array.isArray(defaultKeyBindings[command])).toBe(true);
+ expect(defaultKeyBindings[command]?.length).toBeGreaterThan(0);
+ }
+ });
+
+ it('should export all required types', () => {
+ // Basic type checks
+ expect(typeof Command.HOME).toBe('string');
+ expect(typeof Command.END).toBe('string');
+
+ // Config should be readonly
+ const config: KeyBindingConfig = defaultKeyBindings;
+ expect(config[Command.HOME]).toBeDefined();
+ });
+ });
+
+ describe('command metadata', () => {
+ const commandValues = Object.values(Command);
+
+ it('has a description entry for every command', () => {
+ const describedCommands = Object.keys(commandDescriptions);
+ expect(describedCommands.sort()).toEqual([...commandValues].sort());
+
+ for (const command of commandValues) {
+ expect(typeof commandDescriptions[command]).toBe('string');
+ expect(commandDescriptions[command]?.trim()).not.toHaveLength(0);
+ }
+ });
+
+ it('categorizes each command exactly once', () => {
+ const seen = new Set();
+
+ for (const category of commandCategories) {
+ expect(typeof category.title).toBe('string');
+ expect(Array.isArray(category.commands)).toBe(true);
+
+ for (const command of category.commands) {
+ expect(commandValues).toContain(command);
+ expect(seen.has(command)).toBe(false);
+ seen.add(command);
+ }
+ }
+
+ expect(seen.size).toBe(commandValues.length);
+ });
+ });
+});
diff --git a/packages/cli/src/config/keyBindings.ts b/packages/cli/src/ui/key/keyBindings.ts
similarity index 62%
rename from packages/cli/src/config/keyBindings.ts
rename to packages/cli/src/ui/key/keyBindings.ts
index e2260d99d8..209111b53c 100644
--- a/packages/cli/src/config/keyBindings.ts
+++ b/packages/cli/src/ui/key/keyBindings.ts
@@ -7,6 +7,8 @@
/**
* Command enum for all available keyboard shortcuts
*/
+import type { Key } from '../hooks/useKeypress.js';
+
export enum Command {
// Basic Controls
RETURN = 'basic.confirm',
@@ -49,7 +51,6 @@ export enum Command {
REVERSE_SEARCH = 'history.search.start',
SUBMIT_REVERSE_SEARCH = 'history.search.submit',
ACCEPT_SUGGESTION_REVERSE_SEARCH = 'history.search.accept',
- REWIND = 'history.rewind',
// Navigation
NAVIGATION_UP = 'nav.up',
@@ -102,17 +103,126 @@ export enum Command {
/**
* Data-driven key binding structure for user configuration
*/
-export interface KeyBinding {
+export class KeyBinding {
+ private static readonly VALID_KEYS = new Set([
+ // Letters & Numbers
+ ...'abcdefghijklmnopqrstuvwxyz0123456789',
+ // Punctuation
+ '`',
+ '-',
+ '=',
+ '[',
+ ']',
+ '\\',
+ ';',
+ "'",
+ ',',
+ '.',
+ '/',
+ // Navigation & Actions
+ 'left',
+ 'up',
+ 'right',
+ 'down',
+ 'pageup',
+ 'pagedown',
+ 'end',
+ 'home',
+ 'tab',
+ 'enter',
+ 'escape',
+ 'space',
+ 'backspace',
+ 'delete',
+ 'pausebreak',
+ 'capslock',
+ 'insert',
+ 'numlock',
+ 'scrolllock',
+ // Function Keys
+ ...Array.from({ length: 19 }, (_, i) => `f${i + 1}`),
+ // Numpad
+ ...Array.from({ length: 10 }, (_, i) => `numpad${i}`),
+ 'numpad_multiply',
+ 'numpad_add',
+ 'numpad_separator',
+ 'numpad_subtract',
+ 'numpad_decimal',
+ 'numpad_divide',
+ // Gemini CLI legacy/internal support
+ 'return',
+ ]);
+
/** The key name (e.g., 'a', 'return', 'tab', 'escape') */
- key: string;
- /** Shift key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */
- shift?: boolean;
- /** Alt/Option key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */
- alt?: boolean;
- /** Control key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */
- ctrl?: boolean;
- /** Command/Windows/Super key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */
- cmd?: boolean;
+ readonly key: string;
+ readonly shift: boolean;
+ readonly alt: boolean;
+ readonly ctrl: boolean;
+ readonly cmd: boolean;
+
+ constructor(pattern: string) {
+ let remains = pattern.toLowerCase().trim();
+ let shift = false;
+ let alt = false;
+ let ctrl = false;
+ let cmd = false;
+
+ let matched: boolean;
+ do {
+ matched = false;
+ if (remains.startsWith('ctrl+')) {
+ ctrl = true;
+ remains = remains.slice(5);
+ matched = true;
+ } else if (remains.startsWith('shift+')) {
+ shift = true;
+ remains = remains.slice(6);
+ matched = true;
+ } else if (remains.startsWith('alt+')) {
+ alt = true;
+ remains = remains.slice(4);
+ matched = true;
+ } else if (remains.startsWith('option+')) {
+ alt = true;
+ remains = remains.slice(7);
+ matched = true;
+ } else if (remains.startsWith('opt+')) {
+ alt = true;
+ remains = remains.slice(4);
+ matched = true;
+ } else if (remains.startsWith('cmd+')) {
+ cmd = true;
+ remains = remains.slice(4);
+ matched = true;
+ } else if (remains.startsWith('meta+')) {
+ cmd = true;
+ remains = remains.slice(5);
+ matched = true;
+ }
+ } while (matched);
+
+ const key = remains;
+
+ if (!KeyBinding.VALID_KEYS.has(key)) {
+ throw new Error(`Invalid keybinding key: "${key}" in "${pattern}"`);
+ }
+
+ this.key = key;
+ this.shift = shift;
+ this.alt = alt;
+ this.ctrl = ctrl;
+ this.cmd = cmd;
+ }
+
+ matches(key: Key): boolean {
+ return (
+ this.key === key.name &&
+ !!key.shift === !!this.shift &&
+ !!key.alt === !!this.alt &&
+ !!key.ctrl === !!this.ctrl &&
+ !!key.cmd === !!this.cmd
+ );
+ }
}
/**
@@ -128,135 +238,143 @@ export type KeyBindingConfig = {
*/
export const defaultKeyBindings: KeyBindingConfig = {
// Basic Controls
- [Command.RETURN]: [{ key: 'return' }],
- [Command.ESCAPE]: [{ key: 'escape' }, { key: '[', ctrl: true }],
- [Command.QUIT]: [{ key: 'c', ctrl: true }],
- [Command.EXIT]: [{ key: 'd', ctrl: true }],
+ [Command.RETURN]: [new KeyBinding('return')],
+ [Command.ESCAPE]: [new KeyBinding('escape'), new KeyBinding('ctrl+[')],
+ [Command.QUIT]: [new KeyBinding('ctrl+c')],
+ [Command.EXIT]: [new KeyBinding('ctrl+d')],
// Cursor Movement
- [Command.HOME]: [{ key: 'a', ctrl: true }, { key: 'home' }],
- [Command.END]: [{ key: 'e', ctrl: true }, { key: 'end' }],
- [Command.MOVE_UP]: [{ key: 'up' }],
- [Command.MOVE_DOWN]: [{ key: 'down' }],
- [Command.MOVE_LEFT]: [{ key: 'left' }],
- [Command.MOVE_RIGHT]: [{ key: 'right' }, { key: 'f', ctrl: true }],
+ [Command.HOME]: [new KeyBinding('ctrl+a'), new KeyBinding('home')],
+ [Command.END]: [new KeyBinding('ctrl+e'), new KeyBinding('end')],
+ [Command.MOVE_UP]: [new KeyBinding('up')],
+ [Command.MOVE_DOWN]: [new KeyBinding('down')],
+ [Command.MOVE_LEFT]: [new KeyBinding('left')],
+ [Command.MOVE_RIGHT]: [new KeyBinding('right'), new KeyBinding('ctrl+f')],
[Command.MOVE_WORD_LEFT]: [
- { key: 'left', ctrl: true },
- { key: 'left', alt: true },
- { key: 'b', alt: true },
+ new KeyBinding('ctrl+left'),
+ new KeyBinding('alt+left'),
+ new KeyBinding('alt+b'),
],
[Command.MOVE_WORD_RIGHT]: [
- { key: 'right', ctrl: true },
- { key: 'right', alt: true },
- { key: 'f', alt: true },
+ new KeyBinding('ctrl+right'),
+ new KeyBinding('alt+right'),
+ new KeyBinding('alt+f'),
],
// Editing
- [Command.KILL_LINE_RIGHT]: [{ key: 'k', ctrl: true }],
- [Command.KILL_LINE_LEFT]: [{ key: 'u', ctrl: true }],
- [Command.CLEAR_INPUT]: [{ key: 'c', ctrl: true }],
+ [Command.KILL_LINE_RIGHT]: [new KeyBinding('ctrl+k')],
+ [Command.KILL_LINE_LEFT]: [new KeyBinding('ctrl+u')],
+ [Command.CLEAR_INPUT]: [new KeyBinding('ctrl+c')],
[Command.DELETE_WORD_BACKWARD]: [
- { key: 'backspace', ctrl: true },
- { key: 'backspace', alt: true },
- { key: 'w', ctrl: true },
+ new KeyBinding('ctrl+backspace'),
+ new KeyBinding('alt+backspace'),
+ new KeyBinding('ctrl+w'),
],
[Command.DELETE_WORD_FORWARD]: [
- { key: 'delete', ctrl: true },
- { key: 'delete', alt: true },
- { key: 'd', alt: true },
+ new KeyBinding('ctrl+delete'),
+ new KeyBinding('alt+delete'),
+ new KeyBinding('alt+d'),
],
- [Command.DELETE_CHAR_LEFT]: [{ key: 'backspace' }, { key: 'h', ctrl: true }],
- [Command.DELETE_CHAR_RIGHT]: [{ key: 'delete' }, { key: 'd', ctrl: true }],
- [Command.UNDO]: [
- { key: 'z', cmd: true },
- { key: 'z', alt: true },
+ [Command.DELETE_CHAR_LEFT]: [
+ new KeyBinding('backspace'),
+ new KeyBinding('ctrl+h'),
],
+ [Command.DELETE_CHAR_RIGHT]: [
+ new KeyBinding('delete'),
+ new KeyBinding('ctrl+d'),
+ ],
+ [Command.UNDO]: [new KeyBinding('cmd+z'), new KeyBinding('alt+z')],
[Command.REDO]: [
- { key: 'z', ctrl: true, shift: true },
- { key: 'z', cmd: true, shift: true },
- { key: 'z', alt: true, shift: true },
+ new KeyBinding('ctrl+shift+z'),
+ new KeyBinding('cmd+shift+z'),
+ new KeyBinding('alt+shift+z'),
],
// Scrolling
- [Command.SCROLL_UP]: [{ key: 'up', shift: true }],
- [Command.SCROLL_DOWN]: [{ key: 'down', shift: true }],
+ [Command.SCROLL_UP]: [new KeyBinding('shift+up')],
+ [Command.SCROLL_DOWN]: [new KeyBinding('shift+down')],
[Command.SCROLL_HOME]: [
- { key: 'home', ctrl: true },
- { key: 'home', shift: true },
+ new KeyBinding('ctrl+home'),
+ new KeyBinding('shift+home'),
],
[Command.SCROLL_END]: [
- { key: 'end', ctrl: true },
- { key: 'end', shift: true },
+ new KeyBinding('ctrl+end'),
+ new KeyBinding('shift+end'),
],
- [Command.PAGE_UP]: [{ key: 'pageup' }],
- [Command.PAGE_DOWN]: [{ key: 'pagedown' }],
+ [Command.PAGE_UP]: [new KeyBinding('pageup')],
+ [Command.PAGE_DOWN]: [new KeyBinding('pagedown')],
// History & Search
- [Command.HISTORY_UP]: [{ key: 'p', ctrl: true }],
- [Command.HISTORY_DOWN]: [{ key: 'n', ctrl: true }],
- [Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }],
- [Command.REWIND]: [{ key: 'double escape' }], // for documentation only
- [Command.SUBMIT_REVERSE_SEARCH]: [{ key: 'return' }],
- [Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [{ key: 'tab' }],
+ [Command.HISTORY_UP]: [new KeyBinding('ctrl+p')],
+ [Command.HISTORY_DOWN]: [new KeyBinding('ctrl+n')],
+ [Command.REVERSE_SEARCH]: [new KeyBinding('ctrl+r')],
+ [Command.SUBMIT_REVERSE_SEARCH]: [new KeyBinding('return')],
+ [Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [new KeyBinding('tab')],
// Navigation
- [Command.NAVIGATION_UP]: [{ key: 'up' }],
- [Command.NAVIGATION_DOWN]: [{ key: 'down' }],
+ [Command.NAVIGATION_UP]: [new KeyBinding('up')],
+ [Command.NAVIGATION_DOWN]: [new KeyBinding('down')],
// Navigation shortcuts appropriate for dialogs where we do not need to accept
// text input.
- [Command.DIALOG_NAVIGATION_UP]: [{ key: 'up' }, { key: 'k' }],
- [Command.DIALOG_NAVIGATION_DOWN]: [{ key: 'down' }, { key: 'j' }],
- [Command.DIALOG_NEXT]: [{ key: 'tab' }],
- [Command.DIALOG_PREV]: [{ key: 'tab', shift: true }],
+ [Command.DIALOG_NAVIGATION_UP]: [new KeyBinding('up'), new KeyBinding('k')],
+ [Command.DIALOG_NAVIGATION_DOWN]: [
+ new KeyBinding('down'),
+ new KeyBinding('j'),
+ ],
+ [Command.DIALOG_NEXT]: [new KeyBinding('tab')],
+ [Command.DIALOG_PREV]: [new KeyBinding('shift+tab')],
// Suggestions & Completions
- [Command.ACCEPT_SUGGESTION]: [{ key: 'tab' }, { key: 'return' }],
- [Command.COMPLETION_UP]: [{ key: 'up' }, { key: 'p', ctrl: true }],
- [Command.COMPLETION_DOWN]: [{ key: 'down' }, { key: 'n', ctrl: true }],
- [Command.EXPAND_SUGGESTION]: [{ key: 'right' }],
- [Command.COLLAPSE_SUGGESTION]: [{ key: 'left' }],
+ [Command.ACCEPT_SUGGESTION]: [
+ new KeyBinding('tab'),
+ new KeyBinding('return'),
+ ],
+ [Command.COMPLETION_UP]: [new KeyBinding('up'), new KeyBinding('ctrl+p')],
+ [Command.COMPLETION_DOWN]: [new KeyBinding('down'), new KeyBinding('ctrl+n')],
+ [Command.EXPAND_SUGGESTION]: [new KeyBinding('right')],
+ [Command.COLLAPSE_SUGGESTION]: [new KeyBinding('left')],
// Text Input
// Must also exclude shift to allow shift+enter for newline
- [Command.SUBMIT]: [{ key: 'return' }],
+ [Command.SUBMIT]: [new KeyBinding('return')],
[Command.NEWLINE]: [
- { key: 'return', ctrl: true },
- { key: 'return', cmd: true },
- { key: 'return', alt: true },
- { key: 'return', shift: true },
- { key: 'j', ctrl: true },
+ new KeyBinding('ctrl+return'),
+ new KeyBinding('cmd+return'),
+ new KeyBinding('alt+return'),
+ new KeyBinding('shift+return'),
+ new KeyBinding('ctrl+j'),
],
- [Command.OPEN_EXTERNAL_EDITOR]: [{ key: 'x', ctrl: true }],
+ [Command.OPEN_EXTERNAL_EDITOR]: [new KeyBinding('ctrl+x')],
[Command.PASTE_CLIPBOARD]: [
- { key: 'v', ctrl: true },
- { key: 'v', cmd: true },
- { key: 'v', alt: true },
+ new KeyBinding('ctrl+v'),
+ new KeyBinding('cmd+v'),
+ new KeyBinding('alt+v'),
],
// App Controls
- [Command.SHOW_ERROR_DETAILS]: [{ key: 'f12' }],
- [Command.SHOW_FULL_TODOS]: [{ key: 't', ctrl: true }],
- [Command.SHOW_IDE_CONTEXT_DETAIL]: [{ key: 'g', ctrl: true }],
- [Command.TOGGLE_MARKDOWN]: [{ key: 'm', alt: true }],
- [Command.TOGGLE_COPY_MODE]: [{ key: 's', ctrl: true }],
- [Command.TOGGLE_YOLO]: [{ key: 'y', ctrl: true }],
- [Command.CYCLE_APPROVAL_MODE]: [{ key: 'tab', shift: true }],
- [Command.TOGGLE_BACKGROUND_SHELL]: [{ key: 'b', ctrl: true }],
- [Command.TOGGLE_BACKGROUND_SHELL_LIST]: [{ key: 'l', ctrl: true }],
- [Command.KILL_BACKGROUND_SHELL]: [{ key: 'k', ctrl: true }],
- [Command.UNFOCUS_BACKGROUND_SHELL]: [{ key: 'tab', shift: true }],
- [Command.UNFOCUS_BACKGROUND_SHELL_LIST]: [{ key: 'tab' }],
- [Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: [{ key: 'tab' }],
- [Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING]: [{ key: 'tab' }],
- [Command.BACKGROUND_SHELL_SELECT]: [{ key: 'return' }],
- [Command.BACKGROUND_SHELL_ESCAPE]: [{ key: 'escape' }],
- [Command.SHOW_MORE_LINES]: [{ key: 'o', ctrl: true }],
- [Command.EXPAND_PASTE]: [{ key: 'o', ctrl: true }],
- [Command.FOCUS_SHELL_INPUT]: [{ key: 'tab' }],
- [Command.UNFOCUS_SHELL_INPUT]: [{ key: 'tab', shift: true }],
- [Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }],
- [Command.RESTART_APP]: [{ key: 'r' }, { key: 'r', shift: true }],
- [Command.SUSPEND_APP]: [{ key: 'z', ctrl: true }],
+ [Command.SHOW_ERROR_DETAILS]: [new KeyBinding('f12')],
+ [Command.SHOW_FULL_TODOS]: [new KeyBinding('ctrl+t')],
+ [Command.SHOW_IDE_CONTEXT_DETAIL]: [new KeyBinding('ctrl+g')],
+ [Command.TOGGLE_MARKDOWN]: [new KeyBinding('alt+m')],
+ [Command.TOGGLE_COPY_MODE]: [new KeyBinding('ctrl+s')],
+ [Command.TOGGLE_YOLO]: [new KeyBinding('ctrl+y')],
+ [Command.CYCLE_APPROVAL_MODE]: [new KeyBinding('shift+tab')],
+ [Command.TOGGLE_BACKGROUND_SHELL]: [new KeyBinding('ctrl+b')],
+ [Command.TOGGLE_BACKGROUND_SHELL_LIST]: [new KeyBinding('ctrl+l')],
+ [Command.KILL_BACKGROUND_SHELL]: [new KeyBinding('ctrl+k')],
+ [Command.UNFOCUS_BACKGROUND_SHELL]: [new KeyBinding('shift+tab')],
+ [Command.UNFOCUS_BACKGROUND_SHELL_LIST]: [new KeyBinding('tab')],
+ [Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: [new KeyBinding('tab')],
+ [Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING]: [new KeyBinding('tab')],
+ [Command.BACKGROUND_SHELL_SELECT]: [new KeyBinding('return')],
+ [Command.BACKGROUND_SHELL_ESCAPE]: [new KeyBinding('escape')],
+ [Command.SHOW_MORE_LINES]: [new KeyBinding('ctrl+o')],
+ [Command.EXPAND_PASTE]: [new KeyBinding('ctrl+o')],
+ [Command.FOCUS_SHELL_INPUT]: [new KeyBinding('tab')],
+ [Command.UNFOCUS_SHELL_INPUT]: [new KeyBinding('shift+tab')],
+ [Command.CLEAR_SCREEN]: [new KeyBinding('ctrl+l')],
+ [Command.RESTART_APP]: [new KeyBinding('r'), new KeyBinding('shift+r')],
+ [Command.SUSPEND_APP]: [new KeyBinding('ctrl+z')],
};
interface CommandCategory {
@@ -318,7 +436,6 @@ export const commandCategories: readonly CommandCategory[] = [
Command.REVERSE_SEARCH,
Command.SUBMIT_REVERSE_SEARCH,
Command.ACCEPT_SUGGESTION_REVERSE_SEARCH,
- Command.REWIND,
],
},
{
@@ -428,7 +545,6 @@ export const commandDescriptions: Readonly> = {
[Command.SUBMIT_REVERSE_SEARCH]: 'Submit the selected reverse-search match.',
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]:
'Accept a suggestion while reverse searching.',
- [Command.REWIND]: 'Browse and rewind previous interactions.',
// Navigation
[Command.NAVIGATION_UP]: 'Move selection up in lists.',
diff --git a/packages/cli/src/ui/keyMatchers.test.ts b/packages/cli/src/ui/key/keyMatchers.test.ts
similarity index 97%
rename from packages/cli/src/ui/keyMatchers.test.ts
rename to packages/cli/src/ui/key/keyMatchers.test.ts
index e90f6334be..62766d1a0d 100644
--- a/packages/cli/src/ui/keyMatchers.test.ts
+++ b/packages/cli/src/ui/key/keyMatchers.test.ts
@@ -10,9 +10,9 @@ import {
Command,
createKeyMatchers,
} from './keyMatchers.js';
-import type { KeyBindingConfig } from '../config/keyBindings.js';
-import { defaultKeyBindings } from '../config/keyBindings.js';
-import type { Key } from './hooks/useKeypress.js';
+import type { KeyBindingConfig } from './keyBindings.js';
+import { defaultKeyBindings, KeyBinding } from './keyBindings.js';
+import type { Key } from '../hooks/useKeypress.js';
describe('keyMatchers', () => {
const createKey = (name: string, mods: Partial = {}): Key => ({
@@ -445,7 +445,7 @@ describe('keyMatchers', () => {
it('should work with custom configuration', () => {
const customConfig: KeyBindingConfig = {
...defaultKeyBindings,
- [Command.HOME]: [{ key: 'h', ctrl: true }, { key: '0' }],
+ [Command.HOME]: [new KeyBinding('ctrl+h'), new KeyBinding('0')],
};
const customMatchers = createKeyMatchers(customConfig);
@@ -462,10 +462,7 @@ describe('keyMatchers', () => {
it('should support multiple key bindings for same command', () => {
const config: KeyBindingConfig = {
...defaultKeyBindings,
- [Command.QUIT]: [
- { key: 'q', ctrl: true },
- { key: 'q', alt: true },
- ],
+ [Command.QUIT]: [new KeyBinding('ctrl+q'), new KeyBinding('alt+q')],
};
const matchers = createKeyMatchers(config);
diff --git a/packages/cli/src/ui/keyMatchers.ts b/packages/cli/src/ui/key/keyMatchers.ts
similarity index 59%
rename from packages/cli/src/ui/keyMatchers.ts
rename to packages/cli/src/ui/key/keyMatchers.ts
index 259f1edd9e..a346ecb3ad 100644
--- a/packages/cli/src/ui/keyMatchers.ts
+++ b/packages/cli/src/ui/key/keyMatchers.ts
@@ -4,26 +4,9 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import type { Key } from './hooks/useKeypress.js';
-import type { KeyBinding, KeyBindingConfig } from '../config/keyBindings.js';
-import { Command, defaultKeyBindings } from '../config/keyBindings.js';
-
-/**
- * Matches a KeyBinding against an actual Key press
- * Pure data-driven matching logic
- */
-function matchKeyBinding(keyBinding: KeyBinding, key: Key): boolean {
- // Check modifiers:
- // true = modifier must be pressed
- // false or undefined = modifier must NOT be pressed
- return (
- keyBinding.key === key.name &&
- !!key.shift === !!keyBinding.shift &&
- !!key.alt === !!keyBinding.alt &&
- !!key.ctrl === !!keyBinding.ctrl &&
- !!key.cmd === !!keyBinding.cmd
- );
-}
+import type { Key } from '../hooks/useKeypress.js';
+import type { KeyBindingConfig } from './keyBindings.js';
+import { Command, defaultKeyBindings } from './keyBindings.js';
/**
* Checks if a key matches any of the bindings for a command
@@ -33,8 +16,7 @@ function matchCommand(
key: Key,
config: KeyBindingConfig = defaultKeyBindings,
): boolean {
- const bindings = config[command];
- return bindings.some((binding) => matchKeyBinding(binding, key));
+ return config[command].some((binding) => binding.matches(key));
}
/**
diff --git a/packages/cli/src/ui/key/keyToAnsi.ts b/packages/cli/src/ui/key/keyToAnsi.ts
new file mode 100644
index 0000000000..adb9874933
--- /dev/null
+++ b/packages/cli/src/ui/key/keyToAnsi.ts
@@ -0,0 +1,55 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type { Key } from '../contexts/KeypressContext.js';
+
+export type { Key };
+
+const SPECIAL_KEYS: Record = {
+ up: '\x1b[A',
+ down: '\x1b[B',
+ right: '\x1b[C',
+ left: '\x1b[D',
+ escape: '\x1b',
+ tab: '\t',
+ backspace: '\x7f',
+ delete: '\x1b[3~',
+ home: '\x1b[H',
+ end: '\x1b[F',
+ pageup: '\x1b[5~',
+ pagedown: '\x1b[6~',
+ return: '\r',
+};
+
+/**
+ * Translates a Key object into its corresponding ANSI escape sequence.
+ * This is useful for sending control characters to a pseudo-terminal.
+ *
+ * @param key The Key object to translate.
+ * @returns The ANSI escape sequence as a string, or null if no mapping exists.
+ */
+export function keyToAnsi(key: Key): string | null {
+ if (key.ctrl) {
+ // Ctrl + letter (A-Z maps to 1-26, e.g., Ctrl+C is \x03)
+ if (key.name >= 'a' && key.name <= 'z') {
+ return String.fromCharCode(
+ key.name.charCodeAt(0) - 'a'.charCodeAt(0) + 1,
+ );
+ }
+ }
+
+ // Arrow keys and other special keys
+ if (key.name in SPECIAL_KEYS) {
+ return SPECIAL_KEYS[key.name];
+ }
+
+ // If it's a simple character, return it.
+ if (!key.ctrl && !key.cmd && key.sequence) {
+ return key.sequence;
+ }
+
+ return null;
+}
diff --git a/packages/cli/src/ui/utils/keybindingUtils.test.ts b/packages/cli/src/ui/key/keybindingUtils.test.ts
similarity index 86%
rename from packages/cli/src/ui/utils/keybindingUtils.test.ts
rename to packages/cli/src/ui/key/keybindingUtils.test.ts
index 4dfe2f814c..58a113f4de 100644
--- a/packages/cli/src/ui/utils/keybindingUtils.test.ts
+++ b/packages/cli/src/ui/key/keybindingUtils.test.ts
@@ -6,8 +6,7 @@
import { describe, it, expect } from 'vitest';
import { formatKeyBinding, formatCommand } from './keybindingUtils.js';
-import { Command } from '../../config/keyBindings.js';
-import type { KeyBinding } from '../../config/keyBindings.js';
+import { Command, KeyBinding } from './keyBindings.js';
describe('keybindingUtils', () => {
describe('formatKeyBinding', () => {
@@ -23,12 +22,12 @@ describe('keybindingUtils', () => {
}> = [
{
name: 'simple key',
- binding: { key: 'a' },
+ binding: new KeyBinding('a'),
expected: { darwin: 'A', win32: 'A', linux: 'A', default: 'A' },
},
{
name: 'named key (return)',
- binding: { key: 'return' },
+ binding: new KeyBinding('return'),
expected: {
darwin: 'Enter',
win32: 'Enter',
@@ -38,12 +37,12 @@ describe('keybindingUtils', () => {
},
{
name: 'named key (escape)',
- binding: { key: 'escape' },
+ binding: new KeyBinding('escape'),
expected: { darwin: 'Esc', win32: 'Esc', linux: 'Esc', default: 'Esc' },
},
{
name: 'ctrl modifier',
- binding: { key: 'c', ctrl: true },
+ binding: new KeyBinding('ctrl+c'),
expected: {
darwin: 'Ctrl+C',
win32: 'Ctrl+C',
@@ -53,7 +52,7 @@ describe('keybindingUtils', () => {
},
{
name: 'cmd modifier',
- binding: { key: 'z', cmd: true },
+ binding: new KeyBinding('cmd+z'),
expected: {
darwin: 'Cmd+Z',
win32: 'Win+Z',
@@ -63,7 +62,7 @@ describe('keybindingUtils', () => {
},
{
name: 'alt/option modifier',
- binding: { key: 'left', alt: true },
+ binding: new KeyBinding('alt+left'),
expected: {
darwin: 'Option+Left',
win32: 'Alt+Left',
@@ -73,7 +72,7 @@ describe('keybindingUtils', () => {
},
{
name: 'shift modifier',
- binding: { key: 'up', shift: true },
+ binding: new KeyBinding('shift+up'),
expected: {
darwin: 'Shift+Up',
win32: 'Shift+Up',
@@ -83,7 +82,7 @@ describe('keybindingUtils', () => {
},
{
name: 'multiple modifiers (ctrl+shift)',
- binding: { key: 'z', ctrl: true, shift: true },
+ binding: new KeyBinding('ctrl+shift+z'),
expected: {
darwin: 'Ctrl+Shift+Z',
win32: 'Ctrl+Shift+Z',
@@ -93,7 +92,7 @@ describe('keybindingUtils', () => {
},
{
name: 'all modifiers',
- binding: { key: 'a', ctrl: true, alt: true, shift: true, cmd: true },
+ binding: new KeyBinding('ctrl+alt+shift+cmd+a'),
expected: {
darwin: 'Ctrl+Option+Shift+Cmd+A',
win32: 'Ctrl+Alt+Shift+Win+A',
diff --git a/packages/cli/src/ui/utils/keybindingUtils.ts b/packages/cli/src/ui/key/keybindingUtils.ts
similarity index 96%
rename from packages/cli/src/ui/utils/keybindingUtils.ts
rename to packages/cli/src/ui/key/keybindingUtils.ts
index a084b9c68c..c4f4c6b942 100644
--- a/packages/cli/src/ui/utils/keybindingUtils.ts
+++ b/packages/cli/src/ui/key/keybindingUtils.ts
@@ -10,7 +10,7 @@ import {
type KeyBinding,
type KeyBindingConfig,
defaultKeyBindings,
-} from '../../config/keyBindings.js';
+} from './keyBindings.js';
/**
* Maps internal key names to user-friendly display names.
@@ -30,7 +30,6 @@ const KEY_NAME_MAP: Record = {
end: 'End',
tab: 'Tab',
space: 'Space',
- 'double escape': 'Double Esc',
};
interface ModifierMap {
diff --git a/packages/cli/src/ui/utils/shortcutsHelp.ts b/packages/cli/src/ui/utils/shortcutsHelp.ts
index a5f6d22e19..2c1a501385 100644
--- a/packages/cli/src/ui/utils/shortcutsHelp.ts
+++ b/packages/cli/src/ui/utils/shortcutsHelp.ts
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { Command } from '../keyMatchers.js';
+import { Command } from '../key/keyMatchers.js';
import type { Key } from '../hooks/useKeypress.js';
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
diff --git a/scripts/generate-keybindings-doc.ts b/scripts/generate-keybindings-doc.ts
index 19f07198ac..ab452bb8f2 100644
--- a/scripts/generate-keybindings-doc.ts
+++ b/scripts/generate-keybindings-doc.ts
@@ -8,12 +8,12 @@ import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { readFile, writeFile } from 'node:fs/promises';
-import type { KeyBinding } from '../packages/cli/src/config/keyBindings.js';
+import type { KeyBinding } from '../packages/cli/src/ui/key/keyBindings.js';
import {
commandCategories,
commandDescriptions,
defaultKeyBindings,
-} from '../packages/cli/src/config/keyBindings.js';
+} from '../packages/cli/src/ui/key/keyBindings.js';
import {
formatWithPrettier,
injectBetweenMarkers,
@@ -24,7 +24,7 @@ const START_MARKER = '';
const END_MARKER = '';
const OUTPUT_RELATIVE_PATH = ['docs', 'reference', 'keyboard-shortcuts.md'];
-import { formatKeyBinding } from '../packages/cli/src/ui/utils/keybindingUtils.js';
+import { formatKeyBinding } from '../packages/cli/src/ui/key/keybindingUtils.js';
export interface KeybindingDocCommand {
description: string;
From 43eb74ac594c0a3a6802c22ac0ef806212502a5f Mon Sep 17 00:00:00 2001
From: christine betts
Date: Mon, 9 Mar 2026 19:31:31 -0400
Subject: [PATCH 008/145] Add support for updating extension sources and names
(#21715)
---
docs/extensions/reference.md | 4 +
docs/extensions/releasing.md | 26 ++++
.../cli/src/config/extension-manager.test.ts | 140 ++++++++++++++++++
packages/cli/src/config/extension-manager.ts | 70 ++++++++-
packages/cli/src/config/extension.ts | 4 +
...-consent-if-extension-is-migrated.snap.svg | 13 ++
.../__snapshots__/consent.test.ts.snap | 9 ++
.../cli/src/config/extensions/consent.test.ts | 19 +++
packages/cli/src/config/extensions/consent.ts | 24 ++-
.../cli/src/config/extensions/github.test.ts | 17 +++
packages/cli/src/config/extensions/github.ts | 18 +++
.../cli/src/config/extensions/update.test.ts | 48 ++++++
packages/cli/src/config/extensions/update.ts | 18 +++
packages/core/src/config/config.ts | 4 +
pr-description.md | 65 ++++++++
15 files changed, 473 insertions(+), 6 deletions(-)
create mode 100644 packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-request-consent-if-extension-is-migrated.snap.svg
create mode 100644 pr-description.md
diff --git a/docs/extensions/reference.md b/docs/extensions/reference.md
index 46d43225b2..dbba51fa40 100644
--- a/docs/extensions/reference.md
+++ b/docs/extensions/reference.md
@@ -123,6 +123,7 @@ The manifest file defines the extension's behavior and configuration.
},
"contextFileName": "GEMINI.md",
"excludeTools": ["run_shell_command"],
+ "migratedTo": "https://github.com/new-owner/new-extension-repo",
"plan": {
"directory": ".gemini/plans"
}
@@ -138,6 +139,9 @@ The manifest file defines the extension's behavior and configuration.
- `version`: The version of the extension.
- `description`: A short description of the extension. This will be displayed on
[geminicli.com/extensions](https://geminicli.com/extensions).
+- `migratedTo`: The URL of the new repository source for the extension. If this
+ is set, the CLI will automatically check this new source for updates and
+ migrate the extension's installation to the new source if an update is found.
- `mcpServers`: A map of MCP servers to settings. The key is the name of the
server, and the value is the server configuration. These servers will be
loaded on startup just like MCP servers defined in a
diff --git a/docs/extensions/releasing.md b/docs/extensions/releasing.md
index f29a1eac6e..cb19c351a8 100644
--- a/docs/extensions/releasing.md
+++ b/docs/extensions/releasing.md
@@ -152,3 +152,29 @@ jobs:
release/linux.arm64.my-tool.tar.gz
release/win32.arm64.my-tool.zip
```
+
+## Migrating an Extension Repository
+
+If you need to move your extension to a new repository (e.g., from a personal
+account to an organization) or rename it, you can use the `migratedTo` property
+in your `gemini-extension.json` file to seamlessly transition your users.
+
+1. **Create the new repository**: Setup your extension in its new location.
+2. **Update the old repository**: In your original repository, update the
+ `gemini-extension.json` file to include the `migratedTo` property, pointing
+ to the new repository URL, and bump the version number. You can optionally
+ change the `name` of your extension at this time in the new repository.
+ ```json
+ {
+ "name": "my-extension",
+ "version": "1.1.0",
+ "migratedTo": "https://github.com/new-owner/new-extension-repo"
+ }
+ ```
+3. **Release the update**: Publish this new version in your old repository.
+
+When users check for updates, the Gemini CLI will detect the `migratedTo` field,
+verify that the new repository contains a valid extension update, and
+automatically update their local installation to track the new source and name
+moving forward. All extension settings will automatically migrate to the new
+installation.
diff --git a/packages/cli/src/config/extension-manager.test.ts b/packages/cli/src/config/extension-manager.test.ts
index a5fb822cdb..445f5ce485 100644
--- a/packages/cli/src/config/extension-manager.test.ts
+++ b/packages/cli/src/config/extension-manager.test.ts
@@ -345,4 +345,144 @@ describe('ExtensionManager', () => {
}
});
});
+
+ describe('Extension Renaming', () => {
+ it('should support renaming an extension during update', async () => {
+ // 1. Setup existing extension
+ const oldName = 'old-name';
+ const newName = 'new-name';
+ const extDir = path.join(userExtensionsDir, oldName);
+ fs.mkdirSync(extDir, { recursive: true });
+ fs.writeFileSync(
+ path.join(extDir, 'gemini-extension.json'),
+ JSON.stringify({ name: oldName, version: '1.0.0' }),
+ );
+ fs.writeFileSync(
+ path.join(extDir, 'metadata.json'),
+ JSON.stringify({ type: 'local', source: extDir }),
+ );
+
+ await extensionManager.loadExtensions();
+
+ // 2. Create a temporary "new" version with a different name
+ const newSourceDir = fs.mkdtempSync(
+ path.join(tempHomeDir, 'new-source-'),
+ );
+ fs.writeFileSync(
+ path.join(newSourceDir, 'gemini-extension.json'),
+ JSON.stringify({ name: newName, version: '1.1.0' }),
+ );
+ fs.writeFileSync(
+ path.join(newSourceDir, 'metadata.json'),
+ JSON.stringify({ type: 'local', source: newSourceDir }),
+ );
+
+ // 3. Update the extension
+ await extensionManager.installOrUpdateExtension(
+ { type: 'local', source: newSourceDir },
+ { name: oldName, version: '1.0.0' },
+ );
+
+ // 4. Verify old directory is gone and new one exists
+ expect(fs.existsSync(path.join(userExtensionsDir, oldName))).toBe(false);
+ expect(fs.existsSync(path.join(userExtensionsDir, newName))).toBe(true);
+
+ // Verify the loaded state is updated
+ const extensions = extensionManager.getExtensions();
+ expect(extensions.some((e) => e.name === newName)).toBe(true);
+ expect(extensions.some((e) => e.name === oldName)).toBe(false);
+ });
+
+ it('should carry over enablement status when renaming', async () => {
+ const oldName = 'old-name';
+ const newName = 'new-name';
+ const extDir = path.join(userExtensionsDir, oldName);
+ fs.mkdirSync(extDir, { recursive: true });
+ fs.writeFileSync(
+ path.join(extDir, 'gemini-extension.json'),
+ JSON.stringify({ name: oldName, version: '1.0.0' }),
+ );
+ fs.writeFileSync(
+ path.join(extDir, 'metadata.json'),
+ JSON.stringify({ type: 'local', source: extDir }),
+ );
+
+ // Enable it
+ const enablementManager = extensionManager.getEnablementManager();
+ enablementManager.enable(oldName, true, tempHomeDir);
+
+ await extensionManager.loadExtensions();
+ const extension = extensionManager.getExtensions()[0];
+ expect(extension.isActive).toBe(true);
+
+ const newSourceDir = fs.mkdtempSync(
+ path.join(tempHomeDir, 'new-source-'),
+ );
+ fs.writeFileSync(
+ path.join(newSourceDir, 'gemini-extension.json'),
+ JSON.stringify({ name: newName, version: '1.1.0' }),
+ );
+ fs.writeFileSync(
+ path.join(newSourceDir, 'metadata.json'),
+ JSON.stringify({ type: 'local', source: newSourceDir }),
+ );
+
+ await extensionManager.installOrUpdateExtension(
+ { type: 'local', source: newSourceDir },
+ { name: oldName, version: '1.0.0' },
+ );
+
+ // Verify new name is enabled
+ expect(enablementManager.isEnabled(newName, tempHomeDir)).toBe(true);
+ // Verify old name is removed from enablement
+ expect(enablementManager.readConfig()[oldName]).toBeUndefined();
+ });
+
+ it('should prevent renaming if the new name conflicts with an existing extension', async () => {
+ // Setup two extensions
+ const ext1Dir = path.join(userExtensionsDir, 'ext1');
+ fs.mkdirSync(ext1Dir, { recursive: true });
+ fs.writeFileSync(
+ path.join(ext1Dir, 'gemini-extension.json'),
+ JSON.stringify({ name: 'ext1', version: '1.0.0' }),
+ );
+ fs.writeFileSync(
+ path.join(ext1Dir, 'metadata.json'),
+ JSON.stringify({ type: 'local', source: ext1Dir }),
+ );
+
+ const ext2Dir = path.join(userExtensionsDir, 'ext2');
+ fs.mkdirSync(ext2Dir, { recursive: true });
+ fs.writeFileSync(
+ path.join(ext2Dir, 'gemini-extension.json'),
+ JSON.stringify({ name: 'ext2', version: '1.0.0' }),
+ );
+ fs.writeFileSync(
+ path.join(ext2Dir, 'metadata.json'),
+ JSON.stringify({ type: 'local', source: ext2Dir }),
+ );
+
+ await extensionManager.loadExtensions();
+
+ // Try to update ext1 to name 'ext2'
+ const newSourceDir = fs.mkdtempSync(
+ path.join(tempHomeDir, 'new-source-'),
+ );
+ fs.writeFileSync(
+ path.join(newSourceDir, 'gemini-extension.json'),
+ JSON.stringify({ name: 'ext2', version: '1.1.0' }),
+ );
+ fs.writeFileSync(
+ path.join(newSourceDir, 'metadata.json'),
+ JSON.stringify({ type: 'local', source: newSourceDir }),
+ );
+
+ await expect(
+ extensionManager.installOrUpdateExtension(
+ { type: 'local', source: newSourceDir },
+ { name: 'ext1', version: '1.0.0' },
+ ),
+ ).rejects.toThrow(/already installed/);
+ });
+ });
});
diff --git a/packages/cli/src/config/extension-manager.ts b/packages/cli/src/config/extension-manager.ts
index 678350ba49..5da4f1ed44 100644
--- a/packages/cli/src/config/extension-manager.ts
+++ b/packages/cli/src/config/extension-manager.ts
@@ -129,6 +129,10 @@ export class ExtensionManager extends ExtensionLoader {
this.requestSetting = options.requestSetting ?? undefined;
}
+ getEnablementManager(): ExtensionEnablementManager {
+ return this.extensionEnablementManager;
+ }
+
setRequestConsent(
requestConsent: (consent: string) => Promise,
): void {
@@ -271,17 +275,28 @@ Would you like to attempt to install via "git clone" instead?`,
newExtensionConfig = await this.loadExtensionConfig(localSourcePath);
const newExtensionName = newExtensionConfig.name;
+ const previousName = previousExtensionConfig?.name ?? newExtensionName;
const previous = this.getExtensions().find(
- (installed) => installed.name === newExtensionName,
+ (installed) => installed.name === previousName,
);
+ const nameConflict = this.getExtensions().find(
+ (installed) =>
+ installed.name === newExtensionName &&
+ installed.name !== previousName,
+ );
+
if (isUpdate && !previous) {
throw new Error(
- `Extension "${newExtensionName}" was not already installed, cannot update it.`,
+ `Extension "${previousName}" was not already installed, cannot update it.`,
);
} else if (!isUpdate && previous) {
throw new Error(
`Extension "${newExtensionName}" is already installed. Please uninstall it first.`,
);
+ } else if (isUpdate && nameConflict) {
+ throw new Error(
+ `Cannot update to "${newExtensionName}" because an extension with that name is already installed.`,
+ );
}
const newHasHooks = fs.existsSync(
@@ -298,6 +313,11 @@ Would you like to attempt to install via "git clone" instead?`,
path.join(localSourcePath, 'skills'),
);
const previousSkills = previous?.skills ?? [];
+ const isMigrating = Boolean(
+ previous &&
+ previous.installMetadata &&
+ previous.installMetadata.source !== installMetadata.source,
+ );
await maybeRequestConsentOrFail(
newExtensionConfig,
@@ -307,19 +327,46 @@ Would you like to attempt to install via "git clone" instead?`,
previousHasHooks,
newSkills,
previousSkills,
+ isMigrating,
);
const extensionId = getExtensionId(newExtensionConfig, installMetadata);
const destinationPath = new ExtensionStorage(
newExtensionName,
).getExtensionDir();
+
+ if (
+ (!isUpdate || newExtensionName !== previousName) &&
+ fs.existsSync(destinationPath)
+ ) {
+ throw new Error(
+ `Cannot install extension "${newExtensionName}" because a directory with that name already exists. Please remove it manually.`,
+ );
+ }
+
let previousSettings: Record | undefined;
- if (isUpdate) {
+ let wasEnabledGlobally = false;
+ let wasEnabledWorkspace = false;
+ if (isUpdate && previousExtensionConfig) {
+ const previousExtensionId = previous?.installMetadata
+ ? getExtensionId(previousExtensionConfig, previous.installMetadata)
+ : extensionId;
previousSettings = await getEnvContents(
previousExtensionConfig,
- extensionId,
+ previousExtensionId,
this.workspaceDir,
);
- await this.uninstallExtension(newExtensionName, isUpdate);
+ if (newExtensionName !== previousName) {
+ wasEnabledGlobally = this.extensionEnablementManager.isEnabled(
+ previousName,
+ homedir(),
+ );
+ wasEnabledWorkspace = this.extensionEnablementManager.isEnabled(
+ previousName,
+ this.workspaceDir,
+ );
+ this.extensionEnablementManager.remove(previousName);
+ }
+ await this.uninstallExtension(previousName, isUpdate);
}
await fs.promises.mkdir(destinationPath, { recursive: true });
@@ -392,6 +439,18 @@ Would you like to attempt to install via "git clone" instead?`,
CoreToolCallStatus.Success,
),
);
+
+ if (newExtensionName !== previousName) {
+ if (wasEnabledGlobally) {
+ await this.enableExtension(newExtensionName, SettingScope.User);
+ }
+ if (wasEnabledWorkspace) {
+ await this.enableExtension(
+ newExtensionName,
+ SettingScope.Workspace,
+ );
+ }
+ }
} else {
await logExtensionInstallEvent(
this.telemetryConfig,
@@ -873,6 +932,7 @@ Would you like to attempt to install via "git clone" instead?`,
path: effectiveExtensionPath,
contextFiles,
installMetadata,
+ migratedTo: config.migratedTo,
mcpServers: config.mcpServers,
excludeTools: config.excludeTools,
hooks,
diff --git a/packages/cli/src/config/extension.ts b/packages/cli/src/config/extension.ts
index 04a7b885ca..564c4fbb6f 100644
--- a/packages/cli/src/config/extension.ts
+++ b/packages/cli/src/config/extension.ts
@@ -42,6 +42,10 @@ export interface ExtensionConfig {
*/
directory?: string;
};
+ /**
+ * Used to migrate an extension to a new repository source.
+ */
+ migratedTo?: string;
}
export interface ExtensionUpdateInfo {
diff --git a/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-request-consent-if-extension-is-migrated.snap.svg b/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-request-consent-if-extension-is-migrated.snap.svg
new file mode 100644
index 0000000000..34161f8eb0
--- /dev/null
+++ b/packages/cli/src/config/extensions/__snapshots__/consent-consent-maybeRequestConsentOrFail-consent-string-generation-should-request-consent-if-extension-is-migrated.snap.svg
@@ -0,0 +1,13 @@
+
\ No newline at end of file
diff --git a/packages/cli/src/config/extensions/__snapshots__/consent.test.ts.snap b/packages/cli/src/config/extensions/__snapshots__/consent.test.ts.snap
index d8fe99d004..59b00995eb 100644
--- a/packages/cli/src/config/extensions/__snapshots__/consent.test.ts.snap
+++ b/packages/cli/src/config/extensions/__snapshots__/consent.test.ts.snap
@@ -24,6 +24,15 @@ of extensions. Please carefully inspect any extension and its source code before
understand the permissions it requires and the actions it may perform."
`;
+exports[`consent > maybeRequestConsentOrFail > consent string generation > should request consent if extension is migrated 1`] = `
+"Migrating extension "old-ext" to a new repository, renaming to "test-ext", and installing updates.
+
+The extension you are about to install may have been created by a third-party developer and sourced
+from a public repository. Google does not vet, endorse, or guarantee the functionality or security
+of extensions. Please carefully inspect any extension and its source code before installing to
+understand the permissions it requires and the actions it may perform."
+`;
+
exports[`consent > maybeRequestConsentOrFail > consent string generation > should request consent if skills change 1`] = `
"Installing extension "test-ext".
This extension will run the following MCP servers:
diff --git a/packages/cli/src/config/extensions/consent.test.ts b/packages/cli/src/config/extensions/consent.test.ts
index 04e6cae69f..76d7227ab4 100644
--- a/packages/cli/src/config/extensions/consent.test.ts
+++ b/packages/cli/src/config/extensions/consent.test.ts
@@ -287,6 +287,25 @@ describe('consent', () => {
expect(requestConsent).toHaveBeenCalledTimes(1);
});
+ it('should request consent if extension is migrated', async () => {
+ const requestConsent = vi.fn().mockResolvedValue(true);
+ await maybeRequestConsentOrFail(
+ baseConfig,
+ requestConsent,
+ false,
+ { ...baseConfig, name: 'old-ext' },
+ false,
+ [],
+ [],
+ true,
+ );
+
+ expect(requestConsent).toHaveBeenCalledTimes(1);
+ let consentString = requestConsent.mock.calls[0][0] as string;
+ consentString = normalizePathsForSnapshot(consentString, tempDir);
+ await expectConsentSnapshot(consentString);
+ });
+
it('should request consent if skills change', async () => {
const skill1Dir = path.join(tempDir, 'skill1');
const skill2Dir = path.join(tempDir, 'skill2');
diff --git a/packages/cli/src/config/extensions/consent.ts b/packages/cli/src/config/extensions/consent.ts
index 9a63054d12..5c35c0d899 100644
--- a/packages/cli/src/config/extensions/consent.ts
+++ b/packages/cli/src/config/extensions/consent.ts
@@ -148,11 +148,30 @@ async function extensionConsentString(
extensionConfig: ExtensionConfig,
hasHooks: boolean,
skills: SkillDefinition[] = [],
+ previousName?: string,
+ wasMigrated?: boolean,
): Promise {
const sanitizedConfig = escapeAnsiCtrlCodes(extensionConfig);
const output: string[] = [];
const mcpServerEntries = Object.entries(sanitizedConfig.mcpServers || {});
- output.push(`Installing extension "${sanitizedConfig.name}".`);
+
+ if (wasMigrated) {
+ if (previousName && previousName !== sanitizedConfig.name) {
+ output.push(
+ `Migrating extension "${previousName}" to a new repository, renaming to "${sanitizedConfig.name}", and installing updates.`,
+ );
+ } else {
+ output.push(
+ `Migrating extension "${sanitizedConfig.name}" to a new repository and installing updates.`,
+ );
+ }
+ } else if (previousName && previousName !== sanitizedConfig.name) {
+ output.push(
+ `Renaming extension "${previousName}" to "${sanitizedConfig.name}" and installing updates.`,
+ );
+ } else {
+ output.push(`Installing extension "${sanitizedConfig.name}".`);
+ }
if (mcpServerEntries.length) {
output.push('This extension will run the following MCP servers:');
@@ -231,11 +250,14 @@ export async function maybeRequestConsentOrFail(
previousHasHooks?: boolean,
skills: SkillDefinition[] = [],
previousSkills: SkillDefinition[] = [],
+ isMigrating: boolean = false,
) {
const extensionConsent = await extensionConsentString(
extensionConfig,
hasHooks,
skills,
+ previousExtensionConfig?.name,
+ isMigrating,
);
if (previousExtensionConfig) {
const previousExtensionConsent = await extensionConsentString(
diff --git a/packages/cli/src/config/extensions/github.test.ts b/packages/cli/src/config/extensions/github.test.ts
index c3ff5905b5..830506c002 100644
--- a/packages/cli/src/config/extensions/github.test.ts
+++ b/packages/cli/src/config/extensions/github.test.ts
@@ -285,6 +285,23 @@ describe('github.ts', () => {
ExtensionUpdateState.NOT_UPDATABLE,
);
});
+
+ it('should check migratedTo source if present and return UPDATE_AVAILABLE', async () => {
+ mockGit.getRemotes.mockResolvedValue([
+ { name: 'origin', refs: { fetch: 'new-url' } },
+ ]);
+ mockGit.listRemote.mockResolvedValue('hash\tHEAD');
+ mockGit.revparse.mockResolvedValue('hash');
+
+ const ext = {
+ path: '/path',
+ migratedTo: 'new-url',
+ installMetadata: { type: 'git', source: 'old-url' },
+ } as unknown as GeminiCLIExtension;
+ expect(await checkForExtensionUpdate(ext, mockExtensionManager)).toBe(
+ ExtensionUpdateState.UPDATE_AVAILABLE,
+ );
+ });
});
describe('downloadFromGitHubRelease', () => {
diff --git a/packages/cli/src/config/extensions/github.ts b/packages/cli/src/config/extensions/github.ts
index e8b35a6184..0141ffcc0e 100644
--- a/packages/cli/src/config/extensions/github.ts
+++ b/packages/cli/src/config/extensions/github.ts
@@ -203,6 +203,24 @@ export async function checkForExtensionUpdate(
) {
return ExtensionUpdateState.NOT_UPDATABLE;
}
+
+ if (extension.migratedTo) {
+ const migratedState = await checkForExtensionUpdate(
+ {
+ ...extension,
+ installMetadata: { ...installMetadata, source: extension.migratedTo },
+ migratedTo: undefined,
+ },
+ extensionManager,
+ );
+ if (
+ migratedState === ExtensionUpdateState.UPDATE_AVAILABLE ||
+ migratedState === ExtensionUpdateState.UP_TO_DATE
+ ) {
+ return ExtensionUpdateState.UPDATE_AVAILABLE;
+ }
+ }
+
try {
if (installMetadata.type === 'git') {
const git = simpleGit(extension.path);
diff --git a/packages/cli/src/config/extensions/update.test.ts b/packages/cli/src/config/extensions/update.test.ts
index cb5bba2a11..cee50263bb 100644
--- a/packages/cli/src/config/extensions/update.test.ts
+++ b/packages/cli/src/config/extensions/update.test.ts
@@ -184,6 +184,54 @@ describe('Extension Update Logic', () => {
});
});
+ it('should migrate source if migratedTo is set and an update is available', async () => {
+ vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue(
+ Promise.resolve({
+ name: 'test-extension',
+ version: '1.0.0',
+ }),
+ );
+ vi.mocked(
+ mockExtensionManager.installOrUpdateExtension,
+ ).mockResolvedValue({
+ ...mockExtension,
+ version: '1.1.0',
+ });
+ vi.mocked(checkForExtensionUpdate).mockResolvedValue(
+ ExtensionUpdateState.UPDATE_AVAILABLE,
+ );
+
+ const extensionWithMigratedTo = {
+ ...mockExtension,
+ migratedTo: 'https://new-source.com/repo.git',
+ };
+
+ await updateExtension(
+ extensionWithMigratedTo,
+ mockExtensionManager,
+ ExtensionUpdateState.UPDATE_AVAILABLE,
+ mockDispatch,
+ );
+
+ expect(checkForExtensionUpdate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ installMetadata: expect.objectContaining({
+ source: 'https://new-source.com/repo.git',
+ }),
+ }),
+ mockExtensionManager,
+ );
+
+ expect(
+ mockExtensionManager.installOrUpdateExtension,
+ ).toHaveBeenCalledWith(
+ expect.objectContaining({
+ source: 'https://new-source.com/repo.git',
+ }),
+ expect.anything(),
+ );
+ });
+
it('should set state to UPDATED if enableExtensionReloading is true', async () => {
vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue(
Promise.resolve({
diff --git a/packages/cli/src/config/extensions/update.ts b/packages/cli/src/config/extensions/update.ts
index bdb43e0975..b1139d7143 100644
--- a/packages/cli/src/config/extensions/update.ts
+++ b/packages/cli/src/config/extensions/update.ts
@@ -55,6 +55,24 @@ export async function updateExtension(
});
throw new Error(`Extension is linked so does not need to be updated`);
}
+
+ if (extension.migratedTo) {
+ const migratedState = await checkForExtensionUpdate(
+ {
+ ...extension,
+ installMetadata: { ...installMetadata, source: extension.migratedTo },
+ migratedTo: undefined,
+ },
+ extensionManager,
+ );
+ if (
+ migratedState === ExtensionUpdateState.UPDATE_AVAILABLE ||
+ migratedState === ExtensionUpdateState.UP_TO_DATE
+ ) {
+ installMetadata.source = extension.migratedTo;
+ }
+ }
+
const originalVersion = extension.version;
const tempDir = await ExtensionStorage.createTmpDir();
diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts
index f615564533..ba8f5d508b 100644
--- a/packages/core/src/config/config.ts
+++ b/packages/core/src/config/config.ts
@@ -361,6 +361,10 @@ export interface GeminiCLIExtension {
*/
directory?: string;
};
+ /**
+ * Used to migrate an extension to a new repository source.
+ */
+ migratedTo?: string;
}
export interface ExtensionInstallMetadata {
diff --git a/pr-description.md b/pr-description.md
new file mode 100644
index 0000000000..162b692109
--- /dev/null
+++ b/pr-description.md
@@ -0,0 +1,65 @@
+## Summary
+
+This PR implements a seamless migration path for extensions to move to a new
+repository and optionally change their name without stranding existing users.
+
+When an extension author sets the `migratedTo` field in their
+`gemini-extension.json` and publishes an update to their old repository, the CLI
+will detect this during the next update check. The CLI will then automatically
+download the extension from the new repository, explicitly warn the user about
+the migration (and any renaming) during the consent step, and seamlessly migrate
+the installation and enablement status while cleaning up the old installation.
+
+## Details
+
+- **Configuration:** Added `migratedTo` property to `ExtensionConfig` and
+ `GeminiCLIExtension` to track the new repository URL.
+- **Update checking & downloading:** Updated `checkForExtensionUpdate` and
+ `updateExtension` to inspect the `migratedTo` field. If present, the CLI
+ queries the new repository URL for an update and swaps the installation source
+ so the update resolves from the new location.
+- **Migration & renaming logic (`ExtensionManager`):**
+ - `installOrUpdateExtension` now fully supports renaming. It transfers global
+ and workspace enablement states from the old extension name to the new one
+ and deletes the old extension directory.
+ - Added safeguards to block renaming if the new name conflicts with a
+ different, already-installed extension or if the destination directory
+ already exists.
+ - Exposed `getEnablementManager()` to `ExtensionManager` for better typing
+ during testing.
+- **Consent messaging:** Refactored `maybeRequestConsentOrFail` to compute an
+ `isMigrating` flag (by detecting a change in the installation source). The
+ `extensionConsentString` output now explicitly informs users with messages
+ like: _"Migrating extension 'old-name' to a new repository, renaming to
+ 'new-name', and installing updates."_
+- **Documentation:** Documented the `migratedTo` field in
+ `docs/extensions/reference.md` and added a comprehensive guide in
+ `docs/extensions/releasing.md` explaining how extension maintainers can
+ transition users using this feature.
+- **Testing:** Added extensive unit tests across `extension-manager.test.ts`,
+ `consent.test.ts`, `github.test.ts`, and `update.test.ts` to cover the new
+ migration and renaming logic.
+
+## Related issues
+
+N/A
+
+## How to validate
+
+1. **Unit tests:** Run all related tests to confirm everything passes:
+ ```bash
+ npm run test -w @google/gemini-cli -- src/config/extensions/github.test.ts
+ npm run test -w @google/gemini-cli -- src/config/extensions/update.test.ts
+ npm run test -w @google/gemini-cli -- src/config/extensions/consent.test.ts
+ npm run test -w @google/gemini-cli -- src/config/extension-manager.test.ts
+ ```
+2. **End-to-end migration test:**
+ - Install a local or git extension.
+ - Update its `gemini-extension.json` to include a `migratedTo` field pointing
+ to a _different_ test repository.
+ - Run `gemini extensions check` to confirm it detects the update from the new
+ source.
+ - Run `gemini extensions update `.
+ - Verify that the consent prompt explicitly mentions the migration.
+ - Verify that the new extension is installed, the old directory is deleted,
+ and its enablement status carried over.
From 1e1e7e349d39e59db52003e991bf9c736cfa0b17 Mon Sep 17 00:00:00 2001
From: sinisterchill <91934084+reyyanxahmed@users.noreply.github.com>
Date: Tue, 10 Mar 2026 05:21:10 +0530
Subject: [PATCH 009/145] fix(core): handle GUI editor non-zero exit codes
gracefully (#20376)
Co-authored-by: Jacob Richman
---
packages/core/src/utils/editor.test.ts | 71 +++++++++++++++++++++++++-
packages/core/src/utils/editor.ts | 23 +++++++--
2 files changed, 88 insertions(+), 6 deletions(-)
diff --git a/packages/core/src/utils/editor.test.ts b/packages/core/src/utils/editor.test.ts
index d46c58d677..4005d44b43 100644
--- a/packages/core/src/utils/editor.test.ts
+++ b/packages/core/src/utils/editor.test.ts
@@ -392,7 +392,10 @@ describe('editor utils', () => {
);
});
- it(`should reject if ${editor} exits with non-zero code`, async () => {
+ it(`should resolve and log warning if ${editor} exits with non-zero code`, async () => {
+ const warnSpy = vi
+ .spyOn(debugLogger, 'warn')
+ .mockImplementation(() => {});
const mockSpawnOn = vi.fn((event, cb) => {
if (event === 'close') {
cb(1);
@@ -400,9 +403,73 @@ describe('editor utils', () => {
});
(spawn as Mock).mockReturnValue({ on: mockSpawnOn });
+ await openDiff('old.txt', 'new.txt', editor);
+ expect(warnSpy).toHaveBeenCalledWith(`${editor} exited with code 1`);
+ });
+
+ it(`should emit ExternalEditorClosed when ${editor} exits successfully`, async () => {
+ const emitSpy = vi.spyOn(coreEvents, 'emit');
+ const mockSpawnOn = vi.fn((event, cb) => {
+ if (event === 'close') {
+ cb(0);
+ }
+ });
+ (spawn as Mock).mockReturnValue({ on: mockSpawnOn });
+
+ await openDiff('old.txt', 'new.txt', editor);
+ expect(emitSpy).toHaveBeenCalledWith(CoreEvent.ExternalEditorClosed);
+ });
+
+ it(`should emit ExternalEditorClosed when ${editor} exits with non-zero code`, async () => {
+ vi.spyOn(debugLogger, 'warn').mockImplementation(() => {});
+ const emitSpy = vi.spyOn(coreEvents, 'emit');
+ const mockSpawnOn = vi.fn((event, cb) => {
+ if (event === 'close') {
+ cb(1);
+ }
+ });
+ (spawn as Mock).mockReturnValue({ on: mockSpawnOn });
+
+ await openDiff('old.txt', 'new.txt', editor);
+ expect(emitSpy).toHaveBeenCalledWith(CoreEvent.ExternalEditorClosed);
+ });
+
+ it(`should emit ExternalEditorClosed when ${editor} spawn errors`, async () => {
+ const emitSpy = vi.spyOn(coreEvents, 'emit');
+ const mockError = new Error('spawn error');
+ const mockSpawnOn = vi.fn((event, cb) => {
+ if (event === 'error') {
+ cb(mockError);
+ }
+ });
+ (spawn as Mock).mockReturnValue({ on: mockSpawnOn });
+
await expect(openDiff('old.txt', 'new.txt', editor)).rejects.toThrow(
- `${editor} exited with code 1`,
+ 'spawn error',
);
+ expect(emitSpy).toHaveBeenCalledWith(CoreEvent.ExternalEditorClosed);
+ });
+
+ it(`should only emit ExternalEditorClosed once when ${editor} fires both error and close`, async () => {
+ const emitSpy = vi.spyOn(coreEvents, 'emit');
+ const callbacks: Record void> = {};
+ const mockSpawnOn = vi.fn(
+ (event: string, cb: (arg: unknown) => void) => {
+ callbacks[event] = cb;
+ },
+ );
+ (spawn as Mock).mockReturnValue({ on: mockSpawnOn });
+
+ const promise = openDiff('old.txt', 'new.txt', editor);
+ // Simulate Node.js behavior: error fires first, then close.
+ callbacks['error'](new Error('spawn error'));
+ callbacks['close'](1);
+
+ await expect(promise).rejects.toThrow('spawn error');
+ const editorClosedEmissions = emitSpy.mock.calls.filter(
+ (call) => call[0] === CoreEvent.ExternalEditorClosed,
+ );
+ expect(editorClosedEmissions).toHaveLength(1);
});
}
diff --git a/packages/core/src/utils/editor.ts b/packages/core/src/utils/editor.ts
index cdc1e1d4a5..29dc78fc49 100644
--- a/packages/core/src/utils/editor.ts
+++ b/packages/core/src/utils/editor.ts
@@ -323,15 +323,30 @@ export async function openDiff(
shell: process.platform === 'win32',
});
+ // Guard against both 'error' and 'close' firing for a single failure,
+ // which would emit ExternalEditorClosed twice and attempt to settle
+ // the promise twice.
+ let isSettled = false;
+
childProcess.on('close', (code) => {
- if (code === 0) {
- resolve();
- } else {
- reject(new Error(`${editor} exited with code ${code}`));
+ if (isSettled) return;
+ isSettled = true;
+
+ if (code !== 0) {
+ // GUI editors (VS Code, Zed, etc.) can exit with non-zero codes
+ // under normal circumstances (e.g., window closed while loading).
+ // Log a warning instead of crashing the CLI process.
+ debugLogger.warn(`${editor} exited with code ${code}`);
}
+ coreEvents.emit(CoreEvent.ExternalEditorClosed);
+ resolve();
});
childProcess.on('error', (error) => {
+ if (isSettled) return;
+ isSettled = true;
+
+ coreEvents.emit(CoreEvent.ExternalEditorClosed);
reject(error);
});
});
From 4653b126f3ec349e5801dabd54e0e1bb55ea43e0 Mon Sep 17 00:00:00 2001
From: Nicholas Bardy
Date: Tue, 10 Mar 2026 08:08:16 +0800
Subject: [PATCH 010/145] fix(core): destroy PTY on kill() and exception to
prevent fd leak (#21693)
Co-authored-by: Jacob Richman
---
.../services/shellExecutionService.test.ts | 71 +++++++++++++++++++
.../src/services/shellExecutionService.ts | 18 +++++
2 files changed, 89 insertions(+)
diff --git a/packages/core/src/services/shellExecutionService.test.ts b/packages/core/src/services/shellExecutionService.test.ts
index 77de13de3a..c99bb43292 100644
--- a/packages/core/src/services/shellExecutionService.test.ts
+++ b/packages/core/src/services/shellExecutionService.test.ts
@@ -870,6 +870,77 @@ describe('ShellExecutionService', () => {
expect(ShellExecutionService['activePtys'].size).toBe(0);
});
+
+ it('should destroy the PTY when kill() is called', async () => {
+ // Execute a command to populate activePtys
+ const abortController = new AbortController();
+ await ShellExecutionService.execute(
+ 'long-running',
+ '/test/dir',
+ onOutputEventMock,
+ abortController.signal,
+ true,
+ shellExecutionConfig,
+ );
+ await new Promise((resolve) => process.nextTick(resolve));
+
+ const pid = mockPtyProcess.pid;
+ const activePty = ShellExecutionService['activePtys'].get(pid);
+ expect(activePty).toBeTruthy();
+
+ // Spy on the actual stored object's destroy
+ const storedDestroySpy = vi.spyOn(
+ activePty!.ptyProcess as never as { destroy: () => void },
+ 'destroy',
+ );
+
+ ShellExecutionService.kill(pid);
+
+ expect(storedDestroySpy).toHaveBeenCalled();
+ expect(ShellExecutionService['activePtys'].has(pid)).toBe(false);
+ });
+
+ it('should destroy the PTY when an exception occurs after spawn in executeWithPty', async () => {
+ // Simulate: spawn succeeds, Promise executor runs fine (pid accesses 1-2),
+ // but the return statement `{ pid: ptyProcess.pid }` (access 3) throws.
+ // The catch block should call spawnedPty.destroy() to release the fd.
+ const destroySpy = vi.fn();
+ let pidAccessCount = 0;
+ const faultyPty = {
+ onData: vi.fn(),
+ onExit: vi.fn(),
+ write: vi.fn(),
+ kill: vi.fn(),
+ resize: vi.fn(),
+ destroy: destroySpy,
+ get pid() {
+ pidAccessCount++;
+ // Accesses 1-2 are inside the Promise executor (setup).
+ // Access 3 is at `return { pid: ptyProcess.pid, result }`,
+ // outside the Promise — caught by the outer try/catch.
+ if (pidAccessCount > 2) {
+ throw new Error('Simulated post-spawn failure on pid access');
+ }
+ return 77777;
+ },
+ };
+ mockPtySpawn.mockReturnValueOnce(faultyPty);
+
+ const handle = await ShellExecutionService.execute(
+ 'will-fail-after-spawn',
+ '/test/dir',
+ onOutputEventMock,
+ new AbortController().signal,
+ true,
+ shellExecutionConfig,
+ );
+
+ const result = await handle.result;
+ expect(result.exitCode).toBe(1);
+ expect(result.error).toBeTruthy();
+ // The catch block must call destroy() on spawnedPty to prevent fd leak
+ expect(destroySpy).toHaveBeenCalled();
+ });
});
});
diff --git a/packages/core/src/services/shellExecutionService.ts b/packages/core/src/services/shellExecutionService.ts
index fdb2ca79b5..e393767148 100644
--- a/packages/core/src/services/shellExecutionService.ts
+++ b/packages/core/src/services/shellExecutionService.ts
@@ -552,6 +552,8 @@ export class ShellExecutionService {
// This should not happen, but as a safeguard...
throw new Error('PTY implementation not found');
}
+ let spawnedPty: IPty | undefined;
+
try {
const cols = shellExecutionConfig.terminalWidth ?? 80;
const rows = shellExecutionConfig.terminalHeight ?? 30;
@@ -585,6 +587,8 @@ export class ShellExecutionService {
},
handleFlowControl: true,
});
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ spawnedPty = ptyProcess as IPty;
const result = new Promise((resolve) => {
this.activeResolvers.set(ptyProcess.pid, resolve);
@@ -882,6 +886,15 @@ export class ShellExecutionService {
} catch (e) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
const error = e as Error;
+
+ if (spawnedPty) {
+ try {
+ (spawnedPty as IPty & { destroy?: () => void }).destroy?.();
+ } catch {
+ // Ignore errors during cleanup
+ }
+ }
+
if (error.message.includes('posix_spawnp failed')) {
onOutputEvent({
type: 'data',
@@ -1008,6 +1021,11 @@ export class ShellExecutionService {
this.activeChildProcesses.delete(pid);
} else if (activePty) {
killProcessGroup({ pid, pty: activePty.ptyProcess }).catch(() => {});
+ try {
+ (activePty.ptyProcess as IPty & { destroy?: () => void }).destroy?.();
+ } catch {
+ // Ignore errors during cleanup
+ }
this.activePtys.delete(pid);
}
From ec7773eb7b56685e88657d30e633e9a0b0451963 Mon Sep 17 00:00:00 2001
From: Shehab <127568346+ashmod@users.noreply.github.com>
Date: Tue, 10 Mar 2026 02:35:10 +0200
Subject: [PATCH 011/145] fix(docs): update theme screenshots and add missing
themes (#20689)
Co-authored-by: Chris Williams
Co-authored-by: Chris Williams
Co-authored-by: Sam Roberts <158088236+g-samroberts@users.noreply.github.com>
---
README.md | 2 +-
docs/assets/theme-ansi-dark.png | Bin 0 -> 157337 bytes
docs/assets/theme-ansi-light.png | Bin 129047 -> 144829 bytes
docs/assets/theme-ansi.png | Bin 129751 -> 0 bytes
docs/assets/theme-atom-one-dark.png | Bin 0 -> 154448 bytes
docs/assets/theme-atom-one.png | Bin 131524 -> 0 bytes
docs/assets/theme-ayu-dark.png | Bin 0 -> 149973 bytes
docs/assets/theme-ayu-light.png | Bin 129077 -> 140516 bytes
docs/assets/theme-ayu.png | Bin 131264 -> 0 bytes
docs/assets/theme-default-dark.png | Bin 0 -> 158740 bytes
docs/assets/theme-default-light.png | Bin 128317 -> 147305 bytes
docs/assets/theme-default.png | Bin 130403 -> 0 bytes
docs/assets/theme-dracula-dark.png | Bin 0 -> 160255 bytes
docs/assets/theme-dracula.png | Bin 131270 -> 0 bytes
docs/assets/theme-github-dark.png | Bin 0 -> 157419 bytes
docs/assets/theme-github-light.png | Bin 129371 -> 138435 bytes
docs/assets/theme-github.png | Bin 131452 -> 0 bytes
docs/assets/theme-google-light.png | Bin 129324 -> 137186 bytes
docs/assets/theme-holiday-dark.png | Bin 0 -> 167285 bytes
docs/assets/theme-shades-of-purple-dark.png | Bin 0 -> 161235 bytes
docs/assets/theme-solarized-dark.png | Bin 0 -> 149290 bytes
docs/assets/theme-solarized-light.png | Bin 0 -> 141355 bytes
docs/assets/theme-xcode-light.png | Bin 127968 -> 123060 bytes
docs/cli/themes.md | 42 ++++++++++++--------
24 files changed, 27 insertions(+), 17 deletions(-)
create mode 100644 docs/assets/theme-ansi-dark.png
delete mode 100644 docs/assets/theme-ansi.png
create mode 100644 docs/assets/theme-atom-one-dark.png
delete mode 100644 docs/assets/theme-atom-one.png
create mode 100644 docs/assets/theme-ayu-dark.png
delete mode 100644 docs/assets/theme-ayu.png
create mode 100644 docs/assets/theme-default-dark.png
delete mode 100644 docs/assets/theme-default.png
create mode 100644 docs/assets/theme-dracula-dark.png
delete mode 100644 docs/assets/theme-dracula.png
create mode 100644 docs/assets/theme-github-dark.png
delete mode 100644 docs/assets/theme-github.png
create mode 100644 docs/assets/theme-holiday-dark.png
create mode 100644 docs/assets/theme-shades-of-purple-dark.png
create mode 100644 docs/assets/theme-solarized-dark.png
create mode 100644 docs/assets/theme-solarized-light.png
diff --git a/README.md b/README.md
index 959b5a9534..2b25865179 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@
[](https://github.com/google-gemini/gemini-cli/blob/main/LICENSE)
[](https://codewiki.google/github.com/google-gemini/gemini-cli?utm_source=badge&utm_medium=github&utm_campaign=github.com/google-gemini/gemini-cli)
-
+
Gemini CLI is an open-source AI agent that brings the power of Gemini directly
into your terminal. It provides lightweight access to Gemini, giving you the
diff --git a/docs/assets/theme-ansi-dark.png b/docs/assets/theme-ansi-dark.png
new file mode 100644
index 0000000000000000000000000000000000000000..10bcbd446e10ae7b879f82c456f853800c5cb867
GIT binary patch
literal 157337
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 }!ZAvuUqg=4%X2H1RLjTI}
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