diff --git a/packages/cli/src/ui/hooks/shell-completions/gitProvider.test.ts b/packages/cli/src/ui/hooks/shell-completions/gitProvider.test.ts new file mode 100644 index 0000000000..f1e9d22efa --- /dev/null +++ b/packages/cli/src/ui/hooks/shell-completions/gitProvider.test.ts @@ -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; + }, + ); + + 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; + }, + ); + + 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); + }); +}); diff --git a/packages/cli/src/ui/hooks/shell-completions/gitProvider.ts b/packages/cli/src/ui/hooks/shell-completions/gitProvider.ts new file mode 100644 index 0000000000..61864ed53d --- /dev/null +++ b/packages/cli/src/ui/hooks/shell-completions/gitProvider.ts @@ -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 { + // 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 }; + }, +}; diff --git a/packages/cli/src/ui/hooks/shell-completions/index.ts b/packages/cli/src/ui/hooks/shell-completions/index.ts new file mode 100644 index 0000000000..07b38abda6 --- /dev/null +++ b/packages/cli/src/ui/hooks/shell-completions/index.ts @@ -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 { + const provider = providers.find((p) => p.command === commandToken); + if (!provider) { + return null; + } + return provider.getCompletions(tokens, cursorIndex, cwd, signal); +} diff --git a/packages/cli/src/ui/hooks/shell-completions/npmProvider.test.ts b/packages/cli/src/ui/hooks/shell-completions/npmProvider.test.ts new file mode 100644 index 0000000000..1733b41601 --- /dev/null +++ b/packages/cli/src/ui/hooks/shell-completions/npmProvider.test.ts @@ -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); + }); +}); diff --git a/packages/cli/src/ui/hooks/shell-completions/npmProvider.ts b/packages/cli/src/ui/hooks/shell-completions/npmProvider.ts new file mode 100644 index 0000000000..a1a0a0a4b2 --- /dev/null +++ b/packages/cli/src/ui/hooks/shell-completions/npmProvider.ts @@ -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 { + 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 }; + }, +}; diff --git a/packages/cli/src/ui/hooks/shell-completions/types.ts b/packages/cli/src/ui/hooks/shell-completions/types.ts new file mode 100644 index 0000000000..df3900cf8f --- /dev/null +++ b/packages/cli/src/ui/hooks/shell-completions/types.ts @@ -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; +} diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.tsx b/packages/cli/src/ui/hooks/useCommandCompletion.tsx index c13834a537..573d0571fe 100644 --- a/packages/cli/src/ui/hooks/useCommandCompletion.tsx +++ b/packages/cli/src/ui/hooks/useCommandCompletion.tsx @@ -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, diff --git a/packages/cli/src/ui/hooks/useShellCompletion.test.ts b/packages/cli/src/ui/hooks/useShellCompletion.test.ts index e5e8f1e05c..ec4d7d0ff4 100644 --- a/packages/cli/src/ui/hooks/useShellCompletion.test.ts +++ b/packages/cli/src/ui/hooks/useShellCompletion.test.ts @@ -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', }); }); diff --git a/packages/cli/src/ui/hooks/useShellCompletion.ts b/packages/cli/src/ui/hooks/useShellCompletion.ts index 070d0a6046..d984de5e01 100644 --- a/packages/cli/src/ui/hooks/useShellCompletion.ts +++ b/packages/cli/src/ui/hooks/useShellCompletion.ts @@ -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,