mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-05-01 07:24:38 -07:00
feat(cli): implement interactive shell autocompletion (#20082)
This commit is contained in:
committed by
GitHub
parent
ef247e220d
commit
8380f0a3b1
@@ -411,6 +411,73 @@ describe('InputPrompt', () => {
|
|||||||
unmount();
|
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(<InputPrompt {...props} />, {
|
||||||
|
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(<InputPrompt {...props} />, {
|
||||||
|
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 () => {
|
it('should NOT call shell history methods when not in shell mode', async () => {
|
||||||
props.buffer.setText('some text');
|
props.buffer.setText('some text');
|
||||||
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
|
const { stdin, unmount } = renderWithProviders(<InputPrompt {...props} />, {
|
||||||
|
|||||||
@@ -254,6 +254,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
>(null);
|
>(null);
|
||||||
const pasteTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const pasteTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const innerBoxRef = useRef<DOMElement>(null);
|
const innerBoxRef = useRef<DOMElement>(null);
|
||||||
|
const hasUserNavigatedSuggestions = useRef(false);
|
||||||
|
|
||||||
const [reverseSearchActive, setReverseSearchActive] = useState(false);
|
const [reverseSearchActive, setReverseSearchActive] = useState(false);
|
||||||
const [commandSearchActive, setCommandSearchActive] = useState(false);
|
const [commandSearchActive, setCommandSearchActive] = useState(false);
|
||||||
@@ -610,6 +611,7 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
setSuppressCompletion(
|
setSuppressCompletion(
|
||||||
isHistoryNav || isCursorMovement || keyMatchers[Command.ESCAPE](key),
|
isHistoryNav || isCursorMovement || keyMatchers[Command.ESCAPE](key),
|
||||||
);
|
);
|
||||||
|
hasUserNavigatedSuggestions.current = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO(jacobr): this special case is likely not needed anymore.
|
// TODO(jacobr): this special case is likely not needed anymore.
|
||||||
@@ -643,7 +645,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
Boolean(completion.promptCompletion.text) ||
|
Boolean(completion.promptCompletion.text) ||
|
||||||
reverseSearchActive ||
|
reverseSearchActive ||
|
||||||
commandSearchActive;
|
commandSearchActive;
|
||||||
if (isPlainTab) {
|
|
||||||
|
if (isPlainTab && shellModeActive) {
|
||||||
|
resetPlainTabPress();
|
||||||
|
if (!completion.showSuggestions) {
|
||||||
|
setSuppressCompletion(false);
|
||||||
|
}
|
||||||
|
} else if (isPlainTab) {
|
||||||
if (!hasTabCompletionInteraction) {
|
if (!hasTabCompletionInteraction) {
|
||||||
if (registerPlainTabPress() === 2) {
|
if (registerPlainTabPress() === 2) {
|
||||||
toggleCleanUiDetailsVisible();
|
toggleCleanUiDetailsVisible();
|
||||||
@@ -903,11 +911,13 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
if (completion.suggestions.length > 1) {
|
if (completion.suggestions.length > 1) {
|
||||||
if (keyMatchers[Command.COMPLETION_UP](key)) {
|
if (keyMatchers[Command.COMPLETION_UP](key)) {
|
||||||
completion.navigateUp();
|
completion.navigateUp();
|
||||||
|
hasUserNavigatedSuggestions.current = true;
|
||||||
setExpandedSuggestionIndex(-1); // Reset expansion when navigating
|
setExpandedSuggestionIndex(-1); // Reset expansion when navigating
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (keyMatchers[Command.COMPLETION_DOWN](key)) {
|
if (keyMatchers[Command.COMPLETION_DOWN](key)) {
|
||||||
completion.navigateDown();
|
completion.navigateDown();
|
||||||
|
hasUserNavigatedSuggestions.current = true;
|
||||||
setExpandedSuggestionIndex(-1); // Reset expansion when navigating
|
setExpandedSuggestionIndex(-1); // Reset expansion when navigating
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -925,6 +935,24 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
|
|
||||||
const isEnterKey = key.name === 'return' && !key.ctrl;
|
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('/')) {
|
if (isEnterKey && buffer.text.startsWith('/')) {
|
||||||
const { isArgumentCompletion, leafCommand } =
|
const { isArgumentCompletion, leafCommand } =
|
||||||
completion.slashCompletionRange;
|
completion.slashCompletionRange;
|
||||||
@@ -1381,7 +1409,8 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
|
|||||||
scrollOffset={activeCompletion.visibleStartIndex}
|
scrollOffset={activeCompletion.visibleStartIndex}
|
||||||
userInput={buffer.text}
|
userInput={buffer.text}
|
||||||
mode={
|
mode={
|
||||||
completion.completionMode === CompletionMode.AT
|
completion.completionMode === CompletionMode.AT ||
|
||||||
|
completion.completionMode === CompletionMode.SHELL
|
||||||
? 'reverse'
|
? 'reverse'
|
||||||
: buffer.text.startsWith('/') &&
|
: buffer.text.startsWith('/') &&
|
||||||
!reverseSearchActive &&
|
!reverseSearchActive &&
|
||||||
|
|||||||
@@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { gitProvider } from './gitProvider.js';
|
||||||
|
import * as childProcess from 'node:child_process';
|
||||||
|
|
||||||
|
vi.mock('node:child_process', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('node:child_process')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
execFile: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('gitProvider', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suggests git subcommands for cursorIndex 1', async () => {
|
||||||
|
const result = await gitProvider.getCompletions(['git', 'ch'], 1, '/tmp');
|
||||||
|
|
||||||
|
expect(result.exclusive).toBe(true);
|
||||||
|
expect(result.suggestions).toEqual(
|
||||||
|
expect.arrayContaining([expect.objectContaining({ value: 'checkout' })]),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
result.suggestions.find((s) => s.value === 'commit'),
|
||||||
|
).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('suggests branch names for checkout at cursorIndex 2', async () => {
|
||||||
|
vi.mocked(childProcess.execFile).mockImplementation(
|
||||||
|
(_cmd, _args, _opts, cb: unknown) => {
|
||||||
|
const callback = (typeof _opts === 'function' ? _opts : cb) as (
|
||||||
|
error: Error | null,
|
||||||
|
result: { stdout: string },
|
||||||
|
) => void;
|
||||||
|
callback(null, {
|
||||||
|
stdout: 'main\nfeature-branch\nfix/bug\nbranch(with)special\n',
|
||||||
|
});
|
||||||
|
return {} as ReturnType<typeof childProcess.execFile>;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await gitProvider.getCompletions(
|
||||||
|
['git', 'checkout', 'feat'],
|
||||||
|
2,
|
||||||
|
'/tmp',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.exclusive).toBe(true);
|
||||||
|
expect(result.suggestions).toHaveLength(1);
|
||||||
|
expect(result.suggestions[0].label).toBe('feature-branch');
|
||||||
|
expect(result.suggestions[0].value).toBe('feature-branch');
|
||||||
|
expect(childProcess.execFile).toHaveBeenCalledWith(
|
||||||
|
'git',
|
||||||
|
['branch', '--format=%(refname:short)'],
|
||||||
|
expect.any(Object),
|
||||||
|
expect.any(Function),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('escapes branch names with shell metacharacters', async () => {
|
||||||
|
vi.mocked(childProcess.execFile).mockImplementation(
|
||||||
|
(_cmd, _args, _opts, cb: unknown) => {
|
||||||
|
const callback = (typeof _opts === 'function' ? _opts : cb) as (
|
||||||
|
error: Error | null,
|
||||||
|
result: { stdout: string },
|
||||||
|
) => void;
|
||||||
|
callback(null, { stdout: 'main\nbranch(with)special\n' });
|
||||||
|
return {} as ReturnType<typeof childProcess.execFile>;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await gitProvider.getCompletions(
|
||||||
|
['git', 'checkout', 'branch('],
|
||||||
|
2,
|
||||||
|
'/tmp',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.exclusive).toBe(true);
|
||||||
|
expect(result.suggestions).toHaveLength(1);
|
||||||
|
expect(result.suggestions[0].label).toBe('branch(with)special');
|
||||||
|
|
||||||
|
// On Windows, space escape is not done. But since UNIX_SHELL_SPECIAL_CHARS is mostly tested,
|
||||||
|
// we can use a matcher that checks if escaping was applied (it differs per platform but that's handled by escapeShellPath).
|
||||||
|
// Let's match the value against either unescaped (win) or escaped (unix).
|
||||||
|
const isWin = process.platform === 'win32';
|
||||||
|
expect(result.suggestions[0].value).toBe(
|
||||||
|
isWin ? 'branch(with)special' : 'branch\\(with\\)special',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty results if git branch fails', async () => {
|
||||||
|
vi.mocked(childProcess.execFile).mockImplementation(
|
||||||
|
(_cmd, _args, _opts, cb: unknown) => {
|
||||||
|
const callback = (typeof _opts === 'function' ? _opts : cb) as (
|
||||||
|
error: Error,
|
||||||
|
stdout?: string,
|
||||||
|
) => void;
|
||||||
|
callback(new Error('Not a git repository'));
|
||||||
|
return {} as ReturnType<typeof childProcess.execFile>;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await gitProvider.getCompletions(
|
||||||
|
['git', 'checkout', ''],
|
||||||
|
2,
|
||||||
|
'/tmp',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.exclusive).toBe(true);
|
||||||
|
expect(result.suggestions).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns non-exclusive for unrecognized position', async () => {
|
||||||
|
const result = await gitProvider.getCompletions(
|
||||||
|
['git', 'commit', '-m', 'some message'],
|
||||||
|
3,
|
||||||
|
'/tmp',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.exclusive).toBe(false);
|
||||||
|
expect(result.suggestions).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFile } from 'node:child_process';
|
||||||
|
import { promisify } from 'node:util';
|
||||||
|
import type { ShellCompletionProvider, CompletionResult } from './types.js';
|
||||||
|
import { escapeShellPath } from '../useShellCompletion.js';
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
|
const GIT_SUBCOMMANDS = [
|
||||||
|
'add',
|
||||||
|
'branch',
|
||||||
|
'checkout',
|
||||||
|
'commit',
|
||||||
|
'diff',
|
||||||
|
'merge',
|
||||||
|
'pull',
|
||||||
|
'push',
|
||||||
|
'rebase',
|
||||||
|
'status',
|
||||||
|
'switch',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const gitProvider: ShellCompletionProvider = {
|
||||||
|
command: 'git',
|
||||||
|
async getCompletions(
|
||||||
|
tokens: string[],
|
||||||
|
cursorIndex: number,
|
||||||
|
cwd: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<CompletionResult> {
|
||||||
|
// We are completing the first argument (subcommand)
|
||||||
|
if (cursorIndex === 1) {
|
||||||
|
const partial = tokens[1] || '';
|
||||||
|
return {
|
||||||
|
suggestions: GIT_SUBCOMMANDS.filter((cmd) =>
|
||||||
|
cmd.startsWith(partial),
|
||||||
|
).map((cmd) => ({
|
||||||
|
label: cmd,
|
||||||
|
value: cmd,
|
||||||
|
description: 'git command',
|
||||||
|
})),
|
||||||
|
exclusive: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// We are completing the second argument (e.g. branch name)
|
||||||
|
if (cursorIndex === 2) {
|
||||||
|
const subcommand = tokens[1];
|
||||||
|
if (
|
||||||
|
subcommand === 'checkout' ||
|
||||||
|
subcommand === 'switch' ||
|
||||||
|
subcommand === 'merge' ||
|
||||||
|
subcommand === 'branch'
|
||||||
|
) {
|
||||||
|
const partial = tokens[2] || '';
|
||||||
|
try {
|
||||||
|
const { stdout } = await execFileAsync(
|
||||||
|
'git',
|
||||||
|
['branch', '--format=%(refname:short)'],
|
||||||
|
{ cwd, signal },
|
||||||
|
);
|
||||||
|
|
||||||
|
const branches = stdout
|
||||||
|
.split('\n')
|
||||||
|
.map((b) => b.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
return {
|
||||||
|
suggestions: branches
|
||||||
|
.filter((b) => b.startsWith(partial))
|
||||||
|
.map((b) => ({
|
||||||
|
label: b,
|
||||||
|
value: escapeShellPath(b),
|
||||||
|
description: 'branch',
|
||||||
|
})),
|
||||||
|
exclusive: true,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// If git fails (e.g. not a git repo), return nothing
|
||||||
|
return { suggestions: [], exclusive: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unhandled git argument, fallback to default file completions
|
||||||
|
return { suggestions: [], exclusive: false };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { ShellCompletionProvider, CompletionResult } from './types.js';
|
||||||
|
import { gitProvider } from './gitProvider.js';
|
||||||
|
import { npmProvider } from './npmProvider.js';
|
||||||
|
|
||||||
|
const providers: ShellCompletionProvider[] = [gitProvider, npmProvider];
|
||||||
|
|
||||||
|
export async function getArgumentCompletions(
|
||||||
|
commandToken: string,
|
||||||
|
tokens: string[],
|
||||||
|
cursorIndex: number,
|
||||||
|
cwd: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<CompletionResult | null> {
|
||||||
|
const provider = providers.find((p) => p.command === commandToken);
|
||||||
|
if (!provider) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return provider.getCompletions(tokens, cursorIndex, cwd, signal);
|
||||||
|
}
|
||||||
@@ -0,0 +1,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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from 'node:fs/promises';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import type { ShellCompletionProvider, CompletionResult } from './types.js';
|
||||||
|
import { escapeShellPath } from '../useShellCompletion.js';
|
||||||
|
|
||||||
|
const NPM_SUBCOMMANDS = [
|
||||||
|
'build',
|
||||||
|
'ci',
|
||||||
|
'dev',
|
||||||
|
'install',
|
||||||
|
'publish',
|
||||||
|
'run',
|
||||||
|
'start',
|
||||||
|
'test',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const npmProvider: ShellCompletionProvider = {
|
||||||
|
command: 'npm',
|
||||||
|
async getCompletions(
|
||||||
|
tokens: string[],
|
||||||
|
cursorIndex: number,
|
||||||
|
cwd: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<CompletionResult> {
|
||||||
|
if (cursorIndex === 1) {
|
||||||
|
const partial = tokens[1] || '';
|
||||||
|
return {
|
||||||
|
suggestions: NPM_SUBCOMMANDS.filter((cmd) =>
|
||||||
|
cmd.startsWith(partial),
|
||||||
|
).map((cmd) => ({
|
||||||
|
label: cmd,
|
||||||
|
value: cmd,
|
||||||
|
description: 'npm command',
|
||||||
|
})),
|
||||||
|
exclusive: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cursorIndex === 2 && tokens[1] === 'run') {
|
||||||
|
const partial = tokens[2] || '';
|
||||||
|
try {
|
||||||
|
if (signal?.aborted) return { suggestions: [], exclusive: true };
|
||||||
|
|
||||||
|
const pkgJsonPath = path.join(cwd, 'package.json');
|
||||||
|
const content = await fs.readFile(pkgJsonPath, 'utf8');
|
||||||
|
const pkg = JSON.parse(content) as unknown;
|
||||||
|
|
||||||
|
const scripts =
|
||||||
|
pkg &&
|
||||||
|
typeof pkg === 'object' &&
|
||||||
|
'scripts' in pkg &&
|
||||||
|
pkg.scripts &&
|
||||||
|
typeof pkg.scripts === 'object'
|
||||||
|
? Object.keys(pkg.scripts)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
suggestions: scripts
|
||||||
|
.filter((s) => s.startsWith(partial))
|
||||||
|
.map((s) => ({
|
||||||
|
label: s,
|
||||||
|
value: escapeShellPath(s),
|
||||||
|
description: 'npm script',
|
||||||
|
})),
|
||||||
|
exclusive: true,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// No package.json or invalid JSON
|
||||||
|
return { suggestions: [], exclusive: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { suggestions: [], exclusive: false };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* @license
|
||||||
|
* Copyright 2026 Google LLC
|
||||||
|
* SPDX-License-Identifier: Apache-2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { Suggestion } from '../../components/SuggestionsDisplay.js';
|
||||||
|
|
||||||
|
export interface CompletionResult {
|
||||||
|
suggestions: Suggestion[];
|
||||||
|
// If true, this prevents the shell from appending generic file/path completions
|
||||||
|
// to this list. Use this when the tool expects ONLY specific values (e.g. branches).
|
||||||
|
exclusive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShellCompletionProvider {
|
||||||
|
command: string; // The command trigger, e.g., 'git' or 'npm'
|
||||||
|
getCompletions(
|
||||||
|
tokens: string[], // List of arguments parsed from the input
|
||||||
|
cursorIndex: number, // Which token index the cursor is currently on
|
||||||
|
cwd: string,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<CompletionResult>;
|
||||||
|
}
|
||||||
@@ -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
|
// Helper to set up mocks in a consistent way for both child hooks
|
||||||
const setupMocks = ({
|
const setupMocks = ({
|
||||||
atSuggestions = [],
|
atSuggestions = [],
|
||||||
@@ -94,6 +104,7 @@ const setupMocks = ({
|
|||||||
describe('useCommandCompletion', () => {
|
describe('useCommandCompletion', () => {
|
||||||
const mockCommandContext = {} as CommandContext;
|
const mockCommandContext = {} as CommandContext;
|
||||||
const mockConfig = {
|
const mockConfig = {
|
||||||
|
getEnablePromptCompletion: () => false,
|
||||||
getGeminiClient: vi.fn(),
|
getGeminiClient: vi.fn(),
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
const testRootDir = '/';
|
const testRootDir = '/';
|
||||||
@@ -498,6 +509,7 @@ describe('useCommandCompletion', () => {
|
|||||||
describe('prompt completion filtering', () => {
|
describe('prompt completion filtering', () => {
|
||||||
it('should not trigger prompt completion for line comments', async () => {
|
it('should not trigger prompt completion for line comments', async () => {
|
||||||
const mockConfig = {
|
const mockConfig = {
|
||||||
|
getEnablePromptCompletion: () => true,
|
||||||
getGeminiClient: vi.fn(),
|
getGeminiClient: vi.fn(),
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
|
|
||||||
@@ -530,6 +542,7 @@ describe('useCommandCompletion', () => {
|
|||||||
|
|
||||||
it('should not trigger prompt completion for block comments', async () => {
|
it('should not trigger prompt completion for block comments', async () => {
|
||||||
const mockConfig = {
|
const mockConfig = {
|
||||||
|
getEnablePromptCompletion: () => true,
|
||||||
getGeminiClient: vi.fn(),
|
getGeminiClient: vi.fn(),
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
|
|
||||||
@@ -564,6 +577,7 @@ describe('useCommandCompletion', () => {
|
|||||||
|
|
||||||
it('should trigger prompt completion for regular text when enabled', async () => {
|
it('should trigger prompt completion for regular text when enabled', async () => {
|
||||||
const mockConfig = {
|
const mockConfig = {
|
||||||
|
getEnablePromptCompletion: () => true,
|
||||||
getGeminiClient: vi.fn(),
|
getGeminiClient: vi.fn(),
|
||||||
} as unknown as Config;
|
} as unknown as Config;
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { isSlashCommand } from '../utils/commandUtils.js';
|
|||||||
import { toCodePoints } from '../utils/textUtils.js';
|
import { toCodePoints } from '../utils/textUtils.js';
|
||||||
import { useAtCompletion } from './useAtCompletion.js';
|
import { useAtCompletion } from './useAtCompletion.js';
|
||||||
import { useSlashCompletion } from './useSlashCompletion.js';
|
import { useSlashCompletion } from './useSlashCompletion.js';
|
||||||
|
import { useShellCompletion, getTokenAtCursor } from './useShellCompletion.js';
|
||||||
import type { PromptCompletion } from './usePromptCompletion.js';
|
import type { PromptCompletion } from './usePromptCompletion.js';
|
||||||
import {
|
import {
|
||||||
usePromptCompletion,
|
usePromptCompletion,
|
||||||
@@ -26,6 +27,7 @@ export enum CompletionMode {
|
|||||||
AT = 'AT',
|
AT = 'AT',
|
||||||
SLASH = 'SLASH',
|
SLASH = 'SLASH',
|
||||||
PROMPT = 'PROMPT',
|
PROMPT = 'PROMPT',
|
||||||
|
SHELL = 'SHELL',
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UseCommandCompletionReturn {
|
export interface UseCommandCompletionReturn {
|
||||||
@@ -99,85 +101,135 @@ export function useCommandCompletion({
|
|||||||
const cursorRow = buffer.cursor[0];
|
const cursorRow = buffer.cursor[0];
|
||||||
const cursorCol = buffer.cursor[1];
|
const cursorCol = buffer.cursor[1];
|
||||||
|
|
||||||
const { completionMode, query, completionStart, completionEnd } =
|
const {
|
||||||
useMemo(() => {
|
completionMode,
|
||||||
const currentLine = buffer.lines[cursorRow] || '';
|
query,
|
||||||
const codePoints = toCodePoints(currentLine);
|
completionStart,
|
||||||
|
completionEnd,
|
||||||
|
shellTokenIsCommand,
|
||||||
|
shellTokens,
|
||||||
|
shellCursorIndex,
|
||||||
|
shellCommandToken,
|
||||||
|
} = useMemo(() => {
|
||||||
|
const currentLine = buffer.lines[cursorRow] || '';
|
||||||
|
const codePoints = toCodePoints(currentLine);
|
||||||
|
|
||||||
// FIRST: Check for @ completion (scan backwards from cursor)
|
if (shellModeActive) {
|
||||||
// This must happen before slash command check so that `/cmd @file`
|
const tokenInfo = getTokenAtCursor(currentLine, cursorCol);
|
||||||
// triggers file completion, not just slash command completion.
|
if (tokenInfo) {
|
||||||
for (let i = cursorCol - 1; i >= 0; i--) {
|
return {
|
||||||
const char = codePoints[i];
|
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 === ' ') {
|
// FIRST: Check for @ completion (scan backwards from cursor)
|
||||||
let backslashCount = 0;
|
// This must happen before slash command check so that `/cmd @file`
|
||||||
for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) {
|
// triggers file completion, not just slash command completion.
|
||||||
backslashCount++;
|
for (let i = cursorCol - 1; i >= 0; i--) {
|
||||||
}
|
const char = codePoints[i];
|
||||||
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) {
|
if (char === ' ') {
|
||||||
end = i;
|
let backslashCount = 0;
|
||||||
break;
|
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
const pathStart = i + 1;
|
||||||
|
const partialPath = currentLine.substring(pathStart, end);
|
||||||
// THEN: Check for slash command (only if no @ completion is active)
|
|
||||||
if (cursorRow === 0 && isSlashCommand(currentLine.trim())) {
|
|
||||||
return {
|
return {
|
||||||
completionMode: CompletionMode.SLASH,
|
completionMode: CompletionMode.AT,
|
||||||
query: currentLine,
|
query: partialPath,
|
||||||
completionStart: 0,
|
completionStart: pathStart,
|
||||||
completionEnd: currentLine.length,
|
completionEnd: end,
|
||||||
};
|
shellTokenIsCommand: false,
|
||||||
}
|
shellTokens: [],
|
||||||
|
shellCursorIndex: -1,
|
||||||
// Check for prompt completion - only if enabled
|
shellCommandToken: '',
|
||||||
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,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// THEN: Check for slash command (only if no @ completion is active)
|
||||||
|
if (cursorRow === 0 && isSlashCommand(currentLine.trim())) {
|
||||||
return {
|
return {
|
||||||
completionMode: CompletionMode.IDLE,
|
completionMode: CompletionMode.SLASH,
|
||||||
query: null,
|
query: currentLine,
|
||||||
completionStart: -1,
|
completionStart: 0,
|
||||||
completionEnd: -1,
|
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({
|
useAtCompletion({
|
||||||
enabled: active && completionMode === CompletionMode.AT,
|
enabled: active && completionMode === CompletionMode.AT,
|
||||||
@@ -199,9 +251,20 @@ export function useCommandCompletion({
|
|||||||
setIsPerfectMatch,
|
setIsPerfectMatch,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useShellCompletion({
|
||||||
|
enabled: active && completionMode === CompletionMode.SHELL,
|
||||||
|
query: query || '',
|
||||||
|
isCommandPosition: shellTokenIsCommand,
|
||||||
|
tokens: shellTokens,
|
||||||
|
cursorIndex: shellCursorIndex,
|
||||||
|
commandToken: shellCommandToken,
|
||||||
|
cwd,
|
||||||
|
setSuggestions,
|
||||||
|
setIsLoadingSuggestions,
|
||||||
|
});
|
||||||
|
|
||||||
const promptCompletion = usePromptCompletion({
|
const promptCompletion = usePromptCompletion({
|
||||||
buffer,
|
buffer,
|
||||||
config,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string[]> {
|
||||||
|
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<string>();
|
||||||
|
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<Suggestion[]> {
|
||||||
|
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<import('node:fs').Dirent>;
|
||||||
|
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<string[] | null>(null);
|
||||||
|
const pathEnvRef = useRef<string>(process.env['PATH'] ?? '');
|
||||||
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
const debounceRef = useRef<NodeJS.Timeout | null>(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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user