Merge branch 'main' into rename-directory-to-workspace

This commit is contained in:
Dmitry Lyalin
2026-03-09 20:39:25 -04:00
committed by GitHub
77 changed files with 1180 additions and 430 deletions
+1 -1
View File
@@ -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.
+4
View File
@@ -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
+26
View File
@@ -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.
+2 -2
View File
@@ -71,9 +71,9 @@ Slash commands provide meta-level control over the CLI itself.
[Checkpointing documentation](../cli/checkpointing.md).
- **Equivalent:** `/resume save <tag>`
- **`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`
+7 -8
View File
@@ -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
@@ -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/);
});
});
});
+65 -5
View File
@@ -129,6 +129,10 @@ export class ExtensionManager extends ExtensionLoader {
this.requestSetting = options.requestSetting ?? undefined;
}
getEnablementManager(): ExtensionEnablementManager {
return this.extensionEnablementManager;
}
setRequestConsent(
requestConsent: (consent: string) => Promise<boolean>,
): 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<string, string> | 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,
+4
View File
@@ -42,6 +42,10 @@ export interface ExtensionConfig {
*/
directory?: string;
};
/**
* Used to migrate an extension to a new repository source.
*/
migratedTo?: string;
}
export interface ExtensionUpdateInfo {
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="122" viewBox="0 0 920 122">
<style>
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
</style>
<rect width="920" height="122" fill="#000000" />
<g transform="translate(10, 10)">
<text x="0" y="2" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Migrating extension &quot;old-ext&quot; to a new repository, renaming to &quot;test-ext&quot;, and installing updates. </text>
<text x="0" y="36" fill="#cdcd00" textLength="891" lengthAdjust="spacingAndGlyphs">The extension you are about to install may have been created by a third-party developer and sourced</text>
<text x="0" y="53" fill="#cdcd00" textLength="882" lengthAdjust="spacingAndGlyphs">from a public repository. Google does not vet, endorse, or guarantee the functionality or security</text>
<text x="0" y="70" fill="#cdcd00" textLength="846" lengthAdjust="spacingAndGlyphs">of extensions. Please carefully inspect any extension and its source code before installing to</text>
<text x="0" y="87" fill="#cdcd00" textLength="630" lengthAdjust="spacingAndGlyphs">understand the permissions it requires and the actions it may perform.</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -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:
@@ -288,6 +288,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');
+23 -1
View File
@@ -148,11 +148,30 @@ async function extensionConsentString(
extensionConfig: ExtensionConfig,
hasHooks: boolean,
skills: SkillDefinition[] = [],
previousName?: string,
wasMigrated?: boolean,
): Promise<string> {
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(
@@ -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', () => {
@@ -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);
@@ -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({
@@ -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();
@@ -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<Command>();
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);
});
});
});
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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 {
@@ -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 = () => {
@@ -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;
@@ -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,
@@ -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,
@@ -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,
@@ -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 {
@@ -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';
+2 -2
View File
@@ -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[];
@@ -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';
/**
@@ -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,
@@ -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';
@@ -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 {
@@ -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);
@@ -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 {
@@ -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';
@@ -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 {
@@ -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' },
{
@@ -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 {
@@ -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;
@@ -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();
@@ -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';
@@ -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;
@@ -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';
/**
@@ -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.
@@ -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';
@@ -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;
@@ -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';
/**
@@ -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 {
@@ -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';
@@ -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 {
@@ -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';
-77
View File
@@ -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;
}
@@ -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';
+2 -2
View File
@@ -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.
@@ -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';
+2 -2
View File
@@ -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');
+2 -2
View File
@@ -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;
@@ -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>): Key => ({
name: partial.name || '',
sequence: partial.sequence || '',
@@ -32,27 +26,10 @@ const createKey = (partial: Partial<Key>): 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;
});
@@ -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';
/**
+1 -1
View File
@@ -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';
+159
View File
@@ -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<Command>();
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);
});
});
});
@@ -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<Record<Command, string>> = {
[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.',
@@ -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> = {}): 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);
@@ -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));
}
/**
+55
View File
@@ -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<string, string> = {
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;
}
@@ -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',
@@ -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<string, string> = {
end: 'End',
tab: 'Tab',
space: 'Space',
'double escape': 'Double Esc',
};
interface ModifierMap {
+2 -2
View File
@@ -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) => (
<Box key={index} minHeight={1}>
+1 -1
View File
@@ -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';
+4
View File
@@ -361,6 +361,10 @@ export interface GeminiCLIExtension {
*/
directory?: string;
};
/**
* Used to migrate an extension to a new repository source.
*/
migratedTo?: string;
}
export interface ExtensionInstallMetadata {
@@ -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();
});
});
});
@@ -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<ShellExecutionResult>((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);
}
@@ -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
+69 -2
View File
@@ -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<string, (arg: unknown) => 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);
});
}
+19 -4
View File
@@ -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);
});
});
+1 -2
View File
@@ -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;
+65
View File
@@ -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 <extension>`.
- 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.
+3 -3
View File
@@ -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 = '<!-- KEYBINDINGS-AUTOGEN:START -->';
const END_MARKER = '<!-- KEYBINDINGS-AUTOGEN:END -->';
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;