feat(cli): add context-sensitive shell completions for git and npm (#2492)

This commit is contained in:
MD. MOHIBUR RAHMAN
2026-02-24 02:52:20 +06:00
parent 0c6ebbd1f5
commit 555b95eb09
9 changed files with 531 additions and 10 deletions
@@ -0,0 +1,93 @@
/**
* @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', () => ({
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,
stdout: string,
) => void;
callback(null, 'main\nfeature-branch\nfix/bug\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].value).toBe('feature-branch');
expect(childProcess.execFile).toHaveBeenCalledWith(
'git',
['branch', '--format=%(refname:short)'],
expect.any(Object),
expect.any(Function),
);
});
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);
});
});
@@ -0,0 +1,92 @@
/**
* @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';
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: 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 };
},
};
@@ -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);
}
@@ -0,0 +1,78 @@
/**
* @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].value).toBe('build');
expect(result.suggestions[1].value).toBe('build:dev');
expect(fs.readFile).toHaveBeenCalledWith(
expect.stringContaining('package.json'),
'utf8',
);
});
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);
});
});
@@ -0,0 +1,80 @@
/**
* @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';
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: s,
description: 'npm script',
})),
exclusive: true,
};
} catch {
// No package.json or invalid JSON
return { suggestions: [], exclusive: true };
}
}
return { suggestions: [], exclusive: false };
},
};
@@ -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>;
}
@@ -120,6 +120,9 @@ export function useCommandCompletion({
completionStart: tokenInfo.start,
completionEnd: tokenInfo.end,
shellTokenIsCommand: tokenInfo.isFirstToken,
shellTokens: tokenInfo.tokens,
shellCursorIndex: tokenInfo.cursorIndex,
shellCommandToken: tokenInfo.commandToken,
};
}
return {
@@ -128,6 +131,9 @@ export function useCommandCompletion({
completionStart: cursorCol,
completionEnd: cursorCol,
shellTokenIsCommand: currentLine.trim().length === 0,
shellTokens: [''],
shellCursorIndex: 0,
shellCommandToken: '',
};
}
@@ -168,6 +174,9 @@ export function useCommandCompletion({
completionStart: pathStart,
completionEnd: end,
shellTokenIsCommand: false,
shellTokens: [],
shellCursorIndex: -1,
shellCommandToken: '',
};
}
}
@@ -180,6 +189,9 @@ export function useCommandCompletion({
completionStart: 0,
completionEnd: currentLine.length,
shellTokenIsCommand: false,
shellTokens: [],
shellCursorIndex: -1,
shellCommandToken: '',
};
}
@@ -198,6 +210,9 @@ export function useCommandCompletion({
completionStart: 0,
completionEnd: trimmedText.length,
shellTokenIsCommand: false,
shellTokens: [],
shellCursorIndex: -1,
shellCommandToken: '',
};
}
@@ -207,6 +222,9 @@ export function useCommandCompletion({
completionStart: -1,
completionEnd: -1,
shellTokenIsCommand: false,
shellTokens: [],
shellCursorIndex: -1,
shellCommandToken: '',
};
}, [cursorRow, cursorCol, buffer.lines, buffer.text, shellModeActive]);
@@ -230,10 +248,29 @@ export function useCommandCompletion({
setIsPerfectMatch,
});
const { shellTokens, shellCursorIndex, shellCommandToken } = useMemo(() => {
if (!shellModeActive) {
return { shellTokens: [], shellCursorIndex: -1, shellCommandToken: '' };
}
const currentLine = buffer.lines[cursorRow] || '';
const tokenInfo = getTokenAtCursor(currentLine, cursorCol);
if (!tokenInfo) {
return { shellTokens: [''], shellCursorIndex: 0, shellCommandToken: '' };
}
return {
shellTokens: tokenInfo.tokens,
shellCursorIndex: tokenInfo.cursorIndex,
shellCommandToken: tokenInfo.commandToken,
};
}, [buffer.lines, cursorRow, cursorCol, shellModeActive]);
useShellCompletion({
enabled: active && completionMode === CompletionMode.SHELL,
query: query || '',
isCommandPosition: shellTokenIsCommand,
tokens: shellTokens,
cursorIndex: shellCursorIndex,
commandToken: shellCommandToken,
cwd,
setSuggestions,
setIsLoadingSuggestions,
@@ -16,8 +16,16 @@ import { createTmpDir, cleanupTmpDir } from '@google/gemini-cli-test-utils';
describe('useShellCompletion utilities', () => {
describe('getTokenAtCursor', () => {
it('should return null for empty line', () => {
expect(getTokenAtCursor('', 0)).toBeNull();
it('should return empty token struct for empty line', () => {
expect(getTokenAtCursor('', 0)).toEqual({
token: '',
start: 0,
end: 0,
isFirstToken: true,
tokens: [''],
cursorIndex: 0,
commandToken: '',
});
});
it('should extract the first token at cursor position 0', () => {
@@ -27,6 +35,9 @@ describe('useShellCompletion utilities', () => {
start: 0,
end: 3,
isFirstToken: true,
tokens: ['git', 'status'],
cursorIndex: 0,
commandToken: 'git',
});
});
@@ -37,6 +48,9 @@ describe('useShellCompletion utilities', () => {
start: 4,
end: 10,
isFirstToken: false,
tokens: ['git', 'status'],
cursorIndex: 1,
commandToken: 'git',
});
});
@@ -47,6 +61,9 @@ describe('useShellCompletion utilities', () => {
start: 4,
end: 10,
isFirstToken: false,
tokens: ['git', 'status'],
cursorIndex: 1,
commandToken: 'git',
});
});
@@ -57,6 +74,9 @@ describe('useShellCompletion utilities', () => {
start: 4,
end: 16,
isFirstToken: false,
tokens: ['cat', 'my file.txt'],
cursorIndex: 1,
commandToken: 'cat',
});
});
@@ -67,6 +87,9 @@ describe('useShellCompletion utilities', () => {
start: 4,
end: 17,
isFirstToken: false,
tokens: ['cat', 'my file.txt'],
cursorIndex: 1,
commandToken: 'cat',
});
});
@@ -77,6 +100,9 @@ describe('useShellCompletion utilities', () => {
start: 4,
end: 17,
isFirstToken: false,
tokens: ['cat', 'my file.txt'],
cursorIndex: 1,
commandToken: 'cat',
});
});
@@ -87,6 +113,9 @@ describe('useShellCompletion utilities', () => {
start: 4,
end: 4,
isFirstToken: false,
tokens: ['git', ''],
cursorIndex: 1,
commandToken: 'git',
});
});
@@ -97,6 +126,9 @@ describe('useShellCompletion utilities', () => {
start: 4,
end: 12,
isFirstToken: false,
tokens: ['git', 'checkout', 'main'],
cursorIndex: 1,
commandToken: 'git',
});
});
@@ -10,6 +10,7 @@ import * as path from 'node:path';
import * as os from 'node:os';
import type { Suggestion } from '../components/SuggestionsDisplay.js';
import { debugLogger } from '@google/gemini-cli-core';
import { getArgumentCompletions } from './shell-completions/index.js';
/**
* Maximum number of suggestions to return to avoid freezing the React Ink UI.
@@ -47,13 +48,19 @@ export interface TokenInfo {
end: number;
/** Whether this is the first token (command position). */
isFirstToken: boolean;
/** The fully built list of tokens parsing the string. */
tokens: string[];
/** The index in the tokens list where the cursor lies. */
cursorIndex: number;
/** The command token (always tokens[0] if length > 0, otherwise empty string) */
commandToken: string;
}
export function getTokenAtCursor(
line: string,
cursorCol: number,
): TokenInfo | null {
const tokens: Array<{ token: string; start: number; end: number }> = [];
const tokensInfo: Array<{ token: string; start: number; end: number }> = [];
let i = 0;
while (i < line.length) {
@@ -112,22 +119,36 @@ export function getTokenAtCursor(
i++;
}
tokens.push({ token, start: tokenStart, end: i });
tokensInfo.push({ token, start: tokenStart, end: i });
}
if (tokens.length === 0) {
return null;
const rawTokens = tokensInfo.map((t) => t.token);
const commandToken = rawTokens.length > 0 ? rawTokens[0] : '';
if (tokensInfo.length === 0) {
return {
token: '',
start: cursorCol,
end: cursorCol,
isFirstToken: true,
tokens: [''],
cursorIndex: 0,
commandToken: '',
};
}
// Find the token that contains or is immediately adjacent to the cursor
for (let idx = 0; idx < tokens.length; idx++) {
const t = tokens[idx];
for (let idx = 0; idx < tokensInfo.length; idx++) {
const t = tokensInfo[idx];
if (cursorCol >= t.start && cursorCol <= t.end) {
return {
token: t.token,
start: t.start,
end: t.end,
isFirstToken: idx === 0,
tokens: rawTokens,
cursorIndex: idx,
commandToken,
};
}
}
@@ -138,7 +159,10 @@ export function getTokenAtCursor(
token: '',
start: cursorCol,
end: cursorCol,
isFirstToken: tokens.length === 0,
isFirstToken: tokensInfo.length === 0,
tokens: [...rawTokens, ''],
cursorIndex: rawTokens.length,
commandToken,
};
}
@@ -323,6 +347,12 @@ export interface UseShellCompletionProps {
query: string;
/** Whether the token is in command position (first word). */
isCommandPosition: boolean;
/** The full list of parsed tokens */
tokens: string[];
/** The cursor index in the full list of parsed tokens */
cursorIndex: number;
/** The root command token */
commandToken: string;
/** The current working directory for path resolution. */
cwd: string;
/** Callback to set suggestions on the parent state. */
@@ -335,6 +365,9 @@ export function useShellCompletion({
enabled,
query,
isCommandPosition,
tokens,
cursorIndex,
commandToken,
cwd,
setSuggestions,
setIsLoadingSuggestions,
@@ -395,7 +428,31 @@ export function useShellCompletion({
description: 'command',
}));
} else {
results = await resolvePathCompletions(query, cwd, signal);
const argumentCompletions = await getArgumentCompletions(
commandToken,
tokens,
cursorIndex,
cwd,
signal,
);
if (signal.aborted) return;
if (argumentCompletions?.exclusive) {
results = argumentCompletions.suggestions;
} else {
const pathSuggestions = await resolvePathCompletions(
query,
cwd,
signal,
);
if (signal.aborted) return;
results = [
...(argumentCompletions?.suggestions ?? []),
...pathSuggestions,
].slice(0, MAX_SHELL_SUGGESTIONS);
}
}
if (signal.aborted) return;
@@ -424,6 +481,9 @@ export function useShellCompletion({
enabled,
query,
isCommandPosition,
tokens,
cursorIndex,
commandToken,
cwd,
setSuggestions,
setIsLoadingSuggestions,