From 8380f0a3b1b32d08cdfd050b8139aaeb03457ebc Mon Sep 17 00:00:00 2001 From: "MD. MOHIBUR RAHMAN" <35300157+mrpmohiburrahman@users.noreply.github.com> Date: Thu, 26 Feb 2026 13:49:11 +0600 Subject: [PATCH] feat(cli): implement interactive shell autocompletion (#20082) --- .../src/ui/components/InputPrompt.test.tsx | 67 +++ .../cli/src/ui/components/InputPrompt.tsx | 33 +- .../shell-completions/gitProvider.test.ts | 131 +++++ .../ui/hooks/shell-completions/gitProvider.ts | 93 +++ .../src/ui/hooks/shell-completions/index.ts | 25 + .../shell-completions/npmProvider.test.ts | 106 ++++ .../ui/hooks/shell-completions/npmProvider.ts | 81 +++ .../src/ui/hooks/shell-completions/types.ts | 24 + .../ui/hooks/useCommandCompletion.test.tsx | 14 + .../cli/src/ui/hooks/useCommandCompletion.tsx | 199 ++++--- .../src/ui/hooks/useShellCompletion.test.ts | 405 +++++++++++++ .../cli/src/ui/hooks/useShellCompletion.ts | 548 ++++++++++++++++++ 12 files changed, 1656 insertions(+), 70 deletions(-) create mode 100644 packages/cli/src/ui/hooks/shell-completions/gitProvider.test.ts create mode 100644 packages/cli/src/ui/hooks/shell-completions/gitProvider.ts create mode 100644 packages/cli/src/ui/hooks/shell-completions/index.ts create mode 100644 packages/cli/src/ui/hooks/shell-completions/npmProvider.test.ts create mode 100644 packages/cli/src/ui/hooks/shell-completions/npmProvider.ts create mode 100644 packages/cli/src/ui/hooks/shell-completions/types.ts create mode 100644 packages/cli/src/ui/hooks/useShellCompletion.test.ts create mode 100644 packages/cli/src/ui/hooks/useShellCompletion.ts diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index bf906d4a80..65a4440d77 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -411,6 +411,73 @@ describe('InputPrompt', () => { unmount(); }); + it('should submit command in shell mode when Enter pressed with suggestions visible but no arrow navigation', async () => { + props.shellModeActive = true; + props.buffer.setText('ls '); + + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, + showSuggestions: true, + suggestions: [ + { label: 'dir1', value: 'dir1' }, + { label: 'dir2', value: 'dir2' }, + ], + activeSuggestionIndex: 0, + }); + + const { stdin, unmount } = renderWithProviders(, { + uiActions, + }); + + // Press Enter without navigating — should dismiss suggestions and fall + // through to the main submit handler. + await act(async () => { + stdin.write('\r'); + }); + await waitFor(() => { + expect(mockCommandCompletion.resetCompletionState).toHaveBeenCalled(); + expect(props.onSubmit).toHaveBeenCalledWith('ls'); // Assert fall-through (text is trimmed) + }); + expect(mockCommandCompletion.handleAutocomplete).not.toHaveBeenCalled(); + unmount(); + }); + + it('should accept suggestion in shell mode when Enter pressed after arrow navigation', async () => { + props.shellModeActive = true; + props.buffer.setText('ls '); + + mockedUseCommandCompletion.mockReturnValue({ + ...mockCommandCompletion, + showSuggestions: true, + suggestions: [ + { label: 'dir1', value: 'dir1' }, + { label: 'dir2', value: 'dir2' }, + ], + activeSuggestionIndex: 1, + }); + + const { stdin, unmount } = renderWithProviders(, { + uiActions, + }); + + // Press ArrowDown to navigate, then Enter to accept + await act(async () => { + stdin.write('\u001B[B'); // ArrowDown — sets hasUserNavigatedSuggestions + }); + await waitFor(() => + expect(mockCommandCompletion.navigateDown).toHaveBeenCalled(), + ); + + await act(async () => { + stdin.write('\r'); // Enter — should accept navigated suggestion + }); + await waitFor(() => { + expect(mockCommandCompletion.handleAutocomplete).toHaveBeenCalledWith(1); + }); + expect(props.onSubmit).not.toHaveBeenCalled(); + unmount(); + }); + it('should NOT call shell history methods when not in shell mode', async () => { props.buffer.setText('some text'); const { stdin, unmount } = renderWithProviders(, { diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index ad84dd27f6..ac17284189 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -254,6 +254,7 @@ export const InputPrompt: React.FC = ({ >(null); const pasteTimeoutRef = useRef(null); const innerBoxRef = useRef(null); + const hasUserNavigatedSuggestions = useRef(false); const [reverseSearchActive, setReverseSearchActive] = useState(false); const [commandSearchActive, setCommandSearchActive] = useState(false); @@ -610,6 +611,7 @@ export const InputPrompt: React.FC = ({ setSuppressCompletion( isHistoryNav || isCursorMovement || keyMatchers[Command.ESCAPE](key), ); + hasUserNavigatedSuggestions.current = false; } // TODO(jacobr): this special case is likely not needed anymore. @@ -643,7 +645,13 @@ export const InputPrompt: React.FC = ({ Boolean(completion.promptCompletion.text) || reverseSearchActive || commandSearchActive; - if (isPlainTab) { + + if (isPlainTab && shellModeActive) { + resetPlainTabPress(); + if (!completion.showSuggestions) { + setSuppressCompletion(false); + } + } else if (isPlainTab) { if (!hasTabCompletionInteraction) { if (registerPlainTabPress() === 2) { toggleCleanUiDetailsVisible(); @@ -903,11 +911,13 @@ export const InputPrompt: React.FC = ({ if (completion.suggestions.length > 1) { if (keyMatchers[Command.COMPLETION_UP](key)) { completion.navigateUp(); + hasUserNavigatedSuggestions.current = true; setExpandedSuggestionIndex(-1); // Reset expansion when navigating return true; } if (keyMatchers[Command.COMPLETION_DOWN](key)) { completion.navigateDown(); + hasUserNavigatedSuggestions.current = true; setExpandedSuggestionIndex(-1); // Reset expansion when navigating return true; } @@ -925,6 +935,24 @@ export const InputPrompt: React.FC = ({ const isEnterKey = key.name === 'return' && !key.ctrl; + if (isEnterKey && shellModeActive) { + if (hasUserNavigatedSuggestions.current) { + completion.handleAutocomplete( + completion.activeSuggestionIndex, + ); + setExpandedSuggestionIndex(-1); + hasUserNavigatedSuggestions.current = false; + return true; + } + completion.resetCompletionState(); + setExpandedSuggestionIndex(-1); + hasUserNavigatedSuggestions.current = false; + if (buffer.text.trim()) { + handleSubmit(buffer.text); + } + return true; + } + if (isEnterKey && buffer.text.startsWith('/')) { const { isArgumentCompletion, leafCommand } = completion.slashCompletionRange; @@ -1381,7 +1409,8 @@ export const InputPrompt: React.FC = ({ scrollOffset={activeCompletion.visibleStartIndex} userInput={buffer.text} mode={ - completion.completionMode === CompletionMode.AT + completion.completionMode === CompletionMode.AT || + completion.completionMode === CompletionMode.SHELL ? 'reverse' : buffer.text.startsWith('/') && !reverseSearchActive && 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..f0d7cb4573 --- /dev/null +++ b/packages/cli/src/ui/hooks/shell-completions/gitProvider.test.ts @@ -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(); + 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; + }, + ); + + 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; + }, + ); + + 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; + }, + ); + + 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..7115718487 --- /dev/null +++ b/packages/cli/src/ui/hooks/shell-completions/gitProvider.ts @@ -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 { + // 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 }; + }, +}; 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..95e61b7015 --- /dev/null +++ b/packages/cli/src/ui/hooks/shell-completions/npmProvider.test.ts @@ -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); + }); +}); 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..32b88ca5ca --- /dev/null +++ b/packages/cli/src/ui/hooks/shell-completions/npmProvider.ts @@ -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 { + 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 }; + }, +}; 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.test.tsx b/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx index 2b0bad2743..c7bb2afb50 100644 --- a/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx +++ b/packages/cli/src/ui/hooks/useCommandCompletion.test.tsx @@ -40,6 +40,16 @@ vi.mock('./useSlashCompletion', () => ({ })), })); +vi.mock('./useShellCompletion', async () => { + const actual = await vi.importActual< + typeof import('./useShellCompletion.js') + >('./useShellCompletion'); + return { + ...actual, + useShellCompletion: vi.fn(), + }; +}); + // Helper to set up mocks in a consistent way for both child hooks const setupMocks = ({ atSuggestions = [], @@ -94,6 +104,7 @@ const setupMocks = ({ describe('useCommandCompletion', () => { const mockCommandContext = {} as CommandContext; const mockConfig = { + getEnablePromptCompletion: () => false, getGeminiClient: vi.fn(), } as unknown as Config; const testRootDir = '/'; @@ -498,6 +509,7 @@ describe('useCommandCompletion', () => { describe('prompt completion filtering', () => { it('should not trigger prompt completion for line comments', async () => { const mockConfig = { + getEnablePromptCompletion: () => true, getGeminiClient: vi.fn(), } as unknown as Config; @@ -530,6 +542,7 @@ describe('useCommandCompletion', () => { it('should not trigger prompt completion for block comments', async () => { const mockConfig = { + getEnablePromptCompletion: () => true, getGeminiClient: vi.fn(), } as unknown as Config; @@ -564,6 +577,7 @@ describe('useCommandCompletion', () => { it('should trigger prompt completion for regular text when enabled', async () => { const mockConfig = { + getEnablePromptCompletion: () => true, getGeminiClient: vi.fn(), } as unknown as Config; diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.tsx b/packages/cli/src/ui/hooks/useCommandCompletion.tsx index b9fcb95626..097a1e63b3 100644 --- a/packages/cli/src/ui/hooks/useCommandCompletion.tsx +++ b/packages/cli/src/ui/hooks/useCommandCompletion.tsx @@ -13,6 +13,7 @@ import { isSlashCommand } from '../utils/commandUtils.js'; import { toCodePoints } from '../utils/textUtils.js'; import { useAtCompletion } from './useAtCompletion.js'; import { useSlashCompletion } from './useSlashCompletion.js'; +import { useShellCompletion, getTokenAtCursor } from './useShellCompletion.js'; import type { PromptCompletion } from './usePromptCompletion.js'; import { usePromptCompletion, @@ -26,6 +27,7 @@ export enum CompletionMode { AT = 'AT', SLASH = 'SLASH', PROMPT = 'PROMPT', + SHELL = 'SHELL', } export interface UseCommandCompletionReturn { @@ -99,85 +101,135 @@ export function useCommandCompletion({ const cursorRow = buffer.cursor[0]; const cursorCol = buffer.cursor[1]; - const { completionMode, query, completionStart, completionEnd } = - useMemo(() => { - const currentLine = buffer.lines[cursorRow] || ''; - const codePoints = toCodePoints(currentLine); + const { + completionMode, + query, + completionStart, + completionEnd, + shellTokenIsCommand, + shellTokens, + shellCursorIndex, + shellCommandToken, + } = useMemo(() => { + const currentLine = buffer.lines[cursorRow] || ''; + const codePoints = toCodePoints(currentLine); - // FIRST: Check for @ completion (scan backwards from cursor) - // This must happen before slash command check so that `/cmd @file` - // triggers file completion, not just slash command completion. - for (let i = cursorCol - 1; i >= 0; i--) { - const char = codePoints[i]; + if (shellModeActive) { + const tokenInfo = getTokenAtCursor(currentLine, cursorCol); + if (tokenInfo) { + return { + completionMode: CompletionMode.SHELL, + query: tokenInfo.token, + completionStart: tokenInfo.start, + completionEnd: tokenInfo.end, + shellTokenIsCommand: tokenInfo.isFirstToken, + shellTokens: tokenInfo.tokens, + shellCursorIndex: tokenInfo.cursorIndex, + shellCommandToken: tokenInfo.commandToken, + }; + } + return { + completionMode: CompletionMode.SHELL, + query: '', + completionStart: cursorCol, + completionEnd: cursorCol, + shellTokenIsCommand: currentLine.trim().length === 0, + shellTokens: [''], + shellCursorIndex: 0, + shellCommandToken: '', + }; + } - if (char === ' ') { - let backslashCount = 0; - for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) { - backslashCount++; - } - if (backslashCount % 2 === 0) { - break; - } - } else if (char === '@') { - let end = codePoints.length; - for (let i = cursorCol; i < codePoints.length; i++) { - if (codePoints[i] === ' ') { - let backslashCount = 0; - for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) { - backslashCount++; - } + // FIRST: Check for @ completion (scan backwards from cursor) + // This must happen before slash command check so that `/cmd @file` + // triggers file completion, not just slash command completion. + for (let i = cursorCol - 1; i >= 0; i--) { + const char = codePoints[i]; - if (backslashCount % 2 === 0) { - end = i; - break; - } + if (char === ' ') { + let backslashCount = 0; + for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) { + backslashCount++; + } + if (backslashCount % 2 === 0) { + break; + } + } else if (char === '@') { + let end = codePoints.length; + for (let i = cursorCol; i < codePoints.length; i++) { + if (codePoints[i] === ' ') { + let backslashCount = 0; + for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) { + backslashCount++; + } + + if (backslashCount % 2 === 0) { + end = i; + break; } } - const pathStart = i + 1; - const partialPath = currentLine.substring(pathStart, end); - return { - completionMode: CompletionMode.AT, - query: partialPath, - completionStart: pathStart, - completionEnd: end, - }; } - } - - // THEN: Check for slash command (only if no @ completion is active) - if (cursorRow === 0 && isSlashCommand(currentLine.trim())) { + const pathStart = i + 1; + const partialPath = currentLine.substring(pathStart, end); return { - completionMode: CompletionMode.SLASH, - query: currentLine, - completionStart: 0, - completionEnd: currentLine.length, - }; - } - - // Check for prompt completion - only if enabled - const trimmedText = buffer.text.trim(); - const isPromptCompletionEnabled = false; - if ( - isPromptCompletionEnabled && - trimmedText.length >= PROMPT_COMPLETION_MIN_LENGTH && - !isSlashCommand(trimmedText) && - !trimmedText.includes('@') - ) { - return { - completionMode: CompletionMode.PROMPT, - query: trimmedText, - completionStart: 0, - completionEnd: trimmedText.length, + completionMode: CompletionMode.AT, + query: partialPath, + completionStart: pathStart, + completionEnd: end, + shellTokenIsCommand: false, + shellTokens: [], + shellCursorIndex: -1, + shellCommandToken: '', }; } + } + // THEN: Check for slash command (only if no @ completion is active) + if (cursorRow === 0 && isSlashCommand(currentLine.trim())) { return { - completionMode: CompletionMode.IDLE, - query: null, - completionStart: -1, - completionEnd: -1, + completionMode: CompletionMode.SLASH, + query: currentLine, + completionStart: 0, + completionEnd: currentLine.length, + shellTokenIsCommand: false, + shellTokens: [], + shellCursorIndex: -1, + shellCommandToken: '', }; - }, [cursorRow, cursorCol, buffer.lines, buffer.text]); + } + + // Check for prompt completion - only if enabled + const trimmedText = buffer.text.trim(); + const isPromptCompletionEnabled = false; + if ( + isPromptCompletionEnabled && + trimmedText.length >= PROMPT_COMPLETION_MIN_LENGTH && + !isSlashCommand(trimmedText) && + !trimmedText.includes('@') + ) { + return { + completionMode: CompletionMode.PROMPT, + query: trimmedText, + completionStart: 0, + completionEnd: trimmedText.length, + shellTokenIsCommand: false, + shellTokens: [], + shellCursorIndex: -1, + shellCommandToken: '', + }; + } + + return { + completionMode: CompletionMode.IDLE, + query: null, + completionStart: -1, + completionEnd: -1, + shellTokenIsCommand: false, + shellTokens: [], + shellCursorIndex: -1, + shellCommandToken: '', + }; + }, [cursorRow, cursorCol, buffer.lines, buffer.text, shellModeActive]); useAtCompletion({ enabled: active && completionMode === CompletionMode.AT, @@ -199,9 +251,20 @@ export function useCommandCompletion({ setIsPerfectMatch, }); + useShellCompletion({ + enabled: active && completionMode === CompletionMode.SHELL, + query: query || '', + isCommandPosition: shellTokenIsCommand, + tokens: shellTokens, + cursorIndex: shellCursorIndex, + commandToken: shellCommandToken, + cwd, + setSuggestions, + setIsLoadingSuggestions, + }); + const promptCompletion = usePromptCompletion({ buffer, - config, }); useEffect(() => { diff --git a/packages/cli/src/ui/hooks/useShellCompletion.test.ts b/packages/cli/src/ui/hooks/useShellCompletion.test.ts new file mode 100644 index 0000000000..477db3b184 --- /dev/null +++ b/packages/cli/src/ui/hooks/useShellCompletion.test.ts @@ -0,0 +1,405 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { + getTokenAtCursor, + escapeShellPath, + resolvePathCompletions, + scanPathExecutables, +} from './useShellCompletion.js'; +import type { FileSystemStructure } from '@google/gemini-cli-test-utils'; +import { createTmpDir, cleanupTmpDir } from '@google/gemini-cli-test-utils'; + +describe('useShellCompletion utilities', () => { + describe('getTokenAtCursor', () => { + 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', () => { + const result = getTokenAtCursor('git status', 3); + expect(result).toEqual({ + token: 'git', + start: 0, + end: 3, + isFirstToken: true, + tokens: ['git', 'status'], + cursorIndex: 0, + commandToken: 'git', + }); + }); + + it('should extract the second token when cursor is on it', () => { + const result = getTokenAtCursor('git status', 7); + expect(result).toEqual({ + token: 'status', + start: 4, + end: 10, + isFirstToken: false, + tokens: ['git', 'status'], + cursorIndex: 1, + commandToken: 'git', + }); + }); + + it('should handle cursor at start of second token', () => { + const result = getTokenAtCursor('git status', 4); + expect(result).toEqual({ + token: 'status', + start: 4, + end: 10, + isFirstToken: false, + tokens: ['git', 'status'], + cursorIndex: 1, + commandToken: 'git', + }); + }); + + it('should handle escaped spaces', () => { + const result = getTokenAtCursor('cat my\\ file.txt', 16); + expect(result).toEqual({ + token: 'my file.txt', + start: 4, + end: 16, + isFirstToken: false, + tokens: ['cat', 'my file.txt'], + cursorIndex: 1, + commandToken: 'cat', + }); + }); + + it('should handle single-quoted strings', () => { + const result = getTokenAtCursor("cat 'my file.txt'", 17); + expect(result).toEqual({ + token: 'my file.txt', + start: 4, + end: 17, + isFirstToken: false, + tokens: ['cat', 'my file.txt'], + cursorIndex: 1, + commandToken: 'cat', + }); + }); + + it('should handle double-quoted strings', () => { + const result = getTokenAtCursor('cat "my file.txt"', 17); + expect(result).toEqual({ + token: 'my file.txt', + start: 4, + end: 17, + isFirstToken: false, + tokens: ['cat', 'my file.txt'], + cursorIndex: 1, + commandToken: 'cat', + }); + }); + + it('should handle cursor past all tokens (trailing space)', () => { + const result = getTokenAtCursor('git ', 4); + expect(result).toEqual({ + token: '', + start: 4, + end: 4, + isFirstToken: false, + tokens: ['git', ''], + cursorIndex: 1, + commandToken: 'git', + }); + }); + + it('should handle cursor in the middle of a word', () => { + const result = getTokenAtCursor('git checkout main', 7); + expect(result).toEqual({ + token: 'checkout', + start: 4, + end: 12, + isFirstToken: false, + tokens: ['git', 'checkout', 'main'], + cursorIndex: 1, + commandToken: 'git', + }); + }); + + it('should mark isFirstToken correctly for first word', () => { + const result = getTokenAtCursor('gi', 2); + expect(result?.isFirstToken).toBe(true); + }); + + it('should mark isFirstToken correctly for second word', () => { + const result = getTokenAtCursor('git sta', 7); + expect(result?.isFirstToken).toBe(false); + }); + + it('should handle cursor in whitespace between tokens', () => { + const result = getTokenAtCursor('git status', 4); + expect(result).toEqual({ + token: '', + start: 4, + end: 4, + isFirstToken: false, + tokens: ['git', '', 'status'], + cursorIndex: 1, + commandToken: 'git', + }); + }); + }); + + describe('escapeShellPath', () => { + const isWin = process.platform === 'win32'; + + it('should escape spaces', () => { + expect(escapeShellPath('my file.txt')).toBe( + isWin ? 'my file.txt' : 'my\\ file.txt', + ); + }); + + it('should escape parentheses', () => { + expect(escapeShellPath('file (copy).txt')).toBe( + isWin ? 'file (copy).txt' : 'file\\ \\(copy\\).txt', + ); + }); + + it('should not escape normal characters', () => { + expect(escapeShellPath('normal-file.txt')).toBe('normal-file.txt'); + }); + + it('should escape tabs, newlines, carriage returns, and backslashes', () => { + if (isWin) { + expect(escapeShellPath('a\tb')).toBe('a\tb'); + expect(escapeShellPath('a\nb')).toBe('a\nb'); + expect(escapeShellPath('a\rb')).toBe('a\rb'); + expect(escapeShellPath('a\\b')).toBe('a\\b'); + } else { + expect(escapeShellPath('a\tb')).toBe('a\\\tb'); + expect(escapeShellPath('a\nb')).toBe('a\\\nb'); + expect(escapeShellPath('a\rb')).toBe('a\\\rb'); + expect(escapeShellPath('a\\b')).toBe('a\\\\b'); + } + }); + + it('should handle empty string', () => { + expect(escapeShellPath('')).toBe(''); + }); + }); + + describe('resolvePathCompletions', () => { + let tmpDir: string; + + afterEach(async () => { + if (tmpDir) { + await cleanupTmpDir(tmpDir); + } + }); + + it('should list directory contents for empty partial', async () => { + const structure: FileSystemStructure = { + 'file.txt': '', + subdir: {}, + }; + tmpDir = await createTmpDir(structure); + + const results = await resolvePathCompletions('', tmpDir); + const values = results.map((s) => s.label); + expect(values).toContain('subdir/'); + expect(values).toContain('file.txt'); + }); + + it('should filter by prefix', async () => { + const structure: FileSystemStructure = { + 'abc.txt': '', + 'def.txt': '', + }; + tmpDir = await createTmpDir(structure); + + const results = await resolvePathCompletions('a', tmpDir); + expect(results).toHaveLength(1); + expect(results[0].label).toBe('abc.txt'); + }); + + it('should match case-insensitively', async () => { + const structure: FileSystemStructure = { + Desktop: {}, + }; + tmpDir = await createTmpDir(structure); + + const results = await resolvePathCompletions('desk', tmpDir); + expect(results).toHaveLength(1); + expect(results[0].label).toBe('Desktop/'); + }); + + it('should append trailing slash to directories', async () => { + const structure: FileSystemStructure = { + mydir: {}, + 'myfile.txt': '', + }; + tmpDir = await createTmpDir(structure); + + const results = await resolvePathCompletions('my', tmpDir); + const dirSuggestion = results.find((s) => s.label.startsWith('mydir')); + expect(dirSuggestion?.label).toBe('mydir/'); + expect(dirSuggestion?.description).toBe('directory'); + }); + + it('should hide dotfiles by default', async () => { + const structure: FileSystemStructure = { + '.hidden': '', + visible: '', + }; + tmpDir = await createTmpDir(structure); + + const results = await resolvePathCompletions('', tmpDir); + const labels = results.map((s) => s.label); + expect(labels).not.toContain('.hidden'); + expect(labels).toContain('visible'); + }); + + it('should show dotfiles when query starts with a dot', async () => { + const structure: FileSystemStructure = { + '.hidden': '', + '.bashrc': '', + visible: '', + }; + tmpDir = await createTmpDir(structure); + + const results = await resolvePathCompletions('.h', tmpDir); + const labels = results.map((s) => s.label); + expect(labels).toContain('.hidden'); + }); + + it('should show dotfiles in the current directory when query is exactly "."', async () => { + const structure: FileSystemStructure = { + '.hidden': '', + '.bashrc': '', + visible: '', + }; + tmpDir = await createTmpDir(structure); + + const results = await resolvePathCompletions('.', tmpDir); + const labels = results.map((s) => s.label); + expect(labels).toContain('.hidden'); + expect(labels).toContain('.bashrc'); + expect(labels).not.toContain('visible'); + }); + + it('should handle dotfile completions within a subdirectory', async () => { + const structure: FileSystemStructure = { + subdir: { + '.secret': '', + 'public.txt': '', + }, + }; + tmpDir = await createTmpDir(structure); + + const results = await resolvePathCompletions('subdir/.', tmpDir); + const labels = results.map((s) => s.label); + expect(labels).toContain('.secret'); + expect(labels).not.toContain('public.txt'); + }); + + it('should strip leading quotes to resolve inner directory contents', async () => { + const structure: FileSystemStructure = { + src: { + 'index.ts': '', + }, + }; + tmpDir = await createTmpDir(structure); + + const results = await resolvePathCompletions('"src/', tmpDir); + expect(results).toHaveLength(1); + expect(results[0].label).toBe('index.ts'); + + const resultsSingleQuote = await resolvePathCompletions("'src/", tmpDir); + expect(resultsSingleQuote).toHaveLength(1); + expect(resultsSingleQuote[0].label).toBe('index.ts'); + }); + + it('should properly escape resolutions with spaces inside stripped quote queries', async () => { + const structure: FileSystemStructure = { + 'Folder With Spaces': {}, + }; + tmpDir = await createTmpDir(structure); + + const results = await resolvePathCompletions('"Fo', tmpDir); + expect(results).toHaveLength(1); + expect(results[0].label).toBe('Folder With Spaces/'); + expect(results[0].value).toBe(escapeShellPath('Folder With Spaces/')); + }); + + it('should return empty array for non-existent directory', async () => { + const results = await resolvePathCompletions( + '/nonexistent/path/foo', + '/tmp', + ); + expect(results).toEqual([]); + }); + + it('should handle tilde expansion', async () => { + // Just ensure ~ doesn't throw + const results = await resolvePathCompletions('~/', '/tmp'); + // We can't assert specific files since it depends on the test runner's home + expect(Array.isArray(results)).toBe(true); + }); + + it('should escape special characters in results', async () => { + const isWin = process.platform === 'win32'; + const structure: FileSystemStructure = { + 'my file.txt': '', + }; + tmpDir = await createTmpDir(structure); + + const results = await resolvePathCompletions('my', tmpDir); + expect(results).toHaveLength(1); + expect(results[0].value).toBe(isWin ? 'my file.txt' : 'my\\ file.txt'); + }); + + it('should sort directories before files', async () => { + const structure: FileSystemStructure = { + 'b-file.txt': '', + 'a-dir': {}, + }; + tmpDir = await createTmpDir(structure); + + const results = await resolvePathCompletions('', tmpDir); + expect(results[0].description).toBe('directory'); + expect(results[1].description).toBe('file'); + }); + }); + + describe('scanPathExecutables', () => { + it('should return an array of executables', async () => { + const results = await scanPathExecutables(); + expect(Array.isArray(results)).toBe(true); + // Very basic sanity check: common commands should be found + if (process.platform !== 'win32') { + expect(results).toContain('ls'); + } + }); + + it('should support abort signal', async () => { + const controller = new AbortController(); + controller.abort(); + const results = await scanPathExecutables(controller.signal); + // May return empty or partial depending on timing + expect(Array.isArray(results)).toBe(true); + }); + + it('should handle empty PATH', async () => { + vi.stubEnv('PATH', ''); + const results = await scanPathExecutables(); + expect(results).toEqual([]); + vi.unstubAllEnvs(); + }); + }); +}); diff --git a/packages/cli/src/ui/hooks/useShellCompletion.ts b/packages/cli/src/ui/hooks/useShellCompletion.ts new file mode 100644 index 0000000000..8569ab5cfb --- /dev/null +++ b/packages/cli/src/ui/hooks/useShellCompletion.ts @@ -0,0 +1,548 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useEffect, useRef, useCallback } from 'react'; +import * as fs from 'node:fs/promises'; +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. + */ +const MAX_SHELL_SUGGESTIONS = 100; + +/** + * Debounce interval (ms) for file system completions. + */ +const FS_COMPLETION_DEBOUNCE_MS = 50; + +// Backslash-quote shell metacharacters on non-Windows platforms. + +// On Unix, backslash-quote shell metacharacters (spaces, parens, etc.). +// On Windows, cmd.exe doesn't use backslash-quoting and `\` is the path +// separator, so we leave the path as-is. +const UNIX_SHELL_SPECIAL_CHARS = /[ \t\n\r'"()&|;<>!#$`{}[\]*?\\]/g; + +/** + * Escapes special shell characters in a path segment. + */ +export function escapeShellPath(segment: string): string { + if (process.platform === 'win32') { + return segment; + } + return segment.replace(UNIX_SHELL_SPECIAL_CHARS, '\\$&'); +} + +export interface TokenInfo { + /** The raw token text (without surrounding quotes but with internal escapes). */ + token: string; + /** Offset in the original line where this token begins. */ + start: number; + /** Offset in the original line where this token ends (exclusive). */ + 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 tokensInfo: Array<{ token: string; start: number; end: number }> = []; + let i = 0; + + while (i < line.length) { + // Skip whitespace + if (line[i] === ' ' || line[i] === '\t') { + i++; + continue; + } + + const tokenStart = i; + let token = ''; + + while (i < line.length) { + const ch = line[i]; + + // Backslash escape: consume the next char literally + if (ch === '\\' && i + 1 < line.length) { + token += line[i + 1]; + i += 2; + continue; + } + + // Single-quoted string + if (ch === "'") { + i++; // skip opening quote + while (i < line.length && line[i] !== "'") { + token += line[i]; + i++; + } + if (i < line.length) i++; // skip closing quote + continue; + } + + // Double-quoted string + if (ch === '"') { + i++; // skip opening quote + while (i < line.length && line[i] !== '"') { + if (line[i] === '\\' && i + 1 < line.length) { + token += line[i + 1]; + i += 2; + } else { + token += line[i]; + i++; + } + } + if (i < line.length) i++; // skip closing quote + continue; + } + + // Unquoted whitespace ends the token + if (ch === ' ' || ch === '\t') { + break; + } + + token += ch; + i++; + } + + tokensInfo.push({ token, start: tokenStart, end: i }); + } + + 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 < 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, + }; + } + } + + // Cursor is in whitespace between tokens, or at the start/end of the line. + // Find the appropriate insertion index for a new empty token. + let insertIndex = tokensInfo.length; + for (let idx = 0; idx < tokensInfo.length; idx++) { + if (cursorCol < tokensInfo[idx].start) { + insertIndex = idx; + break; + } + } + + const newTokens = [ + ...rawTokens.slice(0, insertIndex), + '', + ...rawTokens.slice(insertIndex), + ]; + + return { + token: '', + start: cursorCol, + end: cursorCol, + isFirstToken: insertIndex === 0, + tokens: newTokens, + cursorIndex: insertIndex, + commandToken: newTokens.length > 0 ? newTokens[0] : '', + }; +} + +export async function scanPathExecutables( + signal?: AbortSignal, +): Promise { + const pathEnv = process.env['PATH'] ?? ''; + const dirs = pathEnv.split(path.delimiter).filter(Boolean); + const isWindows = process.platform === 'win32'; + const pathExtList = isWindows + ? (process.env['PATHEXT'] ?? '.EXE;.CMD;.BAT;.COM') + .split(';') + .filter(Boolean) + .map((e) => e.toLowerCase()) + : []; + + const seen = new Set(); + const executables: string[] = []; + + const dirResults = await Promise.all( + dirs.map(async (dir) => { + if (signal?.aborted) return []; + try { + const entries = await fs.readdir(dir, { withFileTypes: true }); + const validEntries: string[] = []; + + // Check executability in parallel (batched per directory) + await Promise.all( + entries.map(async (entry) => { + if (signal?.aborted) return; + if (!entry.isFile() && !entry.isSymbolicLink()) return; + + const name = entry.name; + if (isWindows) { + const ext = path.extname(name).toLowerCase(); + if (pathExtList.length > 0 && !pathExtList.includes(ext)) return; + } + + try { + await fs.access( + path.join(dir, name), + fs.constants.R_OK | fs.constants.X_OK, + ); + validEntries.push(name); + } catch { + // Not executable — skip + } + }), + ); + + return validEntries; + } catch { + // EACCES, ENOENT, etc. — skip this directory + return []; + } + }), + ); + + for (const names of dirResults) { + for (const name of names) { + if (!seen.has(name)) { + seen.add(name); + executables.push(name); + } + } + } + + executables.sort(); + return executables; +} + +function expandTilde(inputPath: string): [string, boolean] { + if ( + inputPath === '~' || + inputPath.startsWith('~/') || + inputPath.startsWith('~' + path.sep) + ) { + return [path.join(os.homedir(), inputPath.slice(1)), true]; + } + return [inputPath, false]; +} + +export async function resolvePathCompletions( + partial: string, + cwd: string, + signal?: AbortSignal, +): Promise { + if (partial == null) return []; + + // Input Sanitization + let strippedPartial = partial; + if (strippedPartial.startsWith('"') || strippedPartial.startsWith("'")) { + strippedPartial = strippedPartial.slice(1); + } + if (strippedPartial.endsWith('"') || strippedPartial.endsWith("'")) { + strippedPartial = strippedPartial.slice(0, -1); + } + + // Normalize separators \ to / + const normalizedPartial = strippedPartial.replace(/\\/g, '/'); + + const [expandedPartial, didExpandTilde] = expandTilde(normalizedPartial); + + // Directory Detection + const endsWithSep = + normalizedPartial.endsWith('/') || normalizedPartial === ''; + const dirToRead = endsWithSep + ? path.resolve(cwd, expandedPartial) + : path.resolve(cwd, path.dirname(expandedPartial)); + + const prefix = endsWithSep ? '' : path.basename(expandedPartial); + const prefixLower = prefix.toLowerCase(); + + const showDotfiles = prefix.startsWith('.'); + + let entries: Array; + try { + if (signal?.aborted) return []; + entries = await fs.readdir(dirToRead, { withFileTypes: true }); + } catch { + // EACCES, ENOENT, etc. + return []; + } + + if (signal?.aborted) return []; + + const suggestions: Suggestion[] = []; + for (const entry of entries) { + if (signal?.aborted) break; + + const name = entry.name; + + // Hide dotfiles unless query starts with '.' + if (name.startsWith('.') && !showDotfiles) continue; + + // Case-insensitive matching + if (!name.toLowerCase().startsWith(prefixLower)) continue; + + const isDir = entry.isDirectory(); + const displayName = isDir ? name + '/' : name; + + // Build the completion value relative to what the user typed + let completionValue: string; + if (endsWithSep) { + completionValue = normalizedPartial + displayName; + } else { + const parentPart = normalizedPartial.slice( + 0, + normalizedPartial.length - path.basename(normalizedPartial).length, + ); + completionValue = parentPart + displayName; + } + + // Restore tilde if we expanded it + if (didExpandTilde) { + const homeDir = os.homedir().replace(/\\/g, '/'); + if (completionValue.startsWith(homeDir)) { + completionValue = '~' + completionValue.slice(homeDir.length); + } + } + + // Output formatting: Escape special characters in the completion value + // Since normalizedPartial stripped quotes, we escape the value directly. + const escapedValue = escapeShellPath(completionValue); + + suggestions.push({ + label: displayName, + value: escapedValue, + description: isDir ? 'directory' : 'file', + }); + + if (suggestions.length >= MAX_SHELL_SUGGESTIONS) break; + } + + // Sort: directories first, then alphabetically + suggestions.sort((a, b) => { + const aIsDir = a.description === 'directory'; + const bIsDir = b.description === 'directory'; + if (aIsDir !== bIsDir) return aIsDir ? -1 : 1; + return a.label.localeCompare(b.label); + }); + + return suggestions; +} + +export interface UseShellCompletionProps { + /** Whether shell completion is active. */ + enabled: boolean; + /** The partial query string (the token under the cursor). */ + 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. */ + setSuggestions: (suggestions: Suggestion[]) => void; + /** Callback to set loading state on the parent. */ + setIsLoadingSuggestions: (isLoading: boolean) => void; +} + +export function useShellCompletion({ + enabled, + query, + isCommandPosition, + tokens, + cursorIndex, + commandToken, + cwd, + setSuggestions, + setIsLoadingSuggestions, +}: UseShellCompletionProps): void { + const pathCacheRef = useRef(null); + const pathEnvRef = useRef(process.env['PATH'] ?? ''); + const abortRef = useRef(null); + const debounceRef = useRef(null); + + // Invalidate PATH cache when $PATH changes + useEffect(() => { + const currentPath = process.env['PATH'] ?? ''; + if (currentPath !== pathEnvRef.current) { + pathCacheRef.current = null; + pathEnvRef.current = currentPath; + } + }); + + const performCompletion = useCallback(async () => { + if (!enabled) { + setSuggestions([]); + return; + } + + // Skip flags + if (query.startsWith('-')) { + setSuggestions([]); + return; + } + + // Cancel any in-flight request + if (abortRef.current) { + abortRef.current.abort(); + } + const controller = new AbortController(); + abortRef.current = controller; + const { signal } = controller; + + try { + let results: Suggestion[]; + + if (isCommandPosition) { + setIsLoadingSuggestions(true); + + if (!pathCacheRef.current) { + pathCacheRef.current = await scanPathExecutables(signal); + } + + if (signal.aborted) return; + + const queryLower = query.toLowerCase(); + results = pathCacheRef.current + .filter((cmd) => cmd.toLowerCase().startsWith(queryLower)) + .slice(0, MAX_SHELL_SUGGESTIONS) + .map((cmd) => ({ + label: cmd, + value: escapeShellPath(cmd), + description: 'command', + })); + } else { + 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; + + setSuggestions(results); + } catch (error) { + if ( + !( + signal.aborted || + (error instanceof Error && error.name === 'AbortError') + ) + ) { + debugLogger.warn( + `[WARN] shell completion failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + if (!signal.aborted) { + setSuggestions([]); + } + } finally { + if (!signal.aborted) { + setIsLoadingSuggestions(false); + } + } + }, [ + enabled, + query, + isCommandPosition, + tokens, + cursorIndex, + commandToken, + cwd, + setSuggestions, + setIsLoadingSuggestions, + ]); + + // Debounced effect to trigger completion + useEffect(() => { + if (!enabled) { + setSuggestions([]); + setIsLoadingSuggestions(false); + return; + } + + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + + debounceRef.current = setTimeout(() => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + performCompletion(); + }, FS_COMPLETION_DEBOUNCE_MS); + + return () => { + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + }; + }, [enabled, performCompletion, setSuggestions, setIsLoadingSuggestions]); + + // Cleanup on unmount + useEffect( + () => () => { + abortRef.current?.abort(); + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + }, + [], + ); +}