mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-03 21:51:11 -07:00
Merge branch 'main' into rename-directory-to-workspace
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -71,9 +71,9 @@ Slash commands provide meta-level control over the CLI itself.
|
||||
[Checkpointing documentation](../cli/checkpointing.md).
|
||||
- **Equivalent:** `/resume save <tag>`
|
||||
- **`share [filename]`**
|
||||
- **Description** Writes the current conversation to a provided Markdown or
|
||||
- **Description:** Writes the current conversation to a provided Markdown or
|
||||
JSON file. If no filename is provided, then the CLI will generate one.
|
||||
- **Usage** `/chat share file.md` or `/chat share file.json`.
|
||||
- **Usage:** `/chat share file.md` or `/chat share file.json`.
|
||||
- **Equivalent:** `/resume share [filename]`
|
||||
|
||||
### `/clear`
|
||||
|
||||
@@ -55,14 +55,13 @@ available combinations.
|
||||
|
||||
#### History & Search
|
||||
|
||||
| Action | Keys |
|
||||
| -------------------------------------------- | ------------ |
|
||||
| Show the previous entry in history. | `Ctrl+P` |
|
||||
| Show the next entry in history. | `Ctrl+N` |
|
||||
| Start reverse search through history. | `Ctrl+R` |
|
||||
| Submit the selected reverse-search match. | `Enter` |
|
||||
| Accept a suggestion while reverse searching. | `Tab` |
|
||||
| Browse and rewind previous interactions. | `Double Esc` |
|
||||
| Action | Keys |
|
||||
| -------------------------------------------- | -------- |
|
||||
| Show the previous entry in history. | `Ctrl+P` |
|
||||
| Show the next entry in history. | `Ctrl+N` |
|
||||
| Start reverse search through history. | `Ctrl+R` |
|
||||
| Submit the selected reverse-search match. | `Enter` |
|
||||
| Accept a suggestion while reverse searching. | `Tab` |
|
||||
|
||||
#### Navigation
|
||||
|
||||
|
||||
@@ -345,4 +345,144 @@ describe('ExtensionManager', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Extension Renaming', () => {
|
||||
it('should support renaming an extension during update', async () => {
|
||||
// 1. Setup existing extension
|
||||
const oldName = 'old-name';
|
||||
const newName = 'new-name';
|
||||
const extDir = path.join(userExtensionsDir, oldName);
|
||||
fs.mkdirSync(extDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(extDir, 'gemini-extension.json'),
|
||||
JSON.stringify({ name: oldName, version: '1.0.0' }),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(extDir, 'metadata.json'),
|
||||
JSON.stringify({ type: 'local', source: extDir }),
|
||||
);
|
||||
|
||||
await extensionManager.loadExtensions();
|
||||
|
||||
// 2. Create a temporary "new" version with a different name
|
||||
const newSourceDir = fs.mkdtempSync(
|
||||
path.join(tempHomeDir, 'new-source-'),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(newSourceDir, 'gemini-extension.json'),
|
||||
JSON.stringify({ name: newName, version: '1.1.0' }),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(newSourceDir, 'metadata.json'),
|
||||
JSON.stringify({ type: 'local', source: newSourceDir }),
|
||||
);
|
||||
|
||||
// 3. Update the extension
|
||||
await extensionManager.installOrUpdateExtension(
|
||||
{ type: 'local', source: newSourceDir },
|
||||
{ name: oldName, version: '1.0.0' },
|
||||
);
|
||||
|
||||
// 4. Verify old directory is gone and new one exists
|
||||
expect(fs.existsSync(path.join(userExtensionsDir, oldName))).toBe(false);
|
||||
expect(fs.existsSync(path.join(userExtensionsDir, newName))).toBe(true);
|
||||
|
||||
// Verify the loaded state is updated
|
||||
const extensions = extensionManager.getExtensions();
|
||||
expect(extensions.some((e) => e.name === newName)).toBe(true);
|
||||
expect(extensions.some((e) => e.name === oldName)).toBe(false);
|
||||
});
|
||||
|
||||
it('should carry over enablement status when renaming', async () => {
|
||||
const oldName = 'old-name';
|
||||
const newName = 'new-name';
|
||||
const extDir = path.join(userExtensionsDir, oldName);
|
||||
fs.mkdirSync(extDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(extDir, 'gemini-extension.json'),
|
||||
JSON.stringify({ name: oldName, version: '1.0.0' }),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(extDir, 'metadata.json'),
|
||||
JSON.stringify({ type: 'local', source: extDir }),
|
||||
);
|
||||
|
||||
// Enable it
|
||||
const enablementManager = extensionManager.getEnablementManager();
|
||||
enablementManager.enable(oldName, true, tempHomeDir);
|
||||
|
||||
await extensionManager.loadExtensions();
|
||||
const extension = extensionManager.getExtensions()[0];
|
||||
expect(extension.isActive).toBe(true);
|
||||
|
||||
const newSourceDir = fs.mkdtempSync(
|
||||
path.join(tempHomeDir, 'new-source-'),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(newSourceDir, 'gemini-extension.json'),
|
||||
JSON.stringify({ name: newName, version: '1.1.0' }),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(newSourceDir, 'metadata.json'),
|
||||
JSON.stringify({ type: 'local', source: newSourceDir }),
|
||||
);
|
||||
|
||||
await extensionManager.installOrUpdateExtension(
|
||||
{ type: 'local', source: newSourceDir },
|
||||
{ name: oldName, version: '1.0.0' },
|
||||
);
|
||||
|
||||
// Verify new name is enabled
|
||||
expect(enablementManager.isEnabled(newName, tempHomeDir)).toBe(true);
|
||||
// Verify old name is removed from enablement
|
||||
expect(enablementManager.readConfig()[oldName]).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should prevent renaming if the new name conflicts with an existing extension', async () => {
|
||||
// Setup two extensions
|
||||
const ext1Dir = path.join(userExtensionsDir, 'ext1');
|
||||
fs.mkdirSync(ext1Dir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(ext1Dir, 'gemini-extension.json'),
|
||||
JSON.stringify({ name: 'ext1', version: '1.0.0' }),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(ext1Dir, 'metadata.json'),
|
||||
JSON.stringify({ type: 'local', source: ext1Dir }),
|
||||
);
|
||||
|
||||
const ext2Dir = path.join(userExtensionsDir, 'ext2');
|
||||
fs.mkdirSync(ext2Dir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(ext2Dir, 'gemini-extension.json'),
|
||||
JSON.stringify({ name: 'ext2', version: '1.0.0' }),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(ext2Dir, 'metadata.json'),
|
||||
JSON.stringify({ type: 'local', source: ext2Dir }),
|
||||
);
|
||||
|
||||
await extensionManager.loadExtensions();
|
||||
|
||||
// Try to update ext1 to name 'ext2'
|
||||
const newSourceDir = fs.mkdtempSync(
|
||||
path.join(tempHomeDir, 'new-source-'),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(newSourceDir, 'gemini-extension.json'),
|
||||
JSON.stringify({ name: 'ext2', version: '1.1.0' }),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(newSourceDir, 'metadata.json'),
|
||||
JSON.stringify({ type: 'local', source: newSourceDir }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
extensionManager.installOrUpdateExtension(
|
||||
{ type: 'local', source: newSourceDir },
|
||||
{ name: 'ext1', version: '1.0.0' },
|
||||
),
|
||||
).rejects.toThrow(/already installed/);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -129,6 +129,10 @@ export class ExtensionManager extends ExtensionLoader {
|
||||
this.requestSetting = options.requestSetting ?? undefined;
|
||||
}
|
||||
|
||||
getEnablementManager(): ExtensionEnablementManager {
|
||||
return this.extensionEnablementManager;
|
||||
}
|
||||
|
||||
setRequestConsent(
|
||||
requestConsent: (consent: string) => Promise<boolean>,
|
||||
): void {
|
||||
@@ -271,17 +275,28 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
newExtensionConfig = await this.loadExtensionConfig(localSourcePath);
|
||||
|
||||
const newExtensionName = newExtensionConfig.name;
|
||||
const previousName = previousExtensionConfig?.name ?? newExtensionName;
|
||||
const previous = this.getExtensions().find(
|
||||
(installed) => installed.name === newExtensionName,
|
||||
(installed) => installed.name === previousName,
|
||||
);
|
||||
const nameConflict = this.getExtensions().find(
|
||||
(installed) =>
|
||||
installed.name === newExtensionName &&
|
||||
installed.name !== previousName,
|
||||
);
|
||||
|
||||
if (isUpdate && !previous) {
|
||||
throw new Error(
|
||||
`Extension "${newExtensionName}" was not already installed, cannot update it.`,
|
||||
`Extension "${previousName}" was not already installed, cannot update it.`,
|
||||
);
|
||||
} else if (!isUpdate && previous) {
|
||||
throw new Error(
|
||||
`Extension "${newExtensionName}" is already installed. Please uninstall it first.`,
|
||||
);
|
||||
} else if (isUpdate && nameConflict) {
|
||||
throw new Error(
|
||||
`Cannot update to "${newExtensionName}" because an extension with that name is already installed.`,
|
||||
);
|
||||
}
|
||||
|
||||
const newHasHooks = fs.existsSync(
|
||||
@@ -298,6 +313,11 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
path.join(localSourcePath, 'skills'),
|
||||
);
|
||||
const previousSkills = previous?.skills ?? [];
|
||||
const isMigrating = Boolean(
|
||||
previous &&
|
||||
previous.installMetadata &&
|
||||
previous.installMetadata.source !== installMetadata.source,
|
||||
);
|
||||
|
||||
await maybeRequestConsentOrFail(
|
||||
newExtensionConfig,
|
||||
@@ -307,19 +327,46 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
previousHasHooks,
|
||||
newSkills,
|
||||
previousSkills,
|
||||
isMigrating,
|
||||
);
|
||||
const extensionId = getExtensionId(newExtensionConfig, installMetadata);
|
||||
const destinationPath = new ExtensionStorage(
|
||||
newExtensionName,
|
||||
).getExtensionDir();
|
||||
|
||||
if (
|
||||
(!isUpdate || newExtensionName !== previousName) &&
|
||||
fs.existsSync(destinationPath)
|
||||
) {
|
||||
throw new Error(
|
||||
`Cannot install extension "${newExtensionName}" because a directory with that name already exists. Please remove it manually.`,
|
||||
);
|
||||
}
|
||||
|
||||
let previousSettings: Record<string, string> | undefined;
|
||||
if (isUpdate) {
|
||||
let wasEnabledGlobally = false;
|
||||
let wasEnabledWorkspace = false;
|
||||
if (isUpdate && previousExtensionConfig) {
|
||||
const previousExtensionId = previous?.installMetadata
|
||||
? getExtensionId(previousExtensionConfig, previous.installMetadata)
|
||||
: extensionId;
|
||||
previousSettings = await getEnvContents(
|
||||
previousExtensionConfig,
|
||||
extensionId,
|
||||
previousExtensionId,
|
||||
this.workspaceDir,
|
||||
);
|
||||
await this.uninstallExtension(newExtensionName, isUpdate);
|
||||
if (newExtensionName !== previousName) {
|
||||
wasEnabledGlobally = this.extensionEnablementManager.isEnabled(
|
||||
previousName,
|
||||
homedir(),
|
||||
);
|
||||
wasEnabledWorkspace = this.extensionEnablementManager.isEnabled(
|
||||
previousName,
|
||||
this.workspaceDir,
|
||||
);
|
||||
this.extensionEnablementManager.remove(previousName);
|
||||
}
|
||||
await this.uninstallExtension(previousName, isUpdate);
|
||||
}
|
||||
|
||||
await fs.promises.mkdir(destinationPath, { recursive: true });
|
||||
@@ -392,6 +439,18 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
CoreToolCallStatus.Success,
|
||||
),
|
||||
);
|
||||
|
||||
if (newExtensionName !== previousName) {
|
||||
if (wasEnabledGlobally) {
|
||||
await this.enableExtension(newExtensionName, SettingScope.User);
|
||||
}
|
||||
if (wasEnabledWorkspace) {
|
||||
await this.enableExtension(
|
||||
newExtensionName,
|
||||
SettingScope.Workspace,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await logExtensionInstallEvent(
|
||||
this.telemetryConfig,
|
||||
@@ -873,6 +932,7 @@ Would you like to attempt to install via "git clone" instead?`,
|
||||
path: effectiveExtensionPath,
|
||||
contextFiles,
|
||||
installMetadata,
|
||||
migratedTo: config.migratedTo,
|
||||
mcpServers: config.mcpServers,
|
||||
excludeTools: config.excludeTools,
|
||||
hooks,
|
||||
|
||||
@@ -42,6 +42,10 @@ export interface ExtensionConfig {
|
||||
*/
|
||||
directory?: string;
|
||||
};
|
||||
/**
|
||||
* Used to migrate an extension to a new repository source.
|
||||
*/
|
||||
migratedTo?: string;
|
||||
}
|
||||
|
||||
export interface ExtensionUpdateInfo {
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="920" height="122" viewBox="0 0 920 122">
|
||||
<style>
|
||||
text { font-family: Consolas, "Courier New", monospace; font-size: 14px; dominant-baseline: text-before-edge; white-space: pre; }
|
||||
</style>
|
||||
<rect width="920" height="122" fill="#000000" />
|
||||
<g transform="translate(10, 10)">
|
||||
<text x="0" y="2" fill="#ffffff" textLength="900" lengthAdjust="spacingAndGlyphs">Migrating extension "old-ext" to a new repository, renaming to "test-ext", and installing updates. </text>
|
||||
<text x="0" y="36" fill="#cdcd00" textLength="891" lengthAdjust="spacingAndGlyphs">The extension you are about to install may have been created by a third-party developer and sourced</text>
|
||||
<text x="0" y="53" fill="#cdcd00" textLength="882" lengthAdjust="spacingAndGlyphs">from a public repository. Google does not vet, endorse, or guarantee the functionality or security</text>
|
||||
<text x="0" y="70" fill="#cdcd00" textLength="846" lengthAdjust="spacingAndGlyphs">of extensions. Please carefully inspect any extension and its source code before installing to</text>
|
||||
<text x="0" y="87" fill="#cdcd00" textLength="630" lengthAdjust="spacingAndGlyphs">understand the permissions it requires and the actions it may perform.</text>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -24,6 +24,15 @@ of extensions. Please carefully inspect any extension and its source code before
|
||||
understand the permissions it requires and the actions it may perform."
|
||||
`;
|
||||
|
||||
exports[`consent > maybeRequestConsentOrFail > consent string generation > should request consent if extension is migrated 1`] = `
|
||||
"Migrating extension "old-ext" to a new repository, renaming to "test-ext", and installing updates.
|
||||
|
||||
The extension you are about to install may have been created by a third-party developer and sourced
|
||||
from a public repository. Google does not vet, endorse, or guarantee the functionality or security
|
||||
of extensions. Please carefully inspect any extension and its source code before installing to
|
||||
understand the permissions it requires and the actions it may perform."
|
||||
`;
|
||||
|
||||
exports[`consent > maybeRequestConsentOrFail > consent string generation > should request consent if skills change 1`] = `
|
||||
"Installing extension "test-ext".
|
||||
This extension will run the following MCP servers:
|
||||
|
||||
@@ -288,6 +288,25 @@ describe('consent', () => {
|
||||
expect(requestConsent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should request consent if extension is migrated', async () => {
|
||||
const requestConsent = vi.fn().mockResolvedValue(true);
|
||||
await maybeRequestConsentOrFail(
|
||||
baseConfig,
|
||||
requestConsent,
|
||||
false,
|
||||
{ ...baseConfig, name: 'old-ext' },
|
||||
false,
|
||||
[],
|
||||
[],
|
||||
true,
|
||||
);
|
||||
|
||||
expect(requestConsent).toHaveBeenCalledTimes(1);
|
||||
let consentString = requestConsent.mock.calls[0][0] as string;
|
||||
consentString = normalizePathsForSnapshot(consentString, tempDir);
|
||||
await expectConsentSnapshot(consentString);
|
||||
});
|
||||
|
||||
it('should request consent if skills change', async () => {
|
||||
const skill1Dir = path.join(tempDir, 'skill1');
|
||||
const skill2Dir = path.join(tempDir, 'skill2');
|
||||
|
||||
@@ -148,11 +148,30 @@ async function extensionConsentString(
|
||||
extensionConfig: ExtensionConfig,
|
||||
hasHooks: boolean,
|
||||
skills: SkillDefinition[] = [],
|
||||
previousName?: string,
|
||||
wasMigrated?: boolean,
|
||||
): Promise<string> {
|
||||
const sanitizedConfig = escapeAnsiCtrlCodes(extensionConfig);
|
||||
const output: string[] = [];
|
||||
const mcpServerEntries = Object.entries(sanitizedConfig.mcpServers || {});
|
||||
output.push(`Installing extension "${sanitizedConfig.name}".`);
|
||||
|
||||
if (wasMigrated) {
|
||||
if (previousName && previousName !== sanitizedConfig.name) {
|
||||
output.push(
|
||||
`Migrating extension "${previousName}" to a new repository, renaming to "${sanitizedConfig.name}", and installing updates.`,
|
||||
);
|
||||
} else {
|
||||
output.push(
|
||||
`Migrating extension "${sanitizedConfig.name}" to a new repository and installing updates.`,
|
||||
);
|
||||
}
|
||||
} else if (previousName && previousName !== sanitizedConfig.name) {
|
||||
output.push(
|
||||
`Renaming extension "${previousName}" to "${sanitizedConfig.name}" and installing updates.`,
|
||||
);
|
||||
} else {
|
||||
output.push(`Installing extension "${sanitizedConfig.name}".`);
|
||||
}
|
||||
|
||||
if (mcpServerEntries.length) {
|
||||
output.push('This extension will run the following MCP servers:');
|
||||
@@ -231,11 +250,14 @@ export async function maybeRequestConsentOrFail(
|
||||
previousHasHooks?: boolean,
|
||||
skills: SkillDefinition[] = [],
|
||||
previousSkills: SkillDefinition[] = [],
|
||||
isMigrating: boolean = false,
|
||||
) {
|
||||
const extensionConsent = await extensionConsentString(
|
||||
extensionConfig,
|
||||
hasHooks,
|
||||
skills,
|
||||
previousExtensionConfig?.name,
|
||||
isMigrating,
|
||||
);
|
||||
if (previousExtensionConfig) {
|
||||
const previousExtensionConsent = await extensionConsentString(
|
||||
|
||||
@@ -285,6 +285,23 @@ describe('github.ts', () => {
|
||||
ExtensionUpdateState.NOT_UPDATABLE,
|
||||
);
|
||||
});
|
||||
|
||||
it('should check migratedTo source if present and return UPDATE_AVAILABLE', async () => {
|
||||
mockGit.getRemotes.mockResolvedValue([
|
||||
{ name: 'origin', refs: { fetch: 'new-url' } },
|
||||
]);
|
||||
mockGit.listRemote.mockResolvedValue('hash\tHEAD');
|
||||
mockGit.revparse.mockResolvedValue('hash');
|
||||
|
||||
const ext = {
|
||||
path: '/path',
|
||||
migratedTo: 'new-url',
|
||||
installMetadata: { type: 'git', source: 'old-url' },
|
||||
} as unknown as GeminiCLIExtension;
|
||||
expect(await checkForExtensionUpdate(ext, mockExtensionManager)).toBe(
|
||||
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('downloadFromGitHubRelease', () => {
|
||||
|
||||
@@ -203,6 +203,24 @@ export async function checkForExtensionUpdate(
|
||||
) {
|
||||
return ExtensionUpdateState.NOT_UPDATABLE;
|
||||
}
|
||||
|
||||
if (extension.migratedTo) {
|
||||
const migratedState = await checkForExtensionUpdate(
|
||||
{
|
||||
...extension,
|
||||
installMetadata: { ...installMetadata, source: extension.migratedTo },
|
||||
migratedTo: undefined,
|
||||
},
|
||||
extensionManager,
|
||||
);
|
||||
if (
|
||||
migratedState === ExtensionUpdateState.UPDATE_AVAILABLE ||
|
||||
migratedState === ExtensionUpdateState.UP_TO_DATE
|
||||
) {
|
||||
return ExtensionUpdateState.UPDATE_AVAILABLE;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (installMetadata.type === 'git') {
|
||||
const git = simpleGit(extension.path);
|
||||
|
||||
@@ -184,6 +184,54 @@ describe('Extension Update Logic', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should migrate source if migratedTo is set and an update is available', async () => {
|
||||
vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue(
|
||||
Promise.resolve({
|
||||
name: 'test-extension',
|
||||
version: '1.0.0',
|
||||
}),
|
||||
);
|
||||
vi.mocked(
|
||||
mockExtensionManager.installOrUpdateExtension,
|
||||
).mockResolvedValue({
|
||||
...mockExtension,
|
||||
version: '1.1.0',
|
||||
});
|
||||
vi.mocked(checkForExtensionUpdate).mockResolvedValue(
|
||||
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
);
|
||||
|
||||
const extensionWithMigratedTo = {
|
||||
...mockExtension,
|
||||
migratedTo: 'https://new-source.com/repo.git',
|
||||
};
|
||||
|
||||
await updateExtension(
|
||||
extensionWithMigratedTo,
|
||||
mockExtensionManager,
|
||||
ExtensionUpdateState.UPDATE_AVAILABLE,
|
||||
mockDispatch,
|
||||
);
|
||||
|
||||
expect(checkForExtensionUpdate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
installMetadata: expect.objectContaining({
|
||||
source: 'https://new-source.com/repo.git',
|
||||
}),
|
||||
}),
|
||||
mockExtensionManager,
|
||||
);
|
||||
|
||||
expect(
|
||||
mockExtensionManager.installOrUpdateExtension,
|
||||
).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
source: 'https://new-source.com/repo.git',
|
||||
}),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('should set state to UPDATED if enableExtensionReloading is true', async () => {
|
||||
vi.mocked(mockExtensionManager.loadExtensionConfig).mockReturnValue(
|
||||
Promise.resolve({
|
||||
|
||||
@@ -55,6 +55,24 @@ export async function updateExtension(
|
||||
});
|
||||
throw new Error(`Extension is linked so does not need to be updated`);
|
||||
}
|
||||
|
||||
if (extension.migratedTo) {
|
||||
const migratedState = await checkForExtensionUpdate(
|
||||
{
|
||||
...extension,
|
||||
installMetadata: { ...installMetadata, source: extension.migratedTo },
|
||||
migratedTo: undefined,
|
||||
},
|
||||
extensionManager,
|
||||
);
|
||||
if (
|
||||
migratedState === ExtensionUpdateState.UPDATE_AVAILABLE ||
|
||||
migratedState === ExtensionUpdateState.UP_TO_DATE
|
||||
) {
|
||||
installMetadata.source = extension.migratedTo;
|
||||
}
|
||||
}
|
||||
|
||||
const originalVersion = extension.version;
|
||||
|
||||
const tempDir = await ExtensionStorage.createTmpDir();
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { KeyBindingConfig } from './keyBindings.js';
|
||||
import {
|
||||
Command,
|
||||
commandCategories,
|
||||
commandDescriptions,
|
||||
defaultKeyBindings,
|
||||
} from './keyBindings.js';
|
||||
|
||||
describe('keyBindings config', () => {
|
||||
describe('defaultKeyBindings', () => {
|
||||
it('should have bindings for all commands', () => {
|
||||
const commands = Object.values(Command);
|
||||
|
||||
for (const command of commands) {
|
||||
expect(defaultKeyBindings[command]).toBeDefined();
|
||||
expect(Array.isArray(defaultKeyBindings[command])).toBe(true);
|
||||
expect(defaultKeyBindings[command]?.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should have valid key binding structures', () => {
|
||||
for (const [_, bindings] of Object.entries(defaultKeyBindings)) {
|
||||
for (const binding of bindings) {
|
||||
// Each binding must have a key name
|
||||
expect(typeof binding.key).toBe('string');
|
||||
expect(binding.key.length).toBeGreaterThan(0);
|
||||
|
||||
// Modifier properties should be boolean or undefined
|
||||
if (binding.shift !== undefined) {
|
||||
expect(typeof binding.shift).toBe('boolean');
|
||||
}
|
||||
if (binding.alt !== undefined) {
|
||||
expect(typeof binding.alt).toBe('boolean');
|
||||
}
|
||||
if (binding.ctrl !== undefined) {
|
||||
expect(typeof binding.ctrl).toBe('boolean');
|
||||
}
|
||||
if (binding.cmd !== undefined) {
|
||||
expect(typeof binding.cmd).toBe('boolean');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should export all required types', () => {
|
||||
// Basic type checks
|
||||
expect(typeof Command.HOME).toBe('string');
|
||||
expect(typeof Command.END).toBe('string');
|
||||
|
||||
// Config should be readonly
|
||||
const config: KeyBindingConfig = defaultKeyBindings;
|
||||
expect(config[Command.HOME]).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('command metadata', () => {
|
||||
const commandValues = Object.values(Command);
|
||||
|
||||
it('has a description entry for every command', () => {
|
||||
const describedCommands = Object.keys(commandDescriptions);
|
||||
expect(describedCommands.sort()).toEqual([...commandValues].sort());
|
||||
|
||||
for (const command of commandValues) {
|
||||
expect(typeof commandDescriptions[command]).toBe('string');
|
||||
expect(commandDescriptions[command]?.trim()).not.toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('categorizes each command exactly once', () => {
|
||||
const seen = new Set<Command>();
|
||||
|
||||
for (const category of commandCategories) {
|
||||
expect(typeof category.title).toBe('string');
|
||||
expect(Array.isArray(category.commands)).toBe(true);
|
||||
|
||||
for (const command of category.commands) {
|
||||
expect(commandValues).toContain(command);
|
||||
expect(seen.has(command)).toBe(false);
|
||||
seen.add(command);
|
||||
}
|
||||
}
|
||||
|
||||
expect(seen.size).toBe(commandValues.length);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -13,7 +13,7 @@ import { useTextBuffer } from '../components/shared/text-buffer.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { clearApiKey, debugLogger } from '@google/gemini-cli-core';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { Command } from '../keyMatchers.js';
|
||||
import { Command } from '../key/keyMatchers.js';
|
||||
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
|
||||
|
||||
interface ApiAuthDialogProps {
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { useUIActions } from '../contexts/UIActionsContext.js';
|
||||
import { Command } from '../keyMatchers.js';
|
||||
import { Command } from '../key/keyMatchers.js';
|
||||
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
|
||||
|
||||
export const AdminSettingsChangedDialog = () => {
|
||||
|
||||
@@ -8,8 +8,8 @@ import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { ApprovalMode } from '@google/gemini-cli-core';
|
||||
import { formatCommand } from '../utils/keybindingUtils.js';
|
||||
import { Command } from '../../config/keyBindings.js';
|
||||
import { formatCommand } from '../key/keybindingUtils.js';
|
||||
import { Command } from '../key/keyBindings.js';
|
||||
|
||||
interface ApprovalModeIndicatorProps {
|
||||
approvalMode: ApprovalMode;
|
||||
|
||||
@@ -20,10 +20,10 @@ import { BaseSelectionList } from './shared/BaseSelectionList.js';
|
||||
import type { SelectionListItem } from '../hooks/useSelectionList.js';
|
||||
import { TabHeader, type Tab } from './shared/TabHeader.js';
|
||||
import { useKeypress, type Key } from '../hooks/useKeypress.js';
|
||||
import { Command } from '../keyMatchers.js';
|
||||
import { Command } from '../key/keyMatchers.js';
|
||||
import { checkExhaustive } from '@google/gemini-cli-core';
|
||||
import { TextInput } from './shared/TextInput.js';
|
||||
import { formatCommand } from '../utils/keybindingUtils.js';
|
||||
import { formatCommand } from '../key/keybindingUtils.js';
|
||||
import {
|
||||
useTextBuffer,
|
||||
expandPastePlaceholders,
|
||||
|
||||
@@ -16,9 +16,9 @@ import {
|
||||
} from '@google/gemini-cli-core';
|
||||
import { cpLen, cpSlice, getCachedStringWidth } from '../utils/textUtils.js';
|
||||
import { type BackgroundShell } from '../hooks/shellCommandProcessor.js';
|
||||
import { Command } from '../keyMatchers.js';
|
||||
import { Command } from '../key/keyMatchers.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { formatCommand } from '../utils/keybindingUtils.js';
|
||||
import { formatCommand } from '../key/keybindingUtils.js';
|
||||
import {
|
||||
ScrollableList,
|
||||
type ScrollableListRef,
|
||||
|
||||
@@ -10,7 +10,7 @@ import { renderWithProviders } from '../../test-utils/render.js';
|
||||
import { waitFor } from '../../test-utils/async.js';
|
||||
import { ExitPlanModeDialog } from './ExitPlanModeDialog.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { Command } from '../keyMatchers.js';
|
||||
import { Command } from '../key/keyMatchers.js';
|
||||
import {
|
||||
ApprovalMode,
|
||||
validatePlanContent,
|
||||
|
||||
@@ -22,8 +22,8 @@ import { useConfig } from '../contexts/ConfigContext.js';
|
||||
import { AskUserDialog } from './AskUserDialog.js';
|
||||
import { openFileInEditor } from '../utils/editorUtils.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { Command } from '../keyMatchers.js';
|
||||
import { formatCommand } from '../utils/keybindingUtils.js';
|
||||
import { Command } from '../key/keyMatchers.js';
|
||||
import { formatCommand } from '../key/keybindingUtils.js';
|
||||
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
|
||||
|
||||
export interface ExitPlanModeDialogProps {
|
||||
|
||||
@@ -11,7 +11,7 @@ import { theme } from '../semantic-colors.js';
|
||||
import { useSettingsStore } from '../contexts/SettingsContext.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useKeypress, type Key } from '../hooks/useKeypress.js';
|
||||
import { Command } from '../keyMatchers.js';
|
||||
import { Command } from '../key/keyMatchers.js';
|
||||
import { FooterRow, type FooterRowItem } from './Footer.js';
|
||||
import { ALL_ITEMS, resolveFooterState } from '../../config/footerItems.js';
|
||||
import { SettingScope } from '../../config/settings.js';
|
||||
|
||||
@@ -10,8 +10,8 @@ import { theme } from '../semantic-colors.js';
|
||||
import { type SlashCommand, CommandKind } from '../commands/types.js';
|
||||
import { KEYBOARD_SHORTCUTS_URL } from '../constants.js';
|
||||
import { sanitizeForDisplay } from '../utils/textUtils.js';
|
||||
import { formatCommand } from '../utils/keybindingUtils.js';
|
||||
import { Command } from '../../config/keyBindings.js';
|
||||
import { formatCommand } from '../key/keybindingUtils.js';
|
||||
import { Command } from '../key/keyBindings.js';
|
||||
|
||||
interface Help {
|
||||
commands: readonly SlashCommand[];
|
||||
|
||||
@@ -9,7 +9,7 @@ import { useState, useMemo } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { Command } from '../keyMatchers.js';
|
||||
import { Command } from '../key/keyMatchers.js';
|
||||
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
|
||||
|
||||
/**
|
||||
|
||||
@@ -44,7 +44,7 @@ import { terminalCapabilityManager } from '../utils/terminalCapabilityManager.js
|
||||
import type { UIState } from '../contexts/UIStateContext.js';
|
||||
import { isLowColorDepth } from '../utils/terminalUtils.js';
|
||||
import { cpLen } from '../utils/textUtils.js';
|
||||
import { defaultKeyMatchers, Command } from '../keyMatchers.js';
|
||||
import { defaultKeyMatchers, Command } from '../key/keyMatchers.js';
|
||||
import type { Key } from '../hooks/useKeypress.js';
|
||||
import {
|
||||
appEvents,
|
||||
|
||||
@@ -36,8 +36,8 @@ import {
|
||||
} from '../hooks/useCommandCompletion.js';
|
||||
import type { Key } from '../hooks/useKeypress.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { Command } from '../keyMatchers.js';
|
||||
import { formatCommand } from '../utils/keybindingUtils.js';
|
||||
import { Command } from '../key/keyMatchers.js';
|
||||
import { formatCommand } from '../key/keybindingUtils.js';
|
||||
import type { CommandContext, SlashCommand } from '../commands/types.js';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import { ApprovalMode, coreEvents, debugLogger } from '@google/gemini-cli-core';
|
||||
|
||||
@@ -16,7 +16,7 @@ import { theme } from '../semantic-colors.js';
|
||||
import type { RadioSelectItem } from './shared/RadioButtonSelect.js';
|
||||
import { RadioButtonSelect } from './shared/RadioButtonSelect.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { Command } from '../keyMatchers.js';
|
||||
import { Command } from '../key/keyMatchers.js';
|
||||
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
|
||||
|
||||
export enum PolicyUpdateChoice {
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
import type React from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { formatCommand } from '../utils/keybindingUtils.js';
|
||||
import { Command } from '../../config/keyBindings.js';
|
||||
import { formatCommand } from '../key/keybindingUtils.js';
|
||||
import { Command } from '../key/keyBindings.js';
|
||||
|
||||
export const RawMarkdownIndicator: React.FC = () => {
|
||||
const modKey = formatCommand(Command.TOGGLE_MARKDOWN);
|
||||
|
||||
@@ -13,7 +13,7 @@ import type { RadioSelectItem } from './shared/RadioButtonSelect.js';
|
||||
import type { FileChangeStats } from '../utils/rewindFileOps.js';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { formatTimeAgo } from '../utils/formatters.js';
|
||||
import { Command } from '../keyMatchers.js';
|
||||
import { Command } from '../key/keyMatchers.js';
|
||||
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
|
||||
|
||||
export enum RewindOutcome {
|
||||
|
||||
@@ -19,7 +19,7 @@ import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { useRewind } from '../hooks/useRewind.js';
|
||||
import { RewindConfirmation, RewindOutcome } from './RewindConfirmation.js';
|
||||
import { stripReferenceContent } from '../utils/formatters.js';
|
||||
import { Command } from '../keyMatchers.js';
|
||||
import { Command } from '../key/keyMatchers.js';
|
||||
import { CliSpinner } from './CliSpinner.js';
|
||||
import { ExpandableText } from './shared/ExpandableText.js';
|
||||
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
|
||||
|
||||
@@ -8,9 +8,9 @@ import { useCallback } from 'react';
|
||||
import type React from 'react';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { ShellExecutionService } from '@google/gemini-cli-core';
|
||||
import { keyToAnsi, type Key } from '../hooks/keyToAnsi.js';
|
||||
import { keyToAnsi, type Key } from '../key/keyToAnsi.js';
|
||||
import { ACTIVE_SHELL_MAX_LINES } from '../constants.js';
|
||||
import { Command } from '../keyMatchers.js';
|
||||
import { Command } from '../key/keyMatchers.js';
|
||||
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
|
||||
|
||||
export interface ShellInputPromptProps {
|
||||
|
||||
@@ -10,8 +10,8 @@ import { theme } from '../semantic-colors.js';
|
||||
import { isNarrowWidth } from '../utils/isNarrowWidth.js';
|
||||
import { SectionHeader } from './shared/SectionHeader.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { Command } from '../../config/keyBindings.js';
|
||||
import { formatCommand } from '../utils/keybindingUtils.js';
|
||||
import { Command } from '../key/keyBindings.js';
|
||||
import { formatCommand } from '../key/keybindingUtils.js';
|
||||
|
||||
type ShortcutItem = {
|
||||
key: string;
|
||||
@@ -21,7 +21,7 @@ type ShortcutItem = {
|
||||
const buildShortcutItems = (): ShortcutItem[] => [
|
||||
{ key: '!', description: 'shell mode' },
|
||||
{ key: '@', description: 'select file or folder' },
|
||||
{ key: formatCommand(Command.REWIND), description: 'clear & rewind' },
|
||||
{ key: 'Double Esc', description: 'clear & rewind' },
|
||||
{ key: formatCommand(Command.FOCUS_SHELL_INPUT), description: 'focus UI' },
|
||||
{ key: formatCommand(Command.TOGGLE_YOLO), description: 'YOLO mode' },
|
||||
{
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
type ValidationIntent,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useKeypress } from '../hooks/useKeypress.js';
|
||||
import { Command } from '../keyMatchers.js';
|
||||
import { Command } from '../key/keyMatchers.js';
|
||||
import { useKeyMatchers } from '../hooks/useKeyMatchers.js';
|
||||
|
||||
interface ValidationDialogProps {
|
||||
|
||||
@@ -22,7 +22,7 @@ interface DiffLine {
|
||||
}
|
||||
|
||||
function parseDiffWithLineNumbers(diffContent: string): DiffLine[] {
|
||||
const lines = diffContent.split('\n');
|
||||
const lines = diffContent.split(/\r?\n/);
|
||||
const result: DiffLine[] = [];
|
||||
let currentOldLine = 0;
|
||||
let currentNewLine = 0;
|
||||
|
||||
@@ -11,8 +11,8 @@ import { useMemo } from 'react';
|
||||
import type { HistoryItemToolGroup } from '../../types.js';
|
||||
import { Checklist } from '../Checklist.js';
|
||||
import type { ChecklistItemData } from '../ChecklistItem.js';
|
||||
import { formatCommand } from '../../utils/keybindingUtils.js';
|
||||
import { Command } from '../../../config/keyBindings.js';
|
||||
import { formatCommand } from '../../key/keybindingUtils.js';
|
||||
import { Command } from '../../key/keyBindings.js';
|
||||
|
||||
export const TodoTray: React.FC = () => {
|
||||
const uiState = useUIState();
|
||||
|
||||
@@ -29,8 +29,8 @@ import {
|
||||
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useSettings } from '../../contexts/SettingsContext.js';
|
||||
import { Command } from '../../keyMatchers.js';
|
||||
import { formatCommand } from '../../utils/keybindingUtils.js';
|
||||
import { Command } from '../../key/keyMatchers.js';
|
||||
import { formatCommand } from '../../key/keybindingUtils.js';
|
||||
import { AskUserDialog } from '../AskUserDialog.js';
|
||||
import { ExitPlanModeDialog } from '../ExitPlanModeDialog.js';
|
||||
import { WarningMessage } from './WarningMessage.js';
|
||||
|
||||
@@ -23,8 +23,8 @@ import {
|
||||
CoreToolCallStatus,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useInactivityTimer } from '../../hooks/useInactivityTimer.js';
|
||||
import { formatCommand } from '../../utils/keybindingUtils.js';
|
||||
import { Command } from '../../../config/keyBindings.js';
|
||||
import { formatCommand } from '../../key/keybindingUtils.js';
|
||||
import { Command } from '../../key/keyBindings.js';
|
||||
|
||||
export const STATUS_INDICATOR_WIDTH = 3;
|
||||
|
||||
|
||||
@@ -19,10 +19,10 @@ import { TextInput } from './TextInput.js';
|
||||
import type { TextBuffer } from './text-buffer.js';
|
||||
import { cpSlice, cpLen, cpIndexToOffset } from '../../utils/textUtils.js';
|
||||
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
|
||||
import { Command } from '../../keyMatchers.js';
|
||||
import { Command } from '../../key/keyMatchers.js';
|
||||
import { useSettingsNavigation } from '../../hooks/useSettingsNavigation.js';
|
||||
import { useInlineEditBuffer } from '../../hooks/useInlineEditBuffer.js';
|
||||
import { formatCommand } from '../../utils/keybindingUtils.js';
|
||||
import { formatCommand } from '../../key/keybindingUtils.js';
|
||||
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
|
||||
|
||||
/**
|
||||
|
||||
@@ -10,8 +10,8 @@ import { Box, Text, ResizeObserver, type DOMElement } from 'ink';
|
||||
import { theme } from '../../semantic-colors.js';
|
||||
import { useOverflowActions } from '../../contexts/OverflowContext.js';
|
||||
import { isNarrowWidth } from '../../utils/isNarrowWidth.js';
|
||||
import { Command } from '../../../config/keyBindings.js';
|
||||
import { formatCommand } from '../../utils/keybindingUtils.js';
|
||||
import { Command } from '../../key/keyBindings.js';
|
||||
import { formatCommand } from '../../key/keybindingUtils.js';
|
||||
|
||||
/**
|
||||
* Minimum height for the MaxSizedBox component.
|
||||
|
||||
@@ -19,7 +19,7 @@ import { useKeypress, type Key } from '../../hooks/useKeypress.js';
|
||||
import { useScrollable } from '../../contexts/ScrollProvider.js';
|
||||
import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js';
|
||||
import { useBatchedScroll } from '../../hooks/useBatchedScroll.js';
|
||||
import { Command } from '../../keyMatchers.js';
|
||||
import { Command } from '../../key/keyMatchers.js';
|
||||
import { useOverflowActions } from '../../contexts/OverflowContext.js';
|
||||
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import { useScrollable } from '../../contexts/ScrollProvider.js';
|
||||
import { Box, type DOMElement } from 'ink';
|
||||
import { useAnimatedScrollbar } from '../../hooks/useAnimatedScrollbar.js';
|
||||
import { useKeypress, type Key } from '../../hooks/useKeypress.js';
|
||||
import { Command } from '../../keyMatchers.js';
|
||||
import { Command } from '../../key/keyMatchers.js';
|
||||
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
|
||||
|
||||
const ANIMATION_FRAME_DURATION_MS = 33;
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useSelectionList } from '../../hooks/useSelectionList.js';
|
||||
import { TextInput } from './TextInput.js';
|
||||
import type { TextBuffer } from './text-buffer.js';
|
||||
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||
import { Command } from '../../keyMatchers.js';
|
||||
import { Command } from '../../key/keyMatchers.js';
|
||||
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
|
||||
|
||||
/**
|
||||
|
||||
@@ -14,7 +14,7 @@ import { theme } from '../../semantic-colors.js';
|
||||
import type { TextBuffer } from './text-buffer.js';
|
||||
import { expandPastePlaceholders } from './text-buffer.js';
|
||||
import { cpSlice, cpIndexToOffset } from '../../utils/textUtils.js';
|
||||
import { Command } from '../../keyMatchers.js';
|
||||
import { Command } from '../../key/keyMatchers.js';
|
||||
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
|
||||
|
||||
export interface TextInputProps {
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
} from '../../utils/textUtils.js';
|
||||
import { parsePastedPaths } from '../../utils/clipboardUtils.js';
|
||||
import type { Key } from '../../contexts/KeypressContext.js';
|
||||
import { Command } from '../../keyMatchers.js';
|
||||
import { Command } from '../../key/keyMatchers.js';
|
||||
import type { VimAction } from './vim-buffer-actions.js';
|
||||
import { handleVimAction } from './vim-buffer-actions.js';
|
||||
import { LRU_BUFFER_PERF_CACHE_LIMIT } from '../../constants.js';
|
||||
|
||||
@@ -10,7 +10,7 @@ import Spinner from 'ink-spinner';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import { debugLogger, spawnAsync, LlmRole } from '@google/gemini-cli-core';
|
||||
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||
import { Command } from '../../keyMatchers.js';
|
||||
import { Command } from '../../key/keyMatchers.js';
|
||||
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
|
||||
|
||||
interface Issue {
|
||||
|
||||
@@ -10,7 +10,7 @@ import Spinner from 'ink-spinner';
|
||||
import type { Config } from '@google/gemini-cli-core';
|
||||
import { debugLogger, spawnAsync, LlmRole } from '@google/gemini-cli-core';
|
||||
import { useKeypress } from '../../hooks/useKeypress.js';
|
||||
import { Command } from '../../keyMatchers.js';
|
||||
import { Command } from '../../key/keyMatchers.js';
|
||||
import { TextInput } from '../shared/TextInput.js';
|
||||
import { useTextBuffer } from '../shared/text-buffer.js';
|
||||
import { useKeyMatchers } from '../../hooks/useKeyMatchers.js';
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Key } from '../contexts/KeypressContext.js';
|
||||
|
||||
export type { Key };
|
||||
|
||||
/**
|
||||
* Translates a Key object into its corresponding ANSI escape sequence.
|
||||
* This is useful for sending control characters to a pseudo-terminal.
|
||||
*
|
||||
* @param key The Key object to translate.
|
||||
* @returns The ANSI escape sequence as a string, or null if no mapping exists.
|
||||
*/
|
||||
export function keyToAnsi(key: Key): string | null {
|
||||
if (key.ctrl) {
|
||||
// Ctrl + letter
|
||||
if (key.name >= 'a' && key.name <= 'z') {
|
||||
return String.fromCharCode(
|
||||
key.name.charCodeAt(0) - 'a'.charCodeAt(0) + 1,
|
||||
);
|
||||
}
|
||||
// Other Ctrl combinations might need specific handling
|
||||
switch (key.name) {
|
||||
case 'c':
|
||||
return '\x03'; // ETX (End of Text), commonly used for interrupt
|
||||
// Add other special ctrl cases if needed
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Arrow keys and other special keys
|
||||
switch (key.name) {
|
||||
case 'up':
|
||||
return '\x1b[A';
|
||||
case 'down':
|
||||
return '\x1b[B';
|
||||
case 'right':
|
||||
return '\x1b[C';
|
||||
case 'left':
|
||||
return '\x1b[D';
|
||||
case 'escape':
|
||||
return '\x1b';
|
||||
case 'tab':
|
||||
return '\t';
|
||||
case 'backspace':
|
||||
return '\x7f';
|
||||
case 'delete':
|
||||
return '\x1b[3~';
|
||||
case 'home':
|
||||
return '\x1b[H';
|
||||
case 'end':
|
||||
return '\x1b[F';
|
||||
case 'pageup':
|
||||
return '\x1b[5~';
|
||||
case 'pagedown':
|
||||
return '\x1b[6~';
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// Enter/Return
|
||||
if (key.name === 'return') {
|
||||
return '\r';
|
||||
}
|
||||
|
||||
// If it's a simple character, return it.
|
||||
if (!key.ctrl && !key.cmd && key.sequence) {
|
||||
return key.sequence;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
getAdminErrorMessage,
|
||||
} from '@google/gemini-cli-core';
|
||||
import { useKeypress } from './useKeypress.js';
|
||||
import { Command } from '../keyMatchers.js';
|
||||
import { Command } from '../key/keyMatchers.js';
|
||||
import { useKeyMatchers } from './useKeyMatchers.js';
|
||||
import type { HistoryItemWithoutId } from '../types.js';
|
||||
import { MessageType } from '../types.js';
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type { KeyMatchers } from '../keyMatchers.js';
|
||||
import { defaultKeyMatchers } from '../keyMatchers.js';
|
||||
import type { KeyMatchers } from '../key/keyMatchers.js';
|
||||
import { defaultKeyMatchers } from '../key/keyMatchers.js';
|
||||
|
||||
/**
|
||||
* Hook to retrieve the currently active key matchers.
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import { useReducer, useRef, useEffect, useCallback } from 'react';
|
||||
import { useKeypress, type Key } from './useKeypress.js';
|
||||
import { Command } from '../keyMatchers.js';
|
||||
import { Command } from '../key/keyMatchers.js';
|
||||
import { debugLogger } from '@google/gemini-cli-core';
|
||||
import { useKeyMatchers } from './useKeyMatchers.js';
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -20,8 +20,8 @@ import {
|
||||
terminalCapabilityManager,
|
||||
} from '../utils/terminalCapabilityManager.js';
|
||||
import { WARNING_PROMPT_DURATION_MS } from '../constants.js';
|
||||
import { formatCommand } from '../utils/keybindingUtils.js';
|
||||
import { Command } from '../../config/keyBindings.js';
|
||||
import { formatCommand } from '../key/keybindingUtils.js';
|
||||
import { Command } from '../key/keyBindings.js';
|
||||
|
||||
interface UseSuspendProps {
|
||||
handleWarning: (message: string) => void;
|
||||
|
||||
@@ -9,18 +9,12 @@ import { act } from 'react';
|
||||
import { renderHook } from '../../test-utils/render.js';
|
||||
import { useTabbedNavigation } from './useTabbedNavigation.js';
|
||||
import { useKeypress } from './useKeypress.js';
|
||||
import { useKeyMatchers } from './useKeyMatchers.js';
|
||||
import type { KeyMatchers } from '../keyMatchers.js';
|
||||
import type { Key, KeypressHandler } from '../contexts/KeypressContext.js';
|
||||
|
||||
vi.mock('./useKeypress.js', () => ({
|
||||
useKeypress: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./useKeyMatchers.js', () => ({
|
||||
useKeyMatchers: vi.fn(),
|
||||
}));
|
||||
|
||||
const createKey = (partial: Partial<Key>): Key => ({
|
||||
name: partial.name || '',
|
||||
sequence: partial.sequence || '',
|
||||
@@ -32,27 +26,10 @@ const createKey = (partial: Partial<Key>): Key => ({
|
||||
...partial,
|
||||
});
|
||||
|
||||
const mockKeyMatchers = {
|
||||
'cursor.left': vi.fn((key) => key.name === 'left'),
|
||||
'cursor.right': vi.fn((key) => key.name === 'right'),
|
||||
'dialog.next': vi.fn((key) => key.name === 'tab' && !key.shift),
|
||||
'dialog.previous': vi.fn((key) => key.name === 'tab' && key.shift),
|
||||
} as unknown as KeyMatchers;
|
||||
|
||||
vi.mock('../keyMatchers.js', () => ({
|
||||
Command: {
|
||||
MOVE_LEFT: 'cursor.left',
|
||||
MOVE_RIGHT: 'cursor.right',
|
||||
DIALOG_NEXT: 'dialog.next',
|
||||
DIALOG_PREV: 'dialog.previous',
|
||||
},
|
||||
}));
|
||||
|
||||
describe('useTabbedNavigation', () => {
|
||||
let capturedHandler: KeypressHandler;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(useKeyMatchers).mockReturnValue(mockKeyMatchers);
|
||||
vi.mocked(useKeypress).mockImplementation((handler) => {
|
||||
capturedHandler = handler;
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
import { useReducer, useCallback, useEffect, useRef } from 'react';
|
||||
import { useKeypress, type Key } from './useKeypress.js';
|
||||
import { Command } from '../keyMatchers.js';
|
||||
import { Command } from '../key/keyMatchers.js';
|
||||
import { useKeyMatchers } from './useKeyMatchers.js';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import type { KeyBindingConfig } from './keyBindings.js';
|
||||
import {
|
||||
Command,
|
||||
commandCategories,
|
||||
commandDescriptions,
|
||||
defaultKeyBindings,
|
||||
KeyBinding,
|
||||
} from './keyBindings.js';
|
||||
|
||||
describe('KeyBinding', () => {
|
||||
describe('constructor', () => {
|
||||
it('should parse a simple key', () => {
|
||||
const binding = new KeyBinding('a');
|
||||
expect(binding.key).toBe('a');
|
||||
expect(binding.ctrl).toBe(false);
|
||||
expect(binding.shift).toBe(false);
|
||||
expect(binding.alt).toBe(false);
|
||||
expect(binding.cmd).toBe(false);
|
||||
});
|
||||
|
||||
it('should parse ctrl+key', () => {
|
||||
const binding = new KeyBinding('ctrl+c');
|
||||
expect(binding.key).toBe('c');
|
||||
expect(binding.ctrl).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse shift+key', () => {
|
||||
const binding = new KeyBinding('shift+z');
|
||||
expect(binding.key).toBe('z');
|
||||
expect(binding.shift).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse alt+key', () => {
|
||||
const binding = new KeyBinding('alt+left');
|
||||
expect(binding.key).toBe('left');
|
||||
expect(binding.alt).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse cmd+key', () => {
|
||||
const binding = new KeyBinding('cmd+f');
|
||||
expect(binding.key).toBe('f');
|
||||
expect(binding.cmd).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle aliases (option/opt/meta)', () => {
|
||||
const optionBinding = new KeyBinding('option+b');
|
||||
expect(optionBinding.key).toBe('b');
|
||||
expect(optionBinding.alt).toBe(true);
|
||||
|
||||
const optBinding = new KeyBinding('opt+b');
|
||||
expect(optBinding.key).toBe('b');
|
||||
expect(optBinding.alt).toBe(true);
|
||||
|
||||
const metaBinding = new KeyBinding('meta+enter');
|
||||
expect(metaBinding.key).toBe('enter');
|
||||
expect(metaBinding.cmd).toBe(true);
|
||||
});
|
||||
|
||||
it('should parse multiple modifiers', () => {
|
||||
const binding = new KeyBinding('ctrl+shift+alt+cmd+x');
|
||||
expect(binding.key).toBe('x');
|
||||
expect(binding.ctrl).toBe(true);
|
||||
expect(binding.shift).toBe(true);
|
||||
expect(binding.alt).toBe(true);
|
||||
expect(binding.cmd).toBe(true);
|
||||
});
|
||||
|
||||
it('should be case-insensitive', () => {
|
||||
const binding = new KeyBinding('CTRL+Shift+F');
|
||||
expect(binding.key).toBe('f');
|
||||
expect(binding.ctrl).toBe(true);
|
||||
expect(binding.shift).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle named keys with modifiers', () => {
|
||||
const binding = new KeyBinding('ctrl+return');
|
||||
expect(binding.key).toBe('return');
|
||||
expect(binding.ctrl).toBe(true);
|
||||
});
|
||||
|
||||
it('should throw an error for invalid keys or typos in modifiers', () => {
|
||||
expect(() => new KeyBinding('ctrl+unknown')).toThrow(
|
||||
'Invalid keybinding key: "unknown" in "ctrl+unknown"',
|
||||
);
|
||||
expect(() => new KeyBinding('ctlr+a')).toThrow(
|
||||
'Invalid keybinding key: "ctlr+a" in "ctlr+a"',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw an error for literal "+" as key (must use "=")', () => {
|
||||
// VS Code style peeling logic results in "+" as the remains
|
||||
expect(() => new KeyBinding('alt++')).toThrow(
|
||||
'Invalid keybinding key: "+" in "alt++"',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('keyBindings config', () => {
|
||||
describe('defaultKeyBindings', () => {
|
||||
it('should have bindings for all commands', () => {
|
||||
const commands = Object.values(Command);
|
||||
|
||||
for (const command of commands) {
|
||||
expect(defaultKeyBindings[command]).toBeDefined();
|
||||
expect(Array.isArray(defaultKeyBindings[command])).toBe(true);
|
||||
expect(defaultKeyBindings[command]?.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should export all required types', () => {
|
||||
// Basic type checks
|
||||
expect(typeof Command.HOME).toBe('string');
|
||||
expect(typeof Command.END).toBe('string');
|
||||
|
||||
// Config should be readonly
|
||||
const config: KeyBindingConfig = defaultKeyBindings;
|
||||
expect(config[Command.HOME]).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('command metadata', () => {
|
||||
const commandValues = Object.values(Command);
|
||||
|
||||
it('has a description entry for every command', () => {
|
||||
const describedCommands = Object.keys(commandDescriptions);
|
||||
expect(describedCommands.sort()).toEqual([...commandValues].sort());
|
||||
|
||||
for (const command of commandValues) {
|
||||
expect(typeof commandDescriptions[command]).toBe('string');
|
||||
expect(commandDescriptions[command]?.trim()).not.toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('categorizes each command exactly once', () => {
|
||||
const seen = new Set<Command>();
|
||||
|
||||
for (const category of commandCategories) {
|
||||
expect(typeof category.title).toBe('string');
|
||||
expect(Array.isArray(category.commands)).toBe(true);
|
||||
|
||||
for (const command of category.commands) {
|
||||
expect(commandValues).toContain(command);
|
||||
expect(seen.has(command)).toBe(false);
|
||||
seen.add(command);
|
||||
}
|
||||
}
|
||||
|
||||
expect(seen.size).toBe(commandValues.length);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,8 @@
|
||||
/**
|
||||
* Command enum for all available keyboard shortcuts
|
||||
*/
|
||||
import type { Key } from '../hooks/useKeypress.js';
|
||||
|
||||
export enum Command {
|
||||
// Basic Controls
|
||||
RETURN = 'basic.confirm',
|
||||
@@ -49,7 +51,6 @@ export enum Command {
|
||||
REVERSE_SEARCH = 'history.search.start',
|
||||
SUBMIT_REVERSE_SEARCH = 'history.search.submit',
|
||||
ACCEPT_SUGGESTION_REVERSE_SEARCH = 'history.search.accept',
|
||||
REWIND = 'history.rewind',
|
||||
|
||||
// Navigation
|
||||
NAVIGATION_UP = 'nav.up',
|
||||
@@ -102,17 +103,126 @@ export enum Command {
|
||||
/**
|
||||
* Data-driven key binding structure for user configuration
|
||||
*/
|
||||
export interface KeyBinding {
|
||||
export class KeyBinding {
|
||||
private static readonly VALID_KEYS = new Set([
|
||||
// Letters & Numbers
|
||||
...'abcdefghijklmnopqrstuvwxyz0123456789',
|
||||
// Punctuation
|
||||
'`',
|
||||
'-',
|
||||
'=',
|
||||
'[',
|
||||
']',
|
||||
'\\',
|
||||
';',
|
||||
"'",
|
||||
',',
|
||||
'.',
|
||||
'/',
|
||||
// Navigation & Actions
|
||||
'left',
|
||||
'up',
|
||||
'right',
|
||||
'down',
|
||||
'pageup',
|
||||
'pagedown',
|
||||
'end',
|
||||
'home',
|
||||
'tab',
|
||||
'enter',
|
||||
'escape',
|
||||
'space',
|
||||
'backspace',
|
||||
'delete',
|
||||
'pausebreak',
|
||||
'capslock',
|
||||
'insert',
|
||||
'numlock',
|
||||
'scrolllock',
|
||||
// Function Keys
|
||||
...Array.from({ length: 19 }, (_, i) => `f${i + 1}`),
|
||||
// Numpad
|
||||
...Array.from({ length: 10 }, (_, i) => `numpad${i}`),
|
||||
'numpad_multiply',
|
||||
'numpad_add',
|
||||
'numpad_separator',
|
||||
'numpad_subtract',
|
||||
'numpad_decimal',
|
||||
'numpad_divide',
|
||||
// Gemini CLI legacy/internal support
|
||||
'return',
|
||||
]);
|
||||
|
||||
/** The key name (e.g., 'a', 'return', 'tab', 'escape') */
|
||||
key: string;
|
||||
/** Shift key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */
|
||||
shift?: boolean;
|
||||
/** Alt/Option key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */
|
||||
alt?: boolean;
|
||||
/** Control key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */
|
||||
ctrl?: boolean;
|
||||
/** Command/Windows/Super key requirement: true=must be pressed, false=must not be pressed, undefined=ignore */
|
||||
cmd?: boolean;
|
||||
readonly key: string;
|
||||
readonly shift: boolean;
|
||||
readonly alt: boolean;
|
||||
readonly ctrl: boolean;
|
||||
readonly cmd: boolean;
|
||||
|
||||
constructor(pattern: string) {
|
||||
let remains = pattern.toLowerCase().trim();
|
||||
let shift = false;
|
||||
let alt = false;
|
||||
let ctrl = false;
|
||||
let cmd = false;
|
||||
|
||||
let matched: boolean;
|
||||
do {
|
||||
matched = false;
|
||||
if (remains.startsWith('ctrl+')) {
|
||||
ctrl = true;
|
||||
remains = remains.slice(5);
|
||||
matched = true;
|
||||
} else if (remains.startsWith('shift+')) {
|
||||
shift = true;
|
||||
remains = remains.slice(6);
|
||||
matched = true;
|
||||
} else if (remains.startsWith('alt+')) {
|
||||
alt = true;
|
||||
remains = remains.slice(4);
|
||||
matched = true;
|
||||
} else if (remains.startsWith('option+')) {
|
||||
alt = true;
|
||||
remains = remains.slice(7);
|
||||
matched = true;
|
||||
} else if (remains.startsWith('opt+')) {
|
||||
alt = true;
|
||||
remains = remains.slice(4);
|
||||
matched = true;
|
||||
} else if (remains.startsWith('cmd+')) {
|
||||
cmd = true;
|
||||
remains = remains.slice(4);
|
||||
matched = true;
|
||||
} else if (remains.startsWith('meta+')) {
|
||||
cmd = true;
|
||||
remains = remains.slice(5);
|
||||
matched = true;
|
||||
}
|
||||
} while (matched);
|
||||
|
||||
const key = remains;
|
||||
|
||||
if (!KeyBinding.VALID_KEYS.has(key)) {
|
||||
throw new Error(`Invalid keybinding key: "${key}" in "${pattern}"`);
|
||||
}
|
||||
|
||||
this.key = key;
|
||||
this.shift = shift;
|
||||
this.alt = alt;
|
||||
this.ctrl = ctrl;
|
||||
this.cmd = cmd;
|
||||
}
|
||||
|
||||
matches(key: Key): boolean {
|
||||
return (
|
||||
this.key === key.name &&
|
||||
!!key.shift === !!this.shift &&
|
||||
!!key.alt === !!this.alt &&
|
||||
!!key.ctrl === !!this.ctrl &&
|
||||
!!key.cmd === !!this.cmd
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -128,135 +238,143 @@ export type KeyBindingConfig = {
|
||||
*/
|
||||
export const defaultKeyBindings: KeyBindingConfig = {
|
||||
// Basic Controls
|
||||
[Command.RETURN]: [{ key: 'return' }],
|
||||
[Command.ESCAPE]: [{ key: 'escape' }, { key: '[', ctrl: true }],
|
||||
[Command.QUIT]: [{ key: 'c', ctrl: true }],
|
||||
[Command.EXIT]: [{ key: 'd', ctrl: true }],
|
||||
[Command.RETURN]: [new KeyBinding('return')],
|
||||
[Command.ESCAPE]: [new KeyBinding('escape'), new KeyBinding('ctrl+[')],
|
||||
[Command.QUIT]: [new KeyBinding('ctrl+c')],
|
||||
[Command.EXIT]: [new KeyBinding('ctrl+d')],
|
||||
|
||||
// Cursor Movement
|
||||
[Command.HOME]: [{ key: 'a', ctrl: true }, { key: 'home' }],
|
||||
[Command.END]: [{ key: 'e', ctrl: true }, { key: 'end' }],
|
||||
[Command.MOVE_UP]: [{ key: 'up' }],
|
||||
[Command.MOVE_DOWN]: [{ key: 'down' }],
|
||||
[Command.MOVE_LEFT]: [{ key: 'left' }],
|
||||
[Command.MOVE_RIGHT]: [{ key: 'right' }, { key: 'f', ctrl: true }],
|
||||
[Command.HOME]: [new KeyBinding('ctrl+a'), new KeyBinding('home')],
|
||||
[Command.END]: [new KeyBinding('ctrl+e'), new KeyBinding('end')],
|
||||
[Command.MOVE_UP]: [new KeyBinding('up')],
|
||||
[Command.MOVE_DOWN]: [new KeyBinding('down')],
|
||||
[Command.MOVE_LEFT]: [new KeyBinding('left')],
|
||||
[Command.MOVE_RIGHT]: [new KeyBinding('right'), new KeyBinding('ctrl+f')],
|
||||
[Command.MOVE_WORD_LEFT]: [
|
||||
{ key: 'left', ctrl: true },
|
||||
{ key: 'left', alt: true },
|
||||
{ key: 'b', alt: true },
|
||||
new KeyBinding('ctrl+left'),
|
||||
new KeyBinding('alt+left'),
|
||||
new KeyBinding('alt+b'),
|
||||
],
|
||||
[Command.MOVE_WORD_RIGHT]: [
|
||||
{ key: 'right', ctrl: true },
|
||||
{ key: 'right', alt: true },
|
||||
{ key: 'f', alt: true },
|
||||
new KeyBinding('ctrl+right'),
|
||||
new KeyBinding('alt+right'),
|
||||
new KeyBinding('alt+f'),
|
||||
],
|
||||
|
||||
// Editing
|
||||
[Command.KILL_LINE_RIGHT]: [{ key: 'k', ctrl: true }],
|
||||
[Command.KILL_LINE_LEFT]: [{ key: 'u', ctrl: true }],
|
||||
[Command.CLEAR_INPUT]: [{ key: 'c', ctrl: true }],
|
||||
[Command.KILL_LINE_RIGHT]: [new KeyBinding('ctrl+k')],
|
||||
[Command.KILL_LINE_LEFT]: [new KeyBinding('ctrl+u')],
|
||||
[Command.CLEAR_INPUT]: [new KeyBinding('ctrl+c')],
|
||||
[Command.DELETE_WORD_BACKWARD]: [
|
||||
{ key: 'backspace', ctrl: true },
|
||||
{ key: 'backspace', alt: true },
|
||||
{ key: 'w', ctrl: true },
|
||||
new KeyBinding('ctrl+backspace'),
|
||||
new KeyBinding('alt+backspace'),
|
||||
new KeyBinding('ctrl+w'),
|
||||
],
|
||||
[Command.DELETE_WORD_FORWARD]: [
|
||||
{ key: 'delete', ctrl: true },
|
||||
{ key: 'delete', alt: true },
|
||||
{ key: 'd', alt: true },
|
||||
new KeyBinding('ctrl+delete'),
|
||||
new KeyBinding('alt+delete'),
|
||||
new KeyBinding('alt+d'),
|
||||
],
|
||||
[Command.DELETE_CHAR_LEFT]: [{ key: 'backspace' }, { key: 'h', ctrl: true }],
|
||||
[Command.DELETE_CHAR_RIGHT]: [{ key: 'delete' }, { key: 'd', ctrl: true }],
|
||||
[Command.UNDO]: [
|
||||
{ key: 'z', cmd: true },
|
||||
{ key: 'z', alt: true },
|
||||
[Command.DELETE_CHAR_LEFT]: [
|
||||
new KeyBinding('backspace'),
|
||||
new KeyBinding('ctrl+h'),
|
||||
],
|
||||
[Command.DELETE_CHAR_RIGHT]: [
|
||||
new KeyBinding('delete'),
|
||||
new KeyBinding('ctrl+d'),
|
||||
],
|
||||
[Command.UNDO]: [new KeyBinding('cmd+z'), new KeyBinding('alt+z')],
|
||||
[Command.REDO]: [
|
||||
{ key: 'z', ctrl: true, shift: true },
|
||||
{ key: 'z', cmd: true, shift: true },
|
||||
{ key: 'z', alt: true, shift: true },
|
||||
new KeyBinding('ctrl+shift+z'),
|
||||
new KeyBinding('cmd+shift+z'),
|
||||
new KeyBinding('alt+shift+z'),
|
||||
],
|
||||
|
||||
// Scrolling
|
||||
[Command.SCROLL_UP]: [{ key: 'up', shift: true }],
|
||||
[Command.SCROLL_DOWN]: [{ key: 'down', shift: true }],
|
||||
[Command.SCROLL_UP]: [new KeyBinding('shift+up')],
|
||||
[Command.SCROLL_DOWN]: [new KeyBinding('shift+down')],
|
||||
[Command.SCROLL_HOME]: [
|
||||
{ key: 'home', ctrl: true },
|
||||
{ key: 'home', shift: true },
|
||||
new KeyBinding('ctrl+home'),
|
||||
new KeyBinding('shift+home'),
|
||||
],
|
||||
[Command.SCROLL_END]: [
|
||||
{ key: 'end', ctrl: true },
|
||||
{ key: 'end', shift: true },
|
||||
new KeyBinding('ctrl+end'),
|
||||
new KeyBinding('shift+end'),
|
||||
],
|
||||
[Command.PAGE_UP]: [{ key: 'pageup' }],
|
||||
[Command.PAGE_DOWN]: [{ key: 'pagedown' }],
|
||||
[Command.PAGE_UP]: [new KeyBinding('pageup')],
|
||||
[Command.PAGE_DOWN]: [new KeyBinding('pagedown')],
|
||||
|
||||
// History & Search
|
||||
[Command.HISTORY_UP]: [{ key: 'p', ctrl: true }],
|
||||
[Command.HISTORY_DOWN]: [{ key: 'n', ctrl: true }],
|
||||
[Command.REVERSE_SEARCH]: [{ key: 'r', ctrl: true }],
|
||||
[Command.REWIND]: [{ key: 'double escape' }], // for documentation only
|
||||
[Command.SUBMIT_REVERSE_SEARCH]: [{ key: 'return' }],
|
||||
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [{ key: 'tab' }],
|
||||
[Command.HISTORY_UP]: [new KeyBinding('ctrl+p')],
|
||||
[Command.HISTORY_DOWN]: [new KeyBinding('ctrl+n')],
|
||||
[Command.REVERSE_SEARCH]: [new KeyBinding('ctrl+r')],
|
||||
[Command.SUBMIT_REVERSE_SEARCH]: [new KeyBinding('return')],
|
||||
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]: [new KeyBinding('tab')],
|
||||
|
||||
// Navigation
|
||||
[Command.NAVIGATION_UP]: [{ key: 'up' }],
|
||||
[Command.NAVIGATION_DOWN]: [{ key: 'down' }],
|
||||
[Command.NAVIGATION_UP]: [new KeyBinding('up')],
|
||||
[Command.NAVIGATION_DOWN]: [new KeyBinding('down')],
|
||||
// Navigation shortcuts appropriate for dialogs where we do not need to accept
|
||||
// text input.
|
||||
[Command.DIALOG_NAVIGATION_UP]: [{ key: 'up' }, { key: 'k' }],
|
||||
[Command.DIALOG_NAVIGATION_DOWN]: [{ key: 'down' }, { key: 'j' }],
|
||||
[Command.DIALOG_NEXT]: [{ key: 'tab' }],
|
||||
[Command.DIALOG_PREV]: [{ key: 'tab', shift: true }],
|
||||
[Command.DIALOG_NAVIGATION_UP]: [new KeyBinding('up'), new KeyBinding('k')],
|
||||
[Command.DIALOG_NAVIGATION_DOWN]: [
|
||||
new KeyBinding('down'),
|
||||
new KeyBinding('j'),
|
||||
],
|
||||
[Command.DIALOG_NEXT]: [new KeyBinding('tab')],
|
||||
[Command.DIALOG_PREV]: [new KeyBinding('shift+tab')],
|
||||
|
||||
// Suggestions & Completions
|
||||
[Command.ACCEPT_SUGGESTION]: [{ key: 'tab' }, { key: 'return' }],
|
||||
[Command.COMPLETION_UP]: [{ key: 'up' }, { key: 'p', ctrl: true }],
|
||||
[Command.COMPLETION_DOWN]: [{ key: 'down' }, { key: 'n', ctrl: true }],
|
||||
[Command.EXPAND_SUGGESTION]: [{ key: 'right' }],
|
||||
[Command.COLLAPSE_SUGGESTION]: [{ key: 'left' }],
|
||||
[Command.ACCEPT_SUGGESTION]: [
|
||||
new KeyBinding('tab'),
|
||||
new KeyBinding('return'),
|
||||
],
|
||||
[Command.COMPLETION_UP]: [new KeyBinding('up'), new KeyBinding('ctrl+p')],
|
||||
[Command.COMPLETION_DOWN]: [new KeyBinding('down'), new KeyBinding('ctrl+n')],
|
||||
[Command.EXPAND_SUGGESTION]: [new KeyBinding('right')],
|
||||
[Command.COLLAPSE_SUGGESTION]: [new KeyBinding('left')],
|
||||
|
||||
// Text Input
|
||||
// Must also exclude shift to allow shift+enter for newline
|
||||
[Command.SUBMIT]: [{ key: 'return' }],
|
||||
[Command.SUBMIT]: [new KeyBinding('return')],
|
||||
[Command.NEWLINE]: [
|
||||
{ key: 'return', ctrl: true },
|
||||
{ key: 'return', cmd: true },
|
||||
{ key: 'return', alt: true },
|
||||
{ key: 'return', shift: true },
|
||||
{ key: 'j', ctrl: true },
|
||||
new KeyBinding('ctrl+return'),
|
||||
new KeyBinding('cmd+return'),
|
||||
new KeyBinding('alt+return'),
|
||||
new KeyBinding('shift+return'),
|
||||
new KeyBinding('ctrl+j'),
|
||||
],
|
||||
[Command.OPEN_EXTERNAL_EDITOR]: [{ key: 'x', ctrl: true }],
|
||||
[Command.OPEN_EXTERNAL_EDITOR]: [new KeyBinding('ctrl+x')],
|
||||
[Command.PASTE_CLIPBOARD]: [
|
||||
{ key: 'v', ctrl: true },
|
||||
{ key: 'v', cmd: true },
|
||||
{ key: 'v', alt: true },
|
||||
new KeyBinding('ctrl+v'),
|
||||
new KeyBinding('cmd+v'),
|
||||
new KeyBinding('alt+v'),
|
||||
],
|
||||
|
||||
// App Controls
|
||||
[Command.SHOW_ERROR_DETAILS]: [{ key: 'f12' }],
|
||||
[Command.SHOW_FULL_TODOS]: [{ key: 't', ctrl: true }],
|
||||
[Command.SHOW_IDE_CONTEXT_DETAIL]: [{ key: 'g', ctrl: true }],
|
||||
[Command.TOGGLE_MARKDOWN]: [{ key: 'm', alt: true }],
|
||||
[Command.TOGGLE_COPY_MODE]: [{ key: 's', ctrl: true }],
|
||||
[Command.TOGGLE_YOLO]: [{ key: 'y', ctrl: true }],
|
||||
[Command.CYCLE_APPROVAL_MODE]: [{ key: 'tab', shift: true }],
|
||||
[Command.TOGGLE_BACKGROUND_SHELL]: [{ key: 'b', ctrl: true }],
|
||||
[Command.TOGGLE_BACKGROUND_SHELL_LIST]: [{ key: 'l', ctrl: true }],
|
||||
[Command.KILL_BACKGROUND_SHELL]: [{ key: 'k', ctrl: true }],
|
||||
[Command.UNFOCUS_BACKGROUND_SHELL]: [{ key: 'tab', shift: true }],
|
||||
[Command.UNFOCUS_BACKGROUND_SHELL_LIST]: [{ key: 'tab' }],
|
||||
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: [{ key: 'tab' }],
|
||||
[Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING]: [{ key: 'tab' }],
|
||||
[Command.BACKGROUND_SHELL_SELECT]: [{ key: 'return' }],
|
||||
[Command.BACKGROUND_SHELL_ESCAPE]: [{ key: 'escape' }],
|
||||
[Command.SHOW_MORE_LINES]: [{ key: 'o', ctrl: true }],
|
||||
[Command.EXPAND_PASTE]: [{ key: 'o', ctrl: true }],
|
||||
[Command.FOCUS_SHELL_INPUT]: [{ key: 'tab' }],
|
||||
[Command.UNFOCUS_SHELL_INPUT]: [{ key: 'tab', shift: true }],
|
||||
[Command.CLEAR_SCREEN]: [{ key: 'l', ctrl: true }],
|
||||
[Command.RESTART_APP]: [{ key: 'r' }, { key: 'r', shift: true }],
|
||||
[Command.SUSPEND_APP]: [{ key: 'z', ctrl: true }],
|
||||
[Command.SHOW_ERROR_DETAILS]: [new KeyBinding('f12')],
|
||||
[Command.SHOW_FULL_TODOS]: [new KeyBinding('ctrl+t')],
|
||||
[Command.SHOW_IDE_CONTEXT_DETAIL]: [new KeyBinding('ctrl+g')],
|
||||
[Command.TOGGLE_MARKDOWN]: [new KeyBinding('alt+m')],
|
||||
[Command.TOGGLE_COPY_MODE]: [new KeyBinding('ctrl+s')],
|
||||
[Command.TOGGLE_YOLO]: [new KeyBinding('ctrl+y')],
|
||||
[Command.CYCLE_APPROVAL_MODE]: [new KeyBinding('shift+tab')],
|
||||
[Command.TOGGLE_BACKGROUND_SHELL]: [new KeyBinding('ctrl+b')],
|
||||
[Command.TOGGLE_BACKGROUND_SHELL_LIST]: [new KeyBinding('ctrl+l')],
|
||||
[Command.KILL_BACKGROUND_SHELL]: [new KeyBinding('ctrl+k')],
|
||||
[Command.UNFOCUS_BACKGROUND_SHELL]: [new KeyBinding('shift+tab')],
|
||||
[Command.UNFOCUS_BACKGROUND_SHELL_LIST]: [new KeyBinding('tab')],
|
||||
[Command.SHOW_BACKGROUND_SHELL_UNFOCUS_WARNING]: [new KeyBinding('tab')],
|
||||
[Command.SHOW_SHELL_INPUT_UNFOCUS_WARNING]: [new KeyBinding('tab')],
|
||||
[Command.BACKGROUND_SHELL_SELECT]: [new KeyBinding('return')],
|
||||
[Command.BACKGROUND_SHELL_ESCAPE]: [new KeyBinding('escape')],
|
||||
[Command.SHOW_MORE_LINES]: [new KeyBinding('ctrl+o')],
|
||||
[Command.EXPAND_PASTE]: [new KeyBinding('ctrl+o')],
|
||||
[Command.FOCUS_SHELL_INPUT]: [new KeyBinding('tab')],
|
||||
[Command.UNFOCUS_SHELL_INPUT]: [new KeyBinding('shift+tab')],
|
||||
[Command.CLEAR_SCREEN]: [new KeyBinding('ctrl+l')],
|
||||
[Command.RESTART_APP]: [new KeyBinding('r'), new KeyBinding('shift+r')],
|
||||
[Command.SUSPEND_APP]: [new KeyBinding('ctrl+z')],
|
||||
};
|
||||
|
||||
interface CommandCategory {
|
||||
@@ -318,7 +436,6 @@ export const commandCategories: readonly CommandCategory[] = [
|
||||
Command.REVERSE_SEARCH,
|
||||
Command.SUBMIT_REVERSE_SEARCH,
|
||||
Command.ACCEPT_SUGGESTION_REVERSE_SEARCH,
|
||||
Command.REWIND,
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -428,7 +545,6 @@ export const commandDescriptions: Readonly<Record<Command, string>> = {
|
||||
[Command.SUBMIT_REVERSE_SEARCH]: 'Submit the selected reverse-search match.',
|
||||
[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH]:
|
||||
'Accept a suggestion while reverse searching.',
|
||||
[Command.REWIND]: 'Browse and rewind previous interactions.',
|
||||
|
||||
// Navigation
|
||||
[Command.NAVIGATION_UP]: 'Move selection up in lists.',
|
||||
+5
-8
@@ -10,9 +10,9 @@ import {
|
||||
Command,
|
||||
createKeyMatchers,
|
||||
} from './keyMatchers.js';
|
||||
import type { KeyBindingConfig } from '../config/keyBindings.js';
|
||||
import { defaultKeyBindings } from '../config/keyBindings.js';
|
||||
import type { Key } from './hooks/useKeypress.js';
|
||||
import type { KeyBindingConfig } from './keyBindings.js';
|
||||
import { defaultKeyBindings, KeyBinding } from './keyBindings.js';
|
||||
import type { Key } from '../hooks/useKeypress.js';
|
||||
|
||||
describe('keyMatchers', () => {
|
||||
const createKey = (name: string, mods: Partial<Key> = {}): Key => ({
|
||||
@@ -445,7 +445,7 @@ describe('keyMatchers', () => {
|
||||
it('should work with custom configuration', () => {
|
||||
const customConfig: KeyBindingConfig = {
|
||||
...defaultKeyBindings,
|
||||
[Command.HOME]: [{ key: 'h', ctrl: true }, { key: '0' }],
|
||||
[Command.HOME]: [new KeyBinding('ctrl+h'), new KeyBinding('0')],
|
||||
};
|
||||
|
||||
const customMatchers = createKeyMatchers(customConfig);
|
||||
@@ -462,10 +462,7 @@ describe('keyMatchers', () => {
|
||||
it('should support multiple key bindings for same command', () => {
|
||||
const config: KeyBindingConfig = {
|
||||
...defaultKeyBindings,
|
||||
[Command.QUIT]: [
|
||||
{ key: 'q', ctrl: true },
|
||||
{ key: 'q', alt: true },
|
||||
],
|
||||
[Command.QUIT]: [new KeyBinding('ctrl+q'), new KeyBinding('alt+q')],
|
||||
};
|
||||
|
||||
const matchers = createKeyMatchers(config);
|
||||
@@ -4,26 +4,9 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Key } from './hooks/useKeypress.js';
|
||||
import type { KeyBinding, KeyBindingConfig } from '../config/keyBindings.js';
|
||||
import { Command, defaultKeyBindings } from '../config/keyBindings.js';
|
||||
|
||||
/**
|
||||
* Matches a KeyBinding against an actual Key press
|
||||
* Pure data-driven matching logic
|
||||
*/
|
||||
function matchKeyBinding(keyBinding: KeyBinding, key: Key): boolean {
|
||||
// Check modifiers:
|
||||
// true = modifier must be pressed
|
||||
// false or undefined = modifier must NOT be pressed
|
||||
return (
|
||||
keyBinding.key === key.name &&
|
||||
!!key.shift === !!keyBinding.shift &&
|
||||
!!key.alt === !!keyBinding.alt &&
|
||||
!!key.ctrl === !!keyBinding.ctrl &&
|
||||
!!key.cmd === !!keyBinding.cmd
|
||||
);
|
||||
}
|
||||
import type { Key } from '../hooks/useKeypress.js';
|
||||
import type { KeyBindingConfig } from './keyBindings.js';
|
||||
import { Command, defaultKeyBindings } from './keyBindings.js';
|
||||
|
||||
/**
|
||||
* Checks if a key matches any of the bindings for a command
|
||||
@@ -33,8 +16,7 @@ function matchCommand(
|
||||
key: Key,
|
||||
config: KeyBindingConfig = defaultKeyBindings,
|
||||
): boolean {
|
||||
const bindings = config[command];
|
||||
return bindings.some((binding) => matchKeyBinding(binding, key));
|
||||
return config[command].some((binding) => binding.matches(key));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Key } from '../contexts/KeypressContext.js';
|
||||
|
||||
export type { Key };
|
||||
|
||||
const SPECIAL_KEYS: Record<string, string> = {
|
||||
up: '\x1b[A',
|
||||
down: '\x1b[B',
|
||||
right: '\x1b[C',
|
||||
left: '\x1b[D',
|
||||
escape: '\x1b',
|
||||
tab: '\t',
|
||||
backspace: '\x7f',
|
||||
delete: '\x1b[3~',
|
||||
home: '\x1b[H',
|
||||
end: '\x1b[F',
|
||||
pageup: '\x1b[5~',
|
||||
pagedown: '\x1b[6~',
|
||||
return: '\r',
|
||||
};
|
||||
|
||||
/**
|
||||
* Translates a Key object into its corresponding ANSI escape sequence.
|
||||
* This is useful for sending control characters to a pseudo-terminal.
|
||||
*
|
||||
* @param key The Key object to translate.
|
||||
* @returns The ANSI escape sequence as a string, or null if no mapping exists.
|
||||
*/
|
||||
export function keyToAnsi(key: Key): string | null {
|
||||
if (key.ctrl) {
|
||||
// Ctrl + letter (A-Z maps to 1-26, e.g., Ctrl+C is \x03)
|
||||
if (key.name >= 'a' && key.name <= 'z') {
|
||||
return String.fromCharCode(
|
||||
key.name.charCodeAt(0) - 'a'.charCodeAt(0) + 1,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Arrow keys and other special keys
|
||||
if (key.name in SPECIAL_KEYS) {
|
||||
return SPECIAL_KEYS[key.name];
|
||||
}
|
||||
|
||||
// If it's a simple character, return it.
|
||||
if (!key.ctrl && !key.cmd && key.sequence) {
|
||||
return key.sequence;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
+10
-11
@@ -6,8 +6,7 @@
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { formatKeyBinding, formatCommand } from './keybindingUtils.js';
|
||||
import { Command } from '../../config/keyBindings.js';
|
||||
import type { KeyBinding } from '../../config/keyBindings.js';
|
||||
import { Command, KeyBinding } from './keyBindings.js';
|
||||
|
||||
describe('keybindingUtils', () => {
|
||||
describe('formatKeyBinding', () => {
|
||||
@@ -23,12 +22,12 @@ describe('keybindingUtils', () => {
|
||||
}> = [
|
||||
{
|
||||
name: 'simple key',
|
||||
binding: { key: 'a' },
|
||||
binding: new KeyBinding('a'),
|
||||
expected: { darwin: 'A', win32: 'A', linux: 'A', default: 'A' },
|
||||
},
|
||||
{
|
||||
name: 'named key (return)',
|
||||
binding: { key: 'return' },
|
||||
binding: new KeyBinding('return'),
|
||||
expected: {
|
||||
darwin: 'Enter',
|
||||
win32: 'Enter',
|
||||
@@ -38,12 +37,12 @@ describe('keybindingUtils', () => {
|
||||
},
|
||||
{
|
||||
name: 'named key (escape)',
|
||||
binding: { key: 'escape' },
|
||||
binding: new KeyBinding('escape'),
|
||||
expected: { darwin: 'Esc', win32: 'Esc', linux: 'Esc', default: 'Esc' },
|
||||
},
|
||||
{
|
||||
name: 'ctrl modifier',
|
||||
binding: { key: 'c', ctrl: true },
|
||||
binding: new KeyBinding('ctrl+c'),
|
||||
expected: {
|
||||
darwin: 'Ctrl+C',
|
||||
win32: 'Ctrl+C',
|
||||
@@ -53,7 +52,7 @@ describe('keybindingUtils', () => {
|
||||
},
|
||||
{
|
||||
name: 'cmd modifier',
|
||||
binding: { key: 'z', cmd: true },
|
||||
binding: new KeyBinding('cmd+z'),
|
||||
expected: {
|
||||
darwin: 'Cmd+Z',
|
||||
win32: 'Win+Z',
|
||||
@@ -63,7 +62,7 @@ describe('keybindingUtils', () => {
|
||||
},
|
||||
{
|
||||
name: 'alt/option modifier',
|
||||
binding: { key: 'left', alt: true },
|
||||
binding: new KeyBinding('alt+left'),
|
||||
expected: {
|
||||
darwin: 'Option+Left',
|
||||
win32: 'Alt+Left',
|
||||
@@ -73,7 +72,7 @@ describe('keybindingUtils', () => {
|
||||
},
|
||||
{
|
||||
name: 'shift modifier',
|
||||
binding: { key: 'up', shift: true },
|
||||
binding: new KeyBinding('shift+up'),
|
||||
expected: {
|
||||
darwin: 'Shift+Up',
|
||||
win32: 'Shift+Up',
|
||||
@@ -83,7 +82,7 @@ describe('keybindingUtils', () => {
|
||||
},
|
||||
{
|
||||
name: 'multiple modifiers (ctrl+shift)',
|
||||
binding: { key: 'z', ctrl: true, shift: true },
|
||||
binding: new KeyBinding('ctrl+shift+z'),
|
||||
expected: {
|
||||
darwin: 'Ctrl+Shift+Z',
|
||||
win32: 'Ctrl+Shift+Z',
|
||||
@@ -93,7 +92,7 @@ describe('keybindingUtils', () => {
|
||||
},
|
||||
{
|
||||
name: 'all modifiers',
|
||||
binding: { key: 'a', ctrl: true, alt: true, shift: true, cmd: true },
|
||||
binding: new KeyBinding('ctrl+alt+shift+cmd+a'),
|
||||
expected: {
|
||||
darwin: 'Ctrl+Option+Shift+Cmd+A',
|
||||
win32: 'Ctrl+Alt+Shift+Win+A',
|
||||
+1
-2
@@ -10,7 +10,7 @@ import {
|
||||
type KeyBinding,
|
||||
type KeyBindingConfig,
|
||||
defaultKeyBindings,
|
||||
} from '../../config/keyBindings.js';
|
||||
} from './keyBindings.js';
|
||||
|
||||
/**
|
||||
* Maps internal key names to user-friendly display names.
|
||||
@@ -30,7 +30,6 @@ const KEY_NAME_MAP: Record<string, string> = {
|
||||
end: 'End',
|
||||
tab: 'Tab',
|
||||
space: 'Space',
|
||||
'double escape': 'Double Esc',
|
||||
};
|
||||
|
||||
interface ModifierMap {
|
||||
@@ -156,7 +156,7 @@ export function colorizeCode({
|
||||
try {
|
||||
// Render the HAST tree using the adapted theme
|
||||
// Apply the theme's default foreground color to the top-level Text element
|
||||
let lines = codeToHighlight.split('\n');
|
||||
let lines = codeToHighlight.split(/\r?\n/);
|
||||
const padWidth = String(lines.length).length; // Calculate padding width based on number of lines
|
||||
|
||||
let hiddenLinesCount = 0;
|
||||
@@ -225,7 +225,7 @@ export function colorizeCode({
|
||||
);
|
||||
// Fall back to plain text with default color on error
|
||||
// Also display line numbers in fallback
|
||||
const lines = codeToHighlight.split('\n');
|
||||
const lines = codeToHighlight.split(/\r?\n/);
|
||||
const padWidth = String(lines.length).length; // Calculate padding width based on number of lines
|
||||
const fallbackLines = lines.map((line, index) => (
|
||||
<Box key={index} minHeight={1}>
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -361,6 +361,10 @@ export interface GeminiCLIExtension {
|
||||
*/
|
||||
directory?: string;
|
||||
};
|
||||
/**
|
||||
* Used to migrate an extension to a new repository source.
|
||||
*/
|
||||
migratedTo?: string;
|
||||
}
|
||||
|
||||
export interface ExtensionInstallMetadata {
|
||||
|
||||
@@ -870,6 +870,77 @@ describe('ShellExecutionService', () => {
|
||||
|
||||
expect(ShellExecutionService['activePtys'].size).toBe(0);
|
||||
});
|
||||
|
||||
it('should destroy the PTY when kill() is called', async () => {
|
||||
// Execute a command to populate activePtys
|
||||
const abortController = new AbortController();
|
||||
await ShellExecutionService.execute(
|
||||
'long-running',
|
||||
'/test/dir',
|
||||
onOutputEventMock,
|
||||
abortController.signal,
|
||||
true,
|
||||
shellExecutionConfig,
|
||||
);
|
||||
await new Promise((resolve) => process.nextTick(resolve));
|
||||
|
||||
const pid = mockPtyProcess.pid;
|
||||
const activePty = ShellExecutionService['activePtys'].get(pid);
|
||||
expect(activePty).toBeTruthy();
|
||||
|
||||
// Spy on the actual stored object's destroy
|
||||
const storedDestroySpy = vi.spyOn(
|
||||
activePty!.ptyProcess as never as { destroy: () => void },
|
||||
'destroy',
|
||||
);
|
||||
|
||||
ShellExecutionService.kill(pid);
|
||||
|
||||
expect(storedDestroySpy).toHaveBeenCalled();
|
||||
expect(ShellExecutionService['activePtys'].has(pid)).toBe(false);
|
||||
});
|
||||
|
||||
it('should destroy the PTY when an exception occurs after spawn in executeWithPty', async () => {
|
||||
// Simulate: spawn succeeds, Promise executor runs fine (pid accesses 1-2),
|
||||
// but the return statement `{ pid: ptyProcess.pid }` (access 3) throws.
|
||||
// The catch block should call spawnedPty.destroy() to release the fd.
|
||||
const destroySpy = vi.fn();
|
||||
let pidAccessCount = 0;
|
||||
const faultyPty = {
|
||||
onData: vi.fn(),
|
||||
onExit: vi.fn(),
|
||||
write: vi.fn(),
|
||||
kill: vi.fn(),
|
||||
resize: vi.fn(),
|
||||
destroy: destroySpy,
|
||||
get pid() {
|
||||
pidAccessCount++;
|
||||
// Accesses 1-2 are inside the Promise executor (setup).
|
||||
// Access 3 is at `return { pid: ptyProcess.pid, result }`,
|
||||
// outside the Promise — caught by the outer try/catch.
|
||||
if (pidAccessCount > 2) {
|
||||
throw new Error('Simulated post-spawn failure on pid access');
|
||||
}
|
||||
return 77777;
|
||||
},
|
||||
};
|
||||
mockPtySpawn.mockReturnValueOnce(faultyPty);
|
||||
|
||||
const handle = await ShellExecutionService.execute(
|
||||
'will-fail-after-spawn',
|
||||
'/test/dir',
|
||||
onOutputEventMock,
|
||||
new AbortController().signal,
|
||||
true,
|
||||
shellExecutionConfig,
|
||||
);
|
||||
|
||||
const result = await handle.result;
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.error).toBeTruthy();
|
||||
// The catch block must call destroy() on spawnedPty to prevent fd leak
|
||||
expect(destroySpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -552,6 +552,8 @@ export class ShellExecutionService {
|
||||
// This should not happen, but as a safeguard...
|
||||
throw new Error('PTY implementation not found');
|
||||
}
|
||||
let spawnedPty: IPty | undefined;
|
||||
|
||||
try {
|
||||
const cols = shellExecutionConfig.terminalWidth ?? 80;
|
||||
const rows = shellExecutionConfig.terminalHeight ?? 30;
|
||||
@@ -585,6 +587,8 @@ export class ShellExecutionService {
|
||||
},
|
||||
handleFlowControl: true,
|
||||
});
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
spawnedPty = ptyProcess as IPty;
|
||||
|
||||
const result = new Promise<ShellExecutionResult>((resolve) => {
|
||||
this.activeResolvers.set(ptyProcess.pid, resolve);
|
||||
@@ -882,6 +886,15 @@ export class ShellExecutionService {
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
|
||||
const error = e as Error;
|
||||
|
||||
if (spawnedPty) {
|
||||
try {
|
||||
(spawnedPty as IPty & { destroy?: () => void }).destroy?.();
|
||||
} catch {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
}
|
||||
|
||||
if (error.message.includes('posix_spawnp failed')) {
|
||||
onOutputEvent({
|
||||
type: 'data',
|
||||
@@ -1008,6 +1021,11 @@ export class ShellExecutionService {
|
||||
this.activeChildProcesses.delete(pid);
|
||||
} else if (activePty) {
|
||||
killProcessGroup({ pid, pty: activePty.ptyProcess }).catch(() => {});
|
||||
try {
|
||||
(activePty.ptyProcess as IPty & { destroy?: () => void }).destroy?.();
|
||||
} catch {
|
||||
// Ignore errors during cleanup
|
||||
}
|
||||
this.activePtys.delete(pid);
|
||||
}
|
||||
|
||||
|
||||
@@ -776,7 +776,7 @@ Content of file[1]
|
||||
|
||||
// Mock to track concurrent vs sequential execution
|
||||
detectFileTypeSpy.mockImplementation(async (filePath: string) => {
|
||||
const fileName = filePath.split('/').pop() || '';
|
||||
const fileName = path.basename(filePath);
|
||||
executionOrder.push(`start:${fileName}`);
|
||||
|
||||
// Add delay to make timing differences visible
|
||||
|
||||
@@ -392,7 +392,10 @@ describe('editor utils', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it(`should reject if ${editor} exits with non-zero code`, async () => {
|
||||
it(`should resolve and log warning if ${editor} exits with non-zero code`, async () => {
|
||||
const warnSpy = vi
|
||||
.spyOn(debugLogger, 'warn')
|
||||
.mockImplementation(() => {});
|
||||
const mockSpawnOn = vi.fn((event, cb) => {
|
||||
if (event === 'close') {
|
||||
cb(1);
|
||||
@@ -400,9 +403,73 @@ describe('editor utils', () => {
|
||||
});
|
||||
(spawn as Mock).mockReturnValue({ on: mockSpawnOn });
|
||||
|
||||
await openDiff('old.txt', 'new.txt', editor);
|
||||
expect(warnSpy).toHaveBeenCalledWith(`${editor} exited with code 1`);
|
||||
});
|
||||
|
||||
it(`should emit ExternalEditorClosed when ${editor} exits successfully`, async () => {
|
||||
const emitSpy = vi.spyOn(coreEvents, 'emit');
|
||||
const mockSpawnOn = vi.fn((event, cb) => {
|
||||
if (event === 'close') {
|
||||
cb(0);
|
||||
}
|
||||
});
|
||||
(spawn as Mock).mockReturnValue({ on: mockSpawnOn });
|
||||
|
||||
await openDiff('old.txt', 'new.txt', editor);
|
||||
expect(emitSpy).toHaveBeenCalledWith(CoreEvent.ExternalEditorClosed);
|
||||
});
|
||||
|
||||
it(`should emit ExternalEditorClosed when ${editor} exits with non-zero code`, async () => {
|
||||
vi.spyOn(debugLogger, 'warn').mockImplementation(() => {});
|
||||
const emitSpy = vi.spyOn(coreEvents, 'emit');
|
||||
const mockSpawnOn = vi.fn((event, cb) => {
|
||||
if (event === 'close') {
|
||||
cb(1);
|
||||
}
|
||||
});
|
||||
(spawn as Mock).mockReturnValue({ on: mockSpawnOn });
|
||||
|
||||
await openDiff('old.txt', 'new.txt', editor);
|
||||
expect(emitSpy).toHaveBeenCalledWith(CoreEvent.ExternalEditorClosed);
|
||||
});
|
||||
|
||||
it(`should emit ExternalEditorClosed when ${editor} spawn errors`, async () => {
|
||||
const emitSpy = vi.spyOn(coreEvents, 'emit');
|
||||
const mockError = new Error('spawn error');
|
||||
const mockSpawnOn = vi.fn((event, cb) => {
|
||||
if (event === 'error') {
|
||||
cb(mockError);
|
||||
}
|
||||
});
|
||||
(spawn as Mock).mockReturnValue({ on: mockSpawnOn });
|
||||
|
||||
await expect(openDiff('old.txt', 'new.txt', editor)).rejects.toThrow(
|
||||
`${editor} exited with code 1`,
|
||||
'spawn error',
|
||||
);
|
||||
expect(emitSpy).toHaveBeenCalledWith(CoreEvent.ExternalEditorClosed);
|
||||
});
|
||||
|
||||
it(`should only emit ExternalEditorClosed once when ${editor} fires both error and close`, async () => {
|
||||
const emitSpy = vi.spyOn(coreEvents, 'emit');
|
||||
const callbacks: Record<string, (arg: unknown) => void> = {};
|
||||
const mockSpawnOn = vi.fn(
|
||||
(event: string, cb: (arg: unknown) => void) => {
|
||||
callbacks[event] = cb;
|
||||
},
|
||||
);
|
||||
(spawn as Mock).mockReturnValue({ on: mockSpawnOn });
|
||||
|
||||
const promise = openDiff('old.txt', 'new.txt', editor);
|
||||
// Simulate Node.js behavior: error fires first, then close.
|
||||
callbacks['error'](new Error('spawn error'));
|
||||
callbacks['close'](1);
|
||||
|
||||
await expect(promise).rejects.toThrow('spawn error');
|
||||
const editorClosedEmissions = emitSpy.mock.calls.filter(
|
||||
(call) => call[0] === CoreEvent.ExternalEditorClosed,
|
||||
);
|
||||
expect(editorClosedEmissions).toHaveLength(1);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
## Summary
|
||||
|
||||
This PR implements a seamless migration path for extensions to move to a new
|
||||
repository and optionally change their name without stranding existing users.
|
||||
|
||||
When an extension author sets the `migratedTo` field in their
|
||||
`gemini-extension.json` and publishes an update to their old repository, the CLI
|
||||
will detect this during the next update check. The CLI will then automatically
|
||||
download the extension from the new repository, explicitly warn the user about
|
||||
the migration (and any renaming) during the consent step, and seamlessly migrate
|
||||
the installation and enablement status while cleaning up the old installation.
|
||||
|
||||
## Details
|
||||
|
||||
- **Configuration:** Added `migratedTo` property to `ExtensionConfig` and
|
||||
`GeminiCLIExtension` to track the new repository URL.
|
||||
- **Update checking & downloading:** Updated `checkForExtensionUpdate` and
|
||||
`updateExtension` to inspect the `migratedTo` field. If present, the CLI
|
||||
queries the new repository URL for an update and swaps the installation source
|
||||
so the update resolves from the new location.
|
||||
- **Migration & renaming logic (`ExtensionManager`):**
|
||||
- `installOrUpdateExtension` now fully supports renaming. It transfers global
|
||||
and workspace enablement states from the old extension name to the new one
|
||||
and deletes the old extension directory.
|
||||
- Added safeguards to block renaming if the new name conflicts with a
|
||||
different, already-installed extension or if the destination directory
|
||||
already exists.
|
||||
- Exposed `getEnablementManager()` to `ExtensionManager` for better typing
|
||||
during testing.
|
||||
- **Consent messaging:** Refactored `maybeRequestConsentOrFail` to compute an
|
||||
`isMigrating` flag (by detecting a change in the installation source). The
|
||||
`extensionConsentString` output now explicitly informs users with messages
|
||||
like: _"Migrating extension 'old-name' to a new repository, renaming to
|
||||
'new-name', and installing updates."_
|
||||
- **Documentation:** Documented the `migratedTo` field in
|
||||
`docs/extensions/reference.md` and added a comprehensive guide in
|
||||
`docs/extensions/releasing.md` explaining how extension maintainers can
|
||||
transition users using this feature.
|
||||
- **Testing:** Added extensive unit tests across `extension-manager.test.ts`,
|
||||
`consent.test.ts`, `github.test.ts`, and `update.test.ts` to cover the new
|
||||
migration and renaming logic.
|
||||
|
||||
## Related issues
|
||||
|
||||
N/A
|
||||
|
||||
## How to validate
|
||||
|
||||
1. **Unit tests:** Run all related tests to confirm everything passes:
|
||||
```bash
|
||||
npm run test -w @google/gemini-cli -- src/config/extensions/github.test.ts
|
||||
npm run test -w @google/gemini-cli -- src/config/extensions/update.test.ts
|
||||
npm run test -w @google/gemini-cli -- src/config/extensions/consent.test.ts
|
||||
npm run test -w @google/gemini-cli -- src/config/extension-manager.test.ts
|
||||
```
|
||||
2. **End-to-end migration test:**
|
||||
- Install a local or git extension.
|
||||
- Update its `gemini-extension.json` to include a `migratedTo` field pointing
|
||||
to a _different_ test repository.
|
||||
- Run `gemini extensions check` to confirm it detects the update from the new
|
||||
source.
|
||||
- Run `gemini extensions update <extension>`.
|
||||
- Verify that the consent prompt explicitly mentions the migration.
|
||||
- Verify that the new extension is installed, the old directory is deleted,
|
||||
and its enablement status carried over.
|
||||
@@ -8,12 +8,12 @@ import path from 'node:path';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
|
||||
import type { KeyBinding } from '../packages/cli/src/config/keyBindings.js';
|
||||
import type { KeyBinding } from '../packages/cli/src/ui/key/keyBindings.js';
|
||||
import {
|
||||
commandCategories,
|
||||
commandDescriptions,
|
||||
defaultKeyBindings,
|
||||
} from '../packages/cli/src/config/keyBindings.js';
|
||||
} from '../packages/cli/src/ui/key/keyBindings.js';
|
||||
import {
|
||||
formatWithPrettier,
|
||||
injectBetweenMarkers,
|
||||
@@ -24,7 +24,7 @@ const START_MARKER = '<!-- KEYBINDINGS-AUTOGEN:START -->';
|
||||
const END_MARKER = '<!-- KEYBINDINGS-AUTOGEN:END -->';
|
||||
const OUTPUT_RELATIVE_PATH = ['docs', 'reference', 'keyboard-shortcuts.md'];
|
||||
|
||||
import { formatKeyBinding } from '../packages/cli/src/ui/utils/keybindingUtils.js';
|
||||
import { formatKeyBinding } from '../packages/cli/src/ui/key/keybindingUtils.js';
|
||||
|
||||
export interface KeybindingDocCommand {
|
||||
description: string;
|
||||
|
||||
Reference in New Issue
Block a user