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/README.md b/README.md index 959b5a9534..2b25865179 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ [![License](https://img.shields.io/github/license/google-gemini/gemini-cli)](https://github.com/google-gemini/gemini-cli/blob/main/LICENSE) [![View Code Wiki](https://assets.codewiki.google/readme-badge/static.svg)](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 Screenshot](./docs/assets/gemini-screenshot.png) +![Gemini CLI Screenshot](/docs/assets/gemini-screenshot.png) 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 0000000000..10bcbd446e Binary files /dev/null and b/docs/assets/theme-ansi-dark.png differ diff --git a/docs/assets/theme-ansi-light.png b/docs/assets/theme-ansi-light.png index 9766ae7820..8973ef2f99 100644 Binary files a/docs/assets/theme-ansi-light.png and b/docs/assets/theme-ansi-light.png differ diff --git a/docs/assets/theme-ansi.png b/docs/assets/theme-ansi.png deleted file mode 100644 index 5d46dacab8..0000000000 Binary files a/docs/assets/theme-ansi.png and /dev/null differ diff --git a/docs/assets/theme-atom-one-dark.png b/docs/assets/theme-atom-one-dark.png new file mode 100644 index 0000000000..f81ba24812 Binary files /dev/null and b/docs/assets/theme-atom-one-dark.png differ diff --git a/docs/assets/theme-atom-one.png b/docs/assets/theme-atom-one.png deleted file mode 100644 index c2787d6b62..0000000000 Binary files a/docs/assets/theme-atom-one.png and /dev/null differ diff --git a/docs/assets/theme-ayu-dark.png b/docs/assets/theme-ayu-dark.png new file mode 100644 index 0000000000..3f5d01d110 Binary files /dev/null and b/docs/assets/theme-ayu-dark.png differ diff --git a/docs/assets/theme-ayu-light.png b/docs/assets/theme-ayu-light.png index f177465679..a276a13c05 100644 Binary files a/docs/assets/theme-ayu-light.png and b/docs/assets/theme-ayu-light.png differ diff --git a/docs/assets/theme-ayu.png b/docs/assets/theme-ayu.png deleted file mode 100644 index 99391f8271..0000000000 Binary files a/docs/assets/theme-ayu.png and /dev/null differ diff --git a/docs/assets/theme-default-dark.png b/docs/assets/theme-default-dark.png new file mode 100644 index 0000000000..2f3e2d7534 Binary files /dev/null and b/docs/assets/theme-default-dark.png differ diff --git a/docs/assets/theme-default-light.png b/docs/assets/theme-default-light.png index 829d4ed5cc..e454211fdb 100644 Binary files a/docs/assets/theme-default-light.png and b/docs/assets/theme-default-light.png differ diff --git a/docs/assets/theme-default.png b/docs/assets/theme-default.png deleted file mode 100644 index 0b93a33433..0000000000 Binary files a/docs/assets/theme-default.png and /dev/null differ diff --git a/docs/assets/theme-dracula-dark.png b/docs/assets/theme-dracula-dark.png new file mode 100644 index 0000000000..e95183708e Binary files /dev/null and b/docs/assets/theme-dracula-dark.png differ diff --git a/docs/assets/theme-dracula.png b/docs/assets/theme-dracula.png deleted file mode 100644 index 27213fbc5c..0000000000 Binary files a/docs/assets/theme-dracula.png and /dev/null differ diff --git a/docs/assets/theme-github-dark.png b/docs/assets/theme-github-dark.png new file mode 100644 index 0000000000..bcbd78ee29 Binary files /dev/null and b/docs/assets/theme-github-dark.png differ diff --git a/docs/assets/theme-github-light.png b/docs/assets/theme-github-light.png index 3cdc94aa49..35fbec5c8b 100644 Binary files a/docs/assets/theme-github-light.png and b/docs/assets/theme-github-light.png differ diff --git a/docs/assets/theme-github.png b/docs/assets/theme-github.png deleted file mode 100644 index a62961b650..0000000000 Binary files a/docs/assets/theme-github.png and /dev/null differ diff --git a/docs/assets/theme-google-light.png b/docs/assets/theme-google-light.png index 835ebc4bea..04f0aa8e46 100644 Binary files a/docs/assets/theme-google-light.png and b/docs/assets/theme-google-light.png differ diff --git a/docs/assets/theme-holiday-dark.png b/docs/assets/theme-holiday-dark.png new file mode 100644 index 0000000000..70416650d5 Binary files /dev/null and b/docs/assets/theme-holiday-dark.png differ diff --git a/docs/assets/theme-shades-of-purple-dark.png b/docs/assets/theme-shades-of-purple-dark.png new file mode 100644 index 0000000000..c3d2e50538 Binary files /dev/null and b/docs/assets/theme-shades-of-purple-dark.png differ diff --git a/docs/assets/theme-solarized-dark.png b/docs/assets/theme-solarized-dark.png new file mode 100644 index 0000000000..be57349283 Binary files /dev/null and b/docs/assets/theme-solarized-dark.png differ diff --git a/docs/assets/theme-solarized-light.png b/docs/assets/theme-solarized-light.png new file mode 100644 index 0000000000..838a3b6870 Binary files /dev/null and b/docs/assets/theme-solarized-light.png differ diff --git a/docs/assets/theme-xcode-light.png b/docs/assets/theme-xcode-light.png index eb056a5589..26f0a74314 100644 Binary files a/docs/assets/theme-xcode-light.png and b/docs/assets/theme-xcode-light.png differ diff --git a/docs/cli/themes.md b/docs/cli/themes.md index 08564a249a..adfe64d081 100644 --- a/docs/cli/themes.md +++ b/docs/cli/themes.md @@ -16,6 +16,8 @@ using the `/theme` command within Gemini CLI: - `Default` - `Dracula` - `GitHub` + - `Holiday` + - `Shades Of Purple` - `Solarized Dark` - **Light themes:** - `ANSI Light` @@ -185,7 +187,7 @@ untrusted sources. ### Example custom theme -Custom theme example +Custom theme example ### Using your custom theme @@ -212,58 +214,66 @@ identify their source, for example: `shades-of-green (green-extension)`. ### ANSI -ANSI theme +ANSI theme -### Atom OneDark +### Atom One -Atom One theme +Atom One theme ### Ayu -Ayu theme +Ayu theme ### Default -Default theme +Default theme ### Dracula -Dracula theme +Dracula theme ### GitHub -GitHub theme +GitHub theme + +### Holiday + +Holiday theme + +### Shades Of Purple + +Shades Of Purple theme ### Solarized Dark -Solarized Dark theme +Solarized Dark theme ## Light themes ### ANSI Light -ANSI Light theme +ANSI Light theme ### Ayu Light -Ayu Light theme +Ayu Light theme ### Default Light -Default Light theme +Default Light theme ### GitHub Light -GitHub Light theme +GitHub Light theme ### Google Code -Google Code theme +Google Code theme ### Solarized Light -Solarized Light theme +Solarized Light theme ### Xcode -Xcode Light theme +Xcode Light theme 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/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` 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/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 @@ + + + + + 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. + + \ 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/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.test.tsx b/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx index da8b43dd20..b8de6adb0b 100644 --- a/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx +++ b/packages/cli/src/ui/auth/ApiAuthDialog.test.tsx @@ -103,7 +103,7 @@ describe('ApiAuthDialog', () => { it.each([ { - keyName: 'return', + keyName: 'enter', sequence: '\r', expectedCall: onSubmit, args: ['submitted-key'], 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/ConfigExtensionDialog.tsx b/packages/cli/src/ui/components/ConfigExtensionDialog.tsx index b6fb8ce1b6..7f09d46491 100644 --- a/packages/cli/src/ui/components/ConfigExtensionDialog.tsx +++ b/packages/cli/src/ui/components/ConfigExtensionDialog.tsx @@ -210,7 +210,7 @@ export const ConfigExtensionDialog: React.FC = ({ useKeypress( (key: Key) => { if (state.type === 'ASK_CONFIRMATION') { - if (key.name === 'y' || key.name === 'return') { + if (key.name === 'y' || key.name === 'enter') { state.resolve(true); return true; } @@ -220,7 +220,7 @@ export const ConfigExtensionDialog: React.FC = ({ } } if (state.type === 'DONE' || state.type === 'ERROR') { - if (key.name === 'return' || key.name === 'escape') { + if (key.name === 'enter' || key.name === 'escape') { onClose(); return true; } 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..1cfa2d4215 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'; @@ -972,7 +972,7 @@ export const InputPrompt: React.FC = ({ if (targetIndex < completion.suggestions.length) { const suggestion = completion.suggestions[targetIndex]; - const isEnterKey = key.name === 'return' && !key.ctrl; + const isEnterKey = key.name === 'enter' && !key.ctrl; if (isEnterKey && shellModeActive) { if (hasUserNavigatedSuggestions.current) { 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/SessionBrowser.test.tsx b/packages/cli/src/ui/components/SessionBrowser.test.tsx index 2e68cb6898..e97ae310bd 100644 --- a/packages/cli/src/ui/components/SessionBrowser.test.tsx +++ b/packages/cli/src/ui/components/SessionBrowser.test.tsx @@ -324,7 +324,7 @@ describe('SessionBrowser component', () => { await waitUntilReady(); // Press Enter. - triggerKey({ name: 'return', sequence: '\r' }); + triggerKey({ name: 'enter', sequence: '\r' }); await waitUntilReady(); expect(onResumeSession).toHaveBeenCalledTimes(1); @@ -367,7 +367,7 @@ describe('SessionBrowser component', () => { await waitUntilReady(); // Active selection is at 0 (current session). - triggerKey({ name: 'return', sequence: '\r' }); + triggerKey({ name: 'enter', sequence: '\r' }); await waitUntilReady(); expect(onResumeSession).not.toHaveBeenCalled(); diff --git a/packages/cli/src/ui/components/SessionBrowser.tsx b/packages/cli/src/ui/components/SessionBrowser.tsx index 154ad62522..72eb5ef55c 100644 --- a/packages/cli/src/ui/components/SessionBrowser.tsx +++ b/packages/cli/src/ui/components/SessionBrowser.tsx @@ -873,7 +873,7 @@ export const useSessionBrowserInput = ( // Handling regardless of search mode. if ( - key.name === 'return' && + key.name === 'enter' && state.filteredAndSortedSessions[state.activeIndex] ) { const selectedSession = 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/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/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.test.tsx b/packages/cli/src/ui/components/shared/TextInput.test.tsx index 7e802bbbe3..a5bc79247c 100644 --- a/packages/cli/src/ui/components/shared/TextInput.test.tsx +++ b/packages/cli/src/ui/components/shared/TextInput.test.tsx @@ -287,7 +287,7 @@ describe('TextInput', () => { await act(async () => { keypressHandler({ - name: 'return', + name: 'enter', shift: false, alt: false, ctrl: false, @@ -314,7 +314,7 @@ describe('TextInput', () => { await act(async () => { keypressHandler({ - name: 'return', + name: 'enter', shift: false, alt: false, ctrl: false, @@ -339,7 +339,7 @@ describe('TextInput', () => { await act(async () => { keypressHandler({ - name: 'return', + name: 'enter', shift: false, alt: false, ctrl: false, 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.test.ts b/packages/cli/src/ui/components/shared/text-buffer.test.ts index 51fa728c91..7ea88529ad 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.test.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.test.ts @@ -1533,7 +1533,7 @@ describe('useTextBuffer', () => { const { result } = renderHook(() => useTextBuffer({ viewport })); act(() => { result.current.handleInput({ - name: 'return', + name: 'enter', shift: false, alt: false, ctrl: false, @@ -1789,7 +1789,7 @@ describe('useTextBuffer', () => { const { result } = renderHook(() => useTextBuffer({ viewport })); act(() => { result.current.handleInput({ - name: 'return', + name: 'enter', shift: true, alt: false, ctrl: false, @@ -2290,7 +2290,7 @@ Contrary to popular belief, Lorem Ipsum is not simply random text. It has roots ); act(() => { result.current.handleInput({ - name: 'return', + name: 'enter', shift: false, alt: false, ctrl: false, 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/contexts/KeypressContext.test.tsx b/packages/cli/src/ui/contexts/KeypressContext.test.tsx index bc8e198168..1024488cfb 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.test.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.test.tsx @@ -100,7 +100,7 @@ describe('KeypressContext', () => { expect(keyHandler).toHaveBeenCalledWith( expect.objectContaining({ - name: 'return', + name: 'enter', shift: false, ctrl: false, cmd: false, @@ -115,7 +115,7 @@ describe('KeypressContext', () => { expect(keyHandler).toHaveBeenCalledWith( expect.objectContaining({ - name: 'return', + name: 'enter', shift: true, ctrl: false, cmd: false, @@ -148,7 +148,7 @@ describe('KeypressContext', () => { expect(keyHandler).toHaveBeenCalledWith( expect.objectContaining({ - name: 'return', + name: 'enter', ...expected, }), ); @@ -177,7 +177,7 @@ describe('KeypressContext', () => { expect(keyHandler).toHaveBeenCalledWith( expect.objectContaining({ - name: 'return', + name: 'enter', shift: false, alt: true, ctrl: false, @@ -216,7 +216,7 @@ describe('KeypressContext', () => { expect(keyHandler).toHaveBeenLastCalledWith( expect.objectContaining({ - name: 'return', + name: 'enter', sequence: '\r', insertable: true, shift: true, @@ -238,7 +238,7 @@ describe('KeypressContext', () => { expect(keyHandler).toHaveBeenLastCalledWith( expect.objectContaining({ - name: 'return', + name: 'enter', shift: false, alt: false, ctrl: false, @@ -638,8 +638,8 @@ describe('KeypressContext', () => { describe('Parameterized functional keys', () => { it.each([ // ModifyOtherKeys - { sequence: `\x1b[27;2;13~`, expected: { name: 'return', shift: true } }, - { sequence: `\x1b[27;5;13~`, expected: { name: 'return', ctrl: true } }, + { sequence: `\x1b[27;2;13~`, expected: { name: 'enter', shift: true } }, + { sequence: `\x1b[27;5;13~`, expected: { name: 'enter', ctrl: true } }, { sequence: `\x1b[27;5;9~`, expected: { name: 'tab', ctrl: true } }, { sequence: `\x1b[27;6;9~`, @@ -1124,7 +1124,7 @@ describe('KeypressContext', () => { expect(keyHandler).toHaveBeenNthCalledWith( 1, expect.objectContaining({ - name: 'return', + name: 'enter', }), ); expect(keyHandler).toHaveBeenNthCalledWith( diff --git a/packages/cli/src/ui/contexts/KeypressContext.tsx b/packages/cli/src/ui/contexts/KeypressContext.tsx index d3f9031ffe..7791872865 100644 --- a/packages/cli/src/ui/contexts/KeypressContext.tsx +++ b/packages/cli/src/ui/contexts/KeypressContext.tsx @@ -92,11 +92,11 @@ const KEY_INFO_MAP: Record< '[[5~': { name: 'pageup' }, '[[6~': { name: 'pagedown' }, '[9u': { name: 'tab' }, - '[13u': { name: 'return' }, + '[13u': { name: 'enter' }, '[27u': { name: 'escape' }, '[32u': { name: 'space' }, '[127u': { name: 'backspace' }, - '[57414u': { name: 'return' }, // Numpad Enter + '[57414u': { name: 'enter' }, // Numpad Enter '[a': { name: 'up', shift: true }, '[b': { name: 'down', shift: true }, '[c': { name: 'right', shift: true }, @@ -186,10 +186,10 @@ function bufferFastReturn(keypressHandler: KeypressHandler): KeypressHandler { let lastKeyTime = 0; return (key: Key) => { const now = Date.now(); - if (key.name === 'return' && now - lastKeyTime <= FAST_RETURN_TIMEOUT) { + if (key.name === 'enter' && now - lastKeyTime <= FAST_RETURN_TIMEOUT) { keypressHandler({ ...key, - name: 'return', + name: 'enter', shift: true, // to make it a newline, not a submission alt: false, ctrl: false, @@ -232,7 +232,7 @@ function bufferBackslashEnter( if (nextKey === null) { keypressHandler(key); - } else if (nextKey.name === 'return') { + } else if (nextKey.name === 'enter') { keypressHandler({ ...nextKey, shift: true, @@ -582,11 +582,11 @@ function* emitKeys( } } else if (ch === '\r') { // carriage return - name = 'return'; + name = 'enter'; alt = escaped; } else if (escaped && ch === '\n') { // Alt+Enter (linefeed), should be consistent with carriage return - name = 'return'; + name = 'enter'; alt = escaped; } else if (ch === '\t') { // tab 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/useKeypress.test.tsx b/packages/cli/src/ui/hooks/useKeypress.test.tsx index cde15186d9..0ebfb76f8b 100644 --- a/packages/cli/src/ui/hooks/useKeypress.test.tsx +++ b/packages/cli/src/ui/hooks/useKeypress.test.tsx @@ -111,7 +111,7 @@ describe(`useKeypress`, () => { it('should correctly identify alt+enter (meta key)', () => { renderKeypressHook(true); - const key = { name: 'return', sequence: '\x1B\r' }; + const key = { name: 'enter', sequence: '\x1B\r' }; act(() => stdin.write(key.sequence)); expect(onKeypress).toHaveBeenCalledWith( expect.objectContaining({ diff --git a/packages/cli/src/ui/hooks/useSelectionList.test.tsx b/packages/cli/src/ui/hooks/useSelectionList.test.tsx index 4151375280..6a1b82f77a 100644 --- a/packages/cli/src/ui/hooks/useSelectionList.test.tsx +++ b/packages/cli/src/ui/hooks/useSelectionList.test.tsx @@ -356,7 +356,7 @@ describe('useSelectionList', () => { initialIndex: 2, onSelect: mockOnSelect, }); - pressKey('return'); + pressKey('enter'); await waitUntilReady(); expect(mockOnSelect).toHaveBeenCalledTimes(1); expect(mockOnSelect).toHaveBeenCalledWith('C'); @@ -371,7 +371,7 @@ describe('useSelectionList', () => { act(() => result.current.setActiveIndex(1)); await waitUntilReady(); - pressKey('return'); + pressKey('enter'); await waitUntilReady(); expect(mockOnSelect).not.toHaveBeenCalled(); }); @@ -415,7 +415,7 @@ describe('useSelectionList', () => { await waitUntilReady(); // 3. Press Enter. Should select D. act(() => { - press('return'); + press('enter'); }); await waitUntilReady(); @@ -459,7 +459,7 @@ describe('useSelectionList', () => { // All presses happen in same render cycle - React batches the state updates press('down'); // Should move 0 (A) -> 2 (C) press('down'); // Should move 2 (C) -> 3 (D) - press('return'); // Should select D + press('enter'); // Should select D }); await waitUntilReady(); @@ -759,7 +759,7 @@ describe('useSelectionList', () => { pressNumber('1'); await waitUntilReady(); - pressKey('return'); + pressKey('enter'); await waitUntilReady(); expect(mockOnSelect).toHaveBeenCalledTimes(1); 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..aa1388be9d 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'; @@ -396,7 +396,7 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { // In INSERT mode, let InputPrompt handle completion keys and special commands if ( normalizedKey.name === 'tab' || - (normalizedKey.name === 'return' && !normalizedKey.ctrl) || + (normalizedKey.name === 'enter' && !normalizedKey.ctrl) || normalizedKey.name === 'up' || normalizedKey.name === 'down' || (normalizedKey.ctrl && normalizedKey.name === 'r') @@ -424,7 +424,7 @@ export function useVim(buffer: TextBuffer, onSubmit?: (value: string) => void) { // Special handling for Enter key to allow command submission (lower priority than completion) if ( - normalizedKey.name === 'return' && + normalizedKey.name === 'enter' && !normalizedKey.alt && !normalizedKey.ctrl && !normalizedKey.cmd 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..3057bf85b6 --- /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+enter'); + expect(binding.key).toBe('enter'); + 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..b375d991c8 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,124 @@ export enum Command { /** * Data-driven key binding structure for user configuration */ -export interface KeyBinding { - /** 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; +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', + ]); + + /** The key name (e.g., 'a', 'enter', 'tab', 'escape') */ + 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 +236,140 @@ 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('enter')], + [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('enter')], + [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('enter')], + [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('enter')], [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+enter'), + new KeyBinding('cmd+enter'), + new KeyBinding('alt+enter'), + new KeyBinding('shift+enter'), + 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('enter')], + [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 +431,6 @@ export const commandCategories: readonly CommandCategory[] = [ Command.REVERSE_SEARCH, Command.SUBMIT_REVERSE_SEARCH, Command.ACCEPT_SUGGESTION_REVERSE_SEARCH, - Command.REWIND, ], }, { @@ -428,7 +540,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 92% rename from packages/cli/src/ui/keyMatchers.test.ts rename to packages/cli/src/ui/key/keyMatchers.test.ts index e90f6334be..12e2f07bc2 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 => ({ @@ -31,7 +31,7 @@ describe('keyMatchers', () => { // Basic bindings { command: Command.RETURN, - positive: [createKey('return')], + positive: [createKey('enter')], negative: [createKey('r')], }, { @@ -270,8 +270,8 @@ describe('keyMatchers', () => { // Auto-completion { command: Command.ACCEPT_SUGGESTION, - positive: [createKey('tab'), createKey('return')], - negative: [createKey('return', { ctrl: true }), createKey('space')], + positive: [createKey('tab'), createKey('enter')], + negative: [createKey('enter', { ctrl: true }), createKey('space')], }, { command: Command.COMPLETION_UP, @@ -287,21 +287,21 @@ describe('keyMatchers', () => { // Text input { command: Command.SUBMIT, - positive: [createKey('return')], + positive: [createKey('enter')], negative: [ - createKey('return', { ctrl: true }), - createKey('return', { cmd: true }), - createKey('return', { alt: true }), + createKey('enter', { ctrl: true }), + createKey('enter', { cmd: true }), + createKey('enter', { alt: true }), ], }, { command: Command.NEWLINE, positive: [ - createKey('return', { ctrl: true }), - createKey('return', { cmd: true }), - createKey('return', { alt: true }), + createKey('enter', { ctrl: true }), + createKey('enter', { cmd: true }), + createKey('enter', { alt: true }), ], - negative: [createKey('return'), createKey('n')], + negative: [createKey('enter'), createKey('n')], }, // External tools @@ -382,14 +382,14 @@ describe('keyMatchers', () => { }, { command: Command.SUBMIT_REVERSE_SEARCH, - positive: [createKey('return')], - negative: [createKey('return', { ctrl: true }), createKey('tab')], + positive: [createKey('enter')], + negative: [createKey('enter', { ctrl: true }), createKey('tab')], }, { command: Command.ACCEPT_SUGGESTION_REVERSE_SEARCH, positive: [createKey('tab')], negative: [ - createKey('return'), + createKey('enter'), createKey('space'), createKey('tab', { ctrl: true }), ], @@ -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..6d61c2e114 --- /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~', + enter: '\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..633ebbedb2 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('enter'), 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..f0ec6e37bd 100644 --- a/packages/cli/src/ui/utils/keybindingUtils.ts +++ b/packages/cli/src/ui/key/keybindingUtils.ts @@ -10,13 +10,13 @@ import { type KeyBinding, type KeyBindingConfig, defaultKeyBindings, -} from '../../config/keyBindings.js'; +} from './keyBindings.js'; /** * Maps internal key names to user-friendly display names. */ const KEY_NAME_MAP: Record = { - return: 'Enter', + enter: 'Enter', escape: 'Esc', backspace: 'Backspace', delete: 'Delete', @@ -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/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/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/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 221f3f1813..27f9b0ead0 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -363,6 +363,10 @@ export interface GeminiCLIExtension { */ directory?: string; }; + /** + * Used to migrate an extension to a new repository source. + */ + migratedTo?: string; } export interface ExtensionInstallMetadata { 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); } 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/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); }); }); 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; 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. 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; diff --git a/scripts/tests/generate-keybindings-doc.test.ts b/scripts/tests/generate-keybindings-doc.test.ts index c669fed02e..19ba2e0f98 100644 --- a/scripts/tests/generate-keybindings-doc.test.ts +++ b/scripts/tests/generate-keybindings-doc.test.ts @@ -36,7 +36,7 @@ describe('generate-keybindings-doc', () => { }, { description: 'Submit with Enter if no modifiers are held.', - bindings: [{ key: 'return', shift: false, ctrl: false }], + bindings: [{ key: 'enter', shift: false, ctrl: false }], }, ], },