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);
+ }
+ },
+ [],
+ );
+}