mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-04-14 07:10:34 -07:00
feat(cli): implement interactive shell autocompletion (#20082)
This commit is contained in:
committed by
GitHub
parent
ef247e220d
commit
8380f0a3b1
131
packages/cli/src/ui/hooks/shell-completions/gitProvider.test.ts
Normal file
131
packages/cli/src/ui/hooks/shell-completions/gitProvider.test.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { gitProvider } from './gitProvider.js';
|
||||
import * as childProcess from 'node:child_process';
|
||||
|
||||
vi.mock('node:child_process', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:child_process')>();
|
||||
return {
|
||||
...actual,
|
||||
execFile: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('gitProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('suggests git subcommands for cursorIndex 1', async () => {
|
||||
const result = await gitProvider.getCompletions(['git', 'ch'], 1, '/tmp');
|
||||
|
||||
expect(result.exclusive).toBe(true);
|
||||
expect(result.suggestions).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ value: 'checkout' })]),
|
||||
);
|
||||
expect(
|
||||
result.suggestions.find((s) => s.value === 'commit'),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('suggests branch names for checkout at cursorIndex 2', async () => {
|
||||
vi.mocked(childProcess.execFile).mockImplementation(
|
||||
(_cmd, _args, _opts, cb: unknown) => {
|
||||
const callback = (typeof _opts === 'function' ? _opts : cb) as (
|
||||
error: Error | null,
|
||||
result: { stdout: string },
|
||||
) => void;
|
||||
callback(null, {
|
||||
stdout: 'main\nfeature-branch\nfix/bug\nbranch(with)special\n',
|
||||
});
|
||||
return {} as ReturnType<typeof childProcess.execFile>;
|
||||
},
|
||||
);
|
||||
|
||||
const result = await gitProvider.getCompletions(
|
||||
['git', 'checkout', 'feat'],
|
||||
2,
|
||||
'/tmp',
|
||||
);
|
||||
|
||||
expect(result.exclusive).toBe(true);
|
||||
expect(result.suggestions).toHaveLength(1);
|
||||
expect(result.suggestions[0].label).toBe('feature-branch');
|
||||
expect(result.suggestions[0].value).toBe('feature-branch');
|
||||
expect(childProcess.execFile).toHaveBeenCalledWith(
|
||||
'git',
|
||||
['branch', '--format=%(refname:short)'],
|
||||
expect.any(Object),
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it('escapes branch names with shell metacharacters', async () => {
|
||||
vi.mocked(childProcess.execFile).mockImplementation(
|
||||
(_cmd, _args, _opts, cb: unknown) => {
|
||||
const callback = (typeof _opts === 'function' ? _opts : cb) as (
|
||||
error: Error | null,
|
||||
result: { stdout: string },
|
||||
) => void;
|
||||
callback(null, { stdout: 'main\nbranch(with)special\n' });
|
||||
return {} as ReturnType<typeof childProcess.execFile>;
|
||||
},
|
||||
);
|
||||
|
||||
const result = await gitProvider.getCompletions(
|
||||
['git', 'checkout', 'branch('],
|
||||
2,
|
||||
'/tmp',
|
||||
);
|
||||
|
||||
expect(result.exclusive).toBe(true);
|
||||
expect(result.suggestions).toHaveLength(1);
|
||||
expect(result.suggestions[0].label).toBe('branch(with)special');
|
||||
|
||||
// On Windows, space escape is not done. But since UNIX_SHELL_SPECIAL_CHARS is mostly tested,
|
||||
// we can use a matcher that checks if escaping was applied (it differs per platform but that's handled by escapeShellPath).
|
||||
// Let's match the value against either unescaped (win) or escaped (unix).
|
||||
const isWin = process.platform === 'win32';
|
||||
expect(result.suggestions[0].value).toBe(
|
||||
isWin ? 'branch(with)special' : 'branch\\(with\\)special',
|
||||
);
|
||||
});
|
||||
|
||||
it('returns empty results if git branch fails', async () => {
|
||||
vi.mocked(childProcess.execFile).mockImplementation(
|
||||
(_cmd, _args, _opts, cb: unknown) => {
|
||||
const callback = (typeof _opts === 'function' ? _opts : cb) as (
|
||||
error: Error,
|
||||
stdout?: string,
|
||||
) => void;
|
||||
callback(new Error('Not a git repository'));
|
||||
return {} as ReturnType<typeof childProcess.execFile>;
|
||||
},
|
||||
);
|
||||
|
||||
const result = await gitProvider.getCompletions(
|
||||
['git', 'checkout', ''],
|
||||
2,
|
||||
'/tmp',
|
||||
);
|
||||
|
||||
expect(result.exclusive).toBe(true);
|
||||
expect(result.suggestions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns non-exclusive for unrecognized position', async () => {
|
||||
const result = await gitProvider.getCompletions(
|
||||
['git', 'commit', '-m', 'some message'],
|
||||
3,
|
||||
'/tmp',
|
||||
);
|
||||
|
||||
expect(result.exclusive).toBe(false);
|
||||
expect(result.suggestions).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
93
packages/cli/src/ui/hooks/shell-completions/gitProvider.ts
Normal file
93
packages/cli/src/ui/hooks/shell-completions/gitProvider.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import type { ShellCompletionProvider, CompletionResult } from './types.js';
|
||||
import { escapeShellPath } from '../useShellCompletion.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const GIT_SUBCOMMANDS = [
|
||||
'add',
|
||||
'branch',
|
||||
'checkout',
|
||||
'commit',
|
||||
'diff',
|
||||
'merge',
|
||||
'pull',
|
||||
'push',
|
||||
'rebase',
|
||||
'status',
|
||||
'switch',
|
||||
];
|
||||
|
||||
export const gitProvider: ShellCompletionProvider = {
|
||||
command: 'git',
|
||||
async getCompletions(
|
||||
tokens: string[],
|
||||
cursorIndex: number,
|
||||
cwd: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CompletionResult> {
|
||||
// We are completing the first argument (subcommand)
|
||||
if (cursorIndex === 1) {
|
||||
const partial = tokens[1] || '';
|
||||
return {
|
||||
suggestions: GIT_SUBCOMMANDS.filter((cmd) =>
|
||||
cmd.startsWith(partial),
|
||||
).map((cmd) => ({
|
||||
label: cmd,
|
||||
value: cmd,
|
||||
description: 'git command',
|
||||
})),
|
||||
exclusive: true,
|
||||
};
|
||||
}
|
||||
|
||||
// We are completing the second argument (e.g. branch name)
|
||||
if (cursorIndex === 2) {
|
||||
const subcommand = tokens[1];
|
||||
if (
|
||||
subcommand === 'checkout' ||
|
||||
subcommand === 'switch' ||
|
||||
subcommand === 'merge' ||
|
||||
subcommand === 'branch'
|
||||
) {
|
||||
const partial = tokens[2] || '';
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
'git',
|
||||
['branch', '--format=%(refname:short)'],
|
||||
{ cwd, signal },
|
||||
);
|
||||
|
||||
const branches = stdout
|
||||
.split('\n')
|
||||
.map((b) => b.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
return {
|
||||
suggestions: branches
|
||||
.filter((b) => b.startsWith(partial))
|
||||
.map((b) => ({
|
||||
label: b,
|
||||
value: escapeShellPath(b),
|
||||
description: 'branch',
|
||||
})),
|
||||
exclusive: true,
|
||||
};
|
||||
} catch {
|
||||
// If git fails (e.g. not a git repo), return nothing
|
||||
return { suggestions: [], exclusive: true };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Unhandled git argument, fallback to default file completions
|
||||
return { suggestions: [], exclusive: false };
|
||||
},
|
||||
};
|
||||
25
packages/cli/src/ui/hooks/shell-completions/index.ts
Normal file
25
packages/cli/src/ui/hooks/shell-completions/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { ShellCompletionProvider, CompletionResult } from './types.js';
|
||||
import { gitProvider } from './gitProvider.js';
|
||||
import { npmProvider } from './npmProvider.js';
|
||||
|
||||
const providers: ShellCompletionProvider[] = [gitProvider, npmProvider];
|
||||
|
||||
export async function getArgumentCompletions(
|
||||
commandToken: string,
|
||||
tokens: string[],
|
||||
cursorIndex: number,
|
||||
cwd: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CompletionResult | null> {
|
||||
const provider = providers.find((p) => p.command === commandToken);
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
return provider.getCompletions(tokens, cursorIndex, cwd, signal);
|
||||
}
|
||||
106
packages/cli/src/ui/hooks/shell-completions/npmProvider.test.ts
Normal file
106
packages/cli/src/ui/hooks/shell-completions/npmProvider.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { npmProvider } from './npmProvider.js';
|
||||
import * as fs from 'node:fs/promises';
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
readFile: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('npmProvider', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('suggests npm subcommands for cursorIndex 1', async () => {
|
||||
const result = await npmProvider.getCompletions(['npm', 'ru'], 1, '/tmp');
|
||||
|
||||
expect(result.exclusive).toBe(true);
|
||||
expect(result.suggestions).toEqual([
|
||||
expect.objectContaining({ value: 'run' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('suggests package.json scripts for npm run at cursorIndex 2', async () => {
|
||||
const mockPackageJson = {
|
||||
scripts: {
|
||||
start: 'node index.js',
|
||||
build: 'tsc',
|
||||
'build:dev': 'tsc --watch',
|
||||
},
|
||||
};
|
||||
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockPackageJson));
|
||||
|
||||
const result = await npmProvider.getCompletions(
|
||||
['npm', 'run', 'bu'],
|
||||
2,
|
||||
'/tmp',
|
||||
);
|
||||
|
||||
expect(result.exclusive).toBe(true);
|
||||
expect(result.suggestions).toHaveLength(2);
|
||||
expect(result.suggestions[0].label).toBe('build');
|
||||
expect(result.suggestions[0].value).toBe('build');
|
||||
expect(result.suggestions[1].label).toBe('build:dev');
|
||||
expect(result.suggestions[1].value).toBe('build:dev');
|
||||
expect(fs.readFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining('package.json'),
|
||||
'utf8',
|
||||
);
|
||||
});
|
||||
|
||||
it('escapes script names with shell metacharacters', async () => {
|
||||
const mockPackageJson = {
|
||||
scripts: {
|
||||
'build(prod)': 'tsc',
|
||||
'test:watch': 'vitest',
|
||||
},
|
||||
};
|
||||
vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify(mockPackageJson));
|
||||
|
||||
const result = await npmProvider.getCompletions(
|
||||
['npm', 'run', 'bu'],
|
||||
2,
|
||||
'/tmp',
|
||||
);
|
||||
|
||||
expect(result.exclusive).toBe(true);
|
||||
expect(result.suggestions).toHaveLength(1);
|
||||
expect(result.suggestions[0].label).toBe('build(prod)');
|
||||
|
||||
// Windows does not escape spaces/parens in cmds by default in our function, but Unix does.
|
||||
const isWin = process.platform === 'win32';
|
||||
expect(result.suggestions[0].value).toBe(
|
||||
isWin ? 'build(prod)' : 'build\\(prod\\)',
|
||||
);
|
||||
});
|
||||
|
||||
it('handles missing package.json gracefully', async () => {
|
||||
vi.mocked(fs.readFile).mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
const result = await npmProvider.getCompletions(
|
||||
['npm', 'run', ''],
|
||||
2,
|
||||
'/tmp',
|
||||
);
|
||||
|
||||
expect(result.exclusive).toBe(true);
|
||||
expect(result.suggestions).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('returns non-exclusive for unrecognized position', async () => {
|
||||
const result = await npmProvider.getCompletions(
|
||||
['npm', 'install', 'react'],
|
||||
2,
|
||||
'/tmp',
|
||||
);
|
||||
|
||||
expect(result.exclusive).toBe(false);
|
||||
expect(result.suggestions).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
81
packages/cli/src/ui/hooks/shell-completions/npmProvider.ts
Normal file
81
packages/cli/src/ui/hooks/shell-completions/npmProvider.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as path from 'node:path';
|
||||
import type { ShellCompletionProvider, CompletionResult } from './types.js';
|
||||
import { escapeShellPath } from '../useShellCompletion.js';
|
||||
|
||||
const NPM_SUBCOMMANDS = [
|
||||
'build',
|
||||
'ci',
|
||||
'dev',
|
||||
'install',
|
||||
'publish',
|
||||
'run',
|
||||
'start',
|
||||
'test',
|
||||
];
|
||||
|
||||
export const npmProvider: ShellCompletionProvider = {
|
||||
command: 'npm',
|
||||
async getCompletions(
|
||||
tokens: string[],
|
||||
cursorIndex: number,
|
||||
cwd: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CompletionResult> {
|
||||
if (cursorIndex === 1) {
|
||||
const partial = tokens[1] || '';
|
||||
return {
|
||||
suggestions: NPM_SUBCOMMANDS.filter((cmd) =>
|
||||
cmd.startsWith(partial),
|
||||
).map((cmd) => ({
|
||||
label: cmd,
|
||||
value: cmd,
|
||||
description: 'npm command',
|
||||
})),
|
||||
exclusive: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (cursorIndex === 2 && tokens[1] === 'run') {
|
||||
const partial = tokens[2] || '';
|
||||
try {
|
||||
if (signal?.aborted) return { suggestions: [], exclusive: true };
|
||||
|
||||
const pkgJsonPath = path.join(cwd, 'package.json');
|
||||
const content = await fs.readFile(pkgJsonPath, 'utf8');
|
||||
const pkg = JSON.parse(content) as unknown;
|
||||
|
||||
const scripts =
|
||||
pkg &&
|
||||
typeof pkg === 'object' &&
|
||||
'scripts' in pkg &&
|
||||
pkg.scripts &&
|
||||
typeof pkg.scripts === 'object'
|
||||
? Object.keys(pkg.scripts)
|
||||
: [];
|
||||
|
||||
return {
|
||||
suggestions: scripts
|
||||
.filter((s) => s.startsWith(partial))
|
||||
.map((s) => ({
|
||||
label: s,
|
||||
value: escapeShellPath(s),
|
||||
description: 'npm script',
|
||||
})),
|
||||
exclusive: true,
|
||||
};
|
||||
} catch {
|
||||
// No package.json or invalid JSON
|
||||
return { suggestions: [], exclusive: true };
|
||||
}
|
||||
}
|
||||
|
||||
return { suggestions: [], exclusive: false };
|
||||
},
|
||||
};
|
||||
24
packages/cli/src/ui/hooks/shell-completions/types.ts
Normal file
24
packages/cli/src/ui/hooks/shell-completions/types.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { Suggestion } from '../../components/SuggestionsDisplay.js';
|
||||
|
||||
export interface CompletionResult {
|
||||
suggestions: Suggestion[];
|
||||
// If true, this prevents the shell from appending generic file/path completions
|
||||
// to this list. Use this when the tool expects ONLY specific values (e.g. branches).
|
||||
exclusive?: boolean;
|
||||
}
|
||||
|
||||
export interface ShellCompletionProvider {
|
||||
command: string; // The command trigger, e.g., 'git' or 'npm'
|
||||
getCompletions(
|
||||
tokens: string[], // List of arguments parsed from the input
|
||||
cursorIndex: number, // Which token index the cursor is currently on
|
||||
cwd: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<CompletionResult>;
|
||||
}
|
||||
Reference in New Issue
Block a user