summaryrefslogtreecommitdiff
path: root/packages/cli/src/ui/hooks/useCompletion.ts
diff options
context:
space:
mode:
authorAyesha Shafique <[email protected]>2025-08-04 00:53:24 +0500
committerGitHub <[email protected]>2025-08-03 19:53:24 +0000
commit072d8ba2899f2601dad6d4b0333fdcb80555a7dd (patch)
treea8333f75184889929b844c115c5fb93555abdf62 /packages/cli/src/ui/hooks/useCompletion.ts
parent03ed37d0dc2b5e2077b53073517abaab3d24d9c2 (diff)
feat: Add reverse search capability for shell commands (#4793)
Diffstat (limited to 'packages/cli/src/ui/hooks/useCompletion.ts')
-rw-r--r--packages/cli/src/ui/hooks/useCompletion.ts604
1 files changed, 14 insertions, 590 deletions
diff --git a/packages/cli/src/ui/hooks/useCompletion.ts b/packages/cli/src/ui/hooks/useCompletion.ts
index 7790f835..242b4528 100644
--- a/packages/cli/src/ui/hooks/useCompletion.ts
+++ b/packages/cli/src/ui/hooks/useCompletion.ts
@@ -4,30 +4,12 @@
* SPDX-License-Identifier: Apache-2.0
*/
-import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
-import * as fs from 'fs/promises';
-import * as path from 'path';
-import { glob } from 'glob';
-import {
- isNodeError,
- escapePath,
- unescapePath,
- getErrorMessage,
- Config,
- FileDiscoveryService,
- DEFAULT_FILE_FILTERING_OPTIONS,
-} from '@google/gemini-cli-core';
+import { useState, useCallback } from 'react';
+
import {
MAX_SUGGESTIONS_TO_SHOW,
Suggestion,
} from '../components/SuggestionsDisplay.js';
-import { CommandContext, SlashCommand } from '../commands/types.js';
-import {
- logicalPosToOffset,
- TextBuffer,
-} from '../components/shared/text-buffer.js';
-import { isSlashCommand } from '../utils/commandUtils.js';
-import { toCodePoints } from '../utils/textUtils.js';
export interface UseCompletionReturn {
suggestions: Suggestion[];
@@ -36,22 +18,18 @@ export interface UseCompletionReturn {
showSuggestions: boolean;
isLoadingSuggestions: boolean;
isPerfectMatch: boolean;
+ setSuggestions: React.Dispatch<React.SetStateAction<Suggestion[]>>;
setActiveSuggestionIndex: React.Dispatch<React.SetStateAction<number>>;
+ setVisibleStartIndex: React.Dispatch<React.SetStateAction<number>>;
+ setIsLoadingSuggestions: React.Dispatch<React.SetStateAction<boolean>>;
+ setIsPerfectMatch: React.Dispatch<React.SetStateAction<boolean>>;
setShowSuggestions: React.Dispatch<React.SetStateAction<boolean>>;
resetCompletionState: () => void;
navigateUp: () => void;
navigateDown: () => void;
- handleAutocomplete: (indexToUse: number) => void;
}
-export function useCompletion(
- buffer: TextBuffer,
- dirs: readonly string[],
- cwd: string,
- slashCommands: readonly SlashCommand[],
- commandContext: CommandContext,
- config?: Config,
-): UseCompletionReturn {
+export function useCompletion(): UseCompletionReturn {
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
const [activeSuggestionIndex, setActiveSuggestionIndex] =
useState<number>(-1);
@@ -60,11 +38,6 @@ export function useCompletion(
const [isLoadingSuggestions, setIsLoadingSuggestions] =
useState<boolean>(false);
const [isPerfectMatch, setIsPerfectMatch] = useState<boolean>(false);
- const completionStart = useRef(-1);
- const completionEnd = useRef(-1);
-
- const cursorRow = buffer.cursor[0];
- const cursorCol = buffer.cursor[1];
const resetCompletionState = useCallback(() => {
setSuggestions([]);
@@ -133,560 +106,6 @@ export function useCompletion(
return newActiveIndex;
});
}, [suggestions.length]);
-
- // Check if cursor is after @ or / without unescaped spaces
- const commandIndex = useMemo(() => {
- const currentLine = buffer.lines[cursorRow] || '';
- if (cursorRow === 0 && isSlashCommand(currentLine.trim())) {
- return currentLine.indexOf('/');
- }
-
- // For other completions like '@', we search backwards from the cursor.
-
- const codePoints = toCodePoints(currentLine);
- for (let i = cursorCol - 1; i >= 0; i--) {
- const char = codePoints[i];
-
- if (char === ' ') {
- // Check for unescaped spaces.
- let backslashCount = 0;
- for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) {
- backslashCount++;
- }
- if (backslashCount % 2 === 0) {
- return -1; // Inactive on unescaped space.
- }
- } else if (char === '@') {
- // Active if we find an '@' before any unescaped space.
- return i;
- }
- }
-
- return -1;
- }, [cursorRow, cursorCol, buffer.lines]);
-
- useEffect(() => {
- if (commandIndex === -1) {
- resetCompletionState();
- return;
- }
-
- const currentLine = buffer.lines[cursorRow] || '';
- const codePoints = toCodePoints(currentLine);
-
- if (codePoints[commandIndex] === '/') {
- // Always reset perfect match at the beginning of processing.
- setIsPerfectMatch(false);
-
- const fullPath = currentLine.substring(commandIndex + 1);
- const hasTrailingSpace = currentLine.endsWith(' ');
-
- // Get all non-empty parts of the command.
- const rawParts = fullPath.split(/\s+/).filter((p) => p);
-
- let commandPathParts = rawParts;
- let partial = '';
-
- // If there's no trailing space, the last part is potentially a partial segment.
- // We tentatively separate it.
- if (!hasTrailingSpace && rawParts.length > 0) {
- partial = rawParts[rawParts.length - 1];
- commandPathParts = rawParts.slice(0, -1);
- }
-
- // Traverse the Command Tree using the tentative completed path
- let currentLevel: readonly SlashCommand[] | undefined = slashCommands;
- let leafCommand: SlashCommand | null = null;
-
- for (const part of commandPathParts) {
- if (!currentLevel) {
- leafCommand = null;
- currentLevel = [];
- break;
- }
- const found: SlashCommand | undefined = currentLevel.find(
- (cmd) => cmd.name === part || cmd.altNames?.includes(part),
- );
- if (found) {
- leafCommand = found;
- currentLevel = found.subCommands as
- | readonly SlashCommand[]
- | undefined;
- } else {
- leafCommand = null;
- currentLevel = [];
- break;
- }
- }
-
- let exactMatchAsParent: SlashCommand | undefined;
- // Handle the Ambiguous Case
- if (!hasTrailingSpace && currentLevel) {
- exactMatchAsParent = currentLevel.find(
- (cmd) =>
- (cmd.name === partial || cmd.altNames?.includes(partial)) &&
- cmd.subCommands,
- );
-
- if (exactMatchAsParent) {
- // It's a perfect match for a parent command. Override our initial guess.
- // Treat it as a completed command path.
- leafCommand = exactMatchAsParent;
- currentLevel = exactMatchAsParent.subCommands;
- partial = ''; // We now want to suggest ALL of its sub-commands.
- }
- }
-
- // Check for perfect, executable match
- if (!hasTrailingSpace) {
- if (leafCommand && partial === '' && leafCommand.action) {
- // Case: /command<enter> - command has action, no sub-commands were suggested
- setIsPerfectMatch(true);
- } else if (currentLevel) {
- // Case: /command subcommand<enter>
- const perfectMatch = currentLevel.find(
- (cmd) =>
- (cmd.name === partial || cmd.altNames?.includes(partial)) &&
- cmd.action,
- );
- if (perfectMatch) {
- setIsPerfectMatch(true);
- }
- }
- }
-
- const depth = commandPathParts.length;
- const isArgumentCompletion =
- leafCommand?.completion &&
- (hasTrailingSpace ||
- (rawParts.length > depth && depth > 0 && partial !== ''));
-
- // Set completion range
- if (hasTrailingSpace || exactMatchAsParent) {
- completionStart.current = currentLine.length;
- completionEnd.current = currentLine.length;
- } else if (partial) {
- if (isArgumentCompletion) {
- const commandSoFar = `/${commandPathParts.join(' ')}`;
- const argStartIndex =
- commandSoFar.length + (commandPathParts.length > 0 ? 1 : 0);
- completionStart.current = argStartIndex;
- } else {
- completionStart.current = currentLine.length - partial.length;
- }
- completionEnd.current = currentLine.length;
- } else {
- // e.g. /
- completionStart.current = commandIndex + 1;
- completionEnd.current = currentLine.length;
- }
-
- // Provide Suggestions based on the now-corrected context
- if (isArgumentCompletion) {
- const fetchAndSetSuggestions = async () => {
- setIsLoadingSuggestions(true);
- const argString = rawParts.slice(depth).join(' ');
- const results =
- (await leafCommand!.completion!(commandContext, argString)) || [];
- const finalSuggestions = results.map((s) => ({ label: s, value: s }));
- setSuggestions(finalSuggestions);
- setShowSuggestions(finalSuggestions.length > 0);
- setActiveSuggestionIndex(finalSuggestions.length > 0 ? 0 : -1);
- setIsLoadingSuggestions(false);
- };
- fetchAndSetSuggestions();
- return;
- }
-
- // Command/Sub-command Completion
- const commandsToSearch = currentLevel || [];
- if (commandsToSearch.length > 0) {
- let potentialSuggestions = commandsToSearch.filter(
- (cmd) =>
- cmd.description &&
- (cmd.name.startsWith(partial) ||
- cmd.altNames?.some((alt) => alt.startsWith(partial))),
- );
-
- // If a user's input is an exact match and it is a leaf command,
- // enter should submit immediately.
- if (potentialSuggestions.length > 0 && !hasTrailingSpace) {
- const perfectMatch = potentialSuggestions.find(
- (s) => s.name === partial || s.altNames?.includes(partial),
- );
- if (perfectMatch && perfectMatch.action) {
- potentialSuggestions = [];
- }
- }
-
- const finalSuggestions = potentialSuggestions.map((cmd) => ({
- label: cmd.name,
- value: cmd.name,
- description: cmd.description,
- }));
-
- setSuggestions(finalSuggestions);
- setShowSuggestions(finalSuggestions.length > 0);
- setActiveSuggestionIndex(finalSuggestions.length > 0 ? 0 : -1);
- setIsLoadingSuggestions(false);
- return;
- }
-
- // If we fall through, no suggestions are available.
- resetCompletionState();
- return;
- }
-
- // Handle At Command Completion
- completionEnd.current = 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) {
- completionEnd.current = i;
- break;
- }
- }
- }
-
- const pathStart = commandIndex + 1;
- const partialPath = currentLine.substring(pathStart, completionEnd.current);
- const lastSlashIndex = partialPath.lastIndexOf('/');
- completionStart.current =
- lastSlashIndex === -1 ? pathStart : pathStart + lastSlashIndex + 1;
- const baseDirRelative =
- lastSlashIndex === -1
- ? '.'
- : partialPath.substring(0, lastSlashIndex + 1);
- const prefix = unescapePath(
- lastSlashIndex === -1
- ? partialPath
- : partialPath.substring(lastSlashIndex + 1),
- );
-
- let isMounted = true;
-
- const findFilesRecursively = async (
- startDir: string,
- searchPrefix: string,
- fileDiscovery: FileDiscoveryService | null,
- filterOptions: {
- respectGitIgnore?: boolean;
- respectGeminiIgnore?: boolean;
- },
- currentRelativePath = '',
- depth = 0,
- maxDepth = 10, // Limit recursion depth
- maxResults = 50, // Limit number of results
- ): Promise<Suggestion[]> => {
- if (depth > maxDepth) {
- return [];
- }
-
- const lowerSearchPrefix = searchPrefix.toLowerCase();
- let foundSuggestions: Suggestion[] = [];
- try {
- const entries = await fs.readdir(startDir, { withFileTypes: true });
- for (const entry of entries) {
- if (foundSuggestions.length >= maxResults) break;
-
- const entryPathRelative = path.join(currentRelativePath, entry.name);
- const entryPathFromRoot = path.relative(
- startDir,
- path.join(startDir, entry.name),
- );
-
- // Conditionally ignore dotfiles
- if (!searchPrefix.startsWith('.') && entry.name.startsWith('.')) {
- continue;
- }
-
- // Check if this entry should be ignored by filtering options
- if (
- fileDiscovery &&
- fileDiscovery.shouldIgnoreFile(entryPathFromRoot, filterOptions)
- ) {
- continue;
- }
-
- if (entry.name.toLowerCase().startsWith(lowerSearchPrefix)) {
- foundSuggestions.push({
- label: entryPathRelative + (entry.isDirectory() ? '/' : ''),
- value: escapePath(
- entryPathRelative + (entry.isDirectory() ? '/' : ''),
- ),
- });
- }
- if (
- entry.isDirectory() &&
- entry.name !== 'node_modules' &&
- !entry.name.startsWith('.')
- ) {
- if (foundSuggestions.length < maxResults) {
- foundSuggestions = foundSuggestions.concat(
- await findFilesRecursively(
- path.join(startDir, entry.name),
- searchPrefix, // Pass original searchPrefix for recursive calls
- fileDiscovery,
- filterOptions,
- entryPathRelative,
- depth + 1,
- maxDepth,
- maxResults - foundSuggestions.length,
- ),
- );
- }
- }
- }
- } catch (_err) {
- // Ignore errors like permission denied or ENOENT during recursive search
- }
- return foundSuggestions.slice(0, maxResults);
- };
-
- const findFilesWithGlob = async (
- searchPrefix: string,
- fileDiscoveryService: FileDiscoveryService,
- filterOptions: {
- respectGitIgnore?: boolean;
- respectGeminiIgnore?: boolean;
- },
- searchDir: string,
- maxResults = 50,
- ): Promise<Suggestion[]> => {
- const globPattern = `**/${searchPrefix}*`;
- const files = await glob(globPattern, {
- cwd: searchDir,
- dot: searchPrefix.startsWith('.'),
- nocase: true,
- });
-
- const suggestions: Suggestion[] = files
- .filter((file) => {
- if (fileDiscoveryService) {
- return !fileDiscoveryService.shouldIgnoreFile(file, filterOptions);
- }
- return true;
- })
- .map((file: string) => {
- const absolutePath = path.resolve(searchDir, file);
- const label = path.relative(cwd, absolutePath);
- return {
- label,
- value: escapePath(label),
- };
- })
- .slice(0, maxResults);
-
- return suggestions;
- };
-
- const fetchSuggestions = async () => {
- setIsLoadingSuggestions(true);
- let fetchedSuggestions: Suggestion[] = [];
-
- const fileDiscoveryService = config ? config.getFileService() : null;
- const enableRecursiveSearch =
- config?.getEnableRecursiveFileSearch() ?? true;
- const filterOptions =
- config?.getFileFilteringOptions() ?? DEFAULT_FILE_FILTERING_OPTIONS;
-
- try {
- // If there's no slash, or it's the root, do a recursive search from workspace directories
- for (const dir of dirs) {
- let fetchedSuggestionsPerDir: Suggestion[] = [];
- if (
- partialPath.indexOf('/') === -1 &&
- prefix &&
- enableRecursiveSearch
- ) {
- if (fileDiscoveryService) {
- fetchedSuggestionsPerDir = await findFilesWithGlob(
- prefix,
- fileDiscoveryService,
- filterOptions,
- dir,
- );
- } else {
- fetchedSuggestionsPerDir = await findFilesRecursively(
- dir,
- prefix,
- null,
- filterOptions,
- );
- }
- } else {
- // Original behavior: list files in the specific directory
- const lowerPrefix = prefix.toLowerCase();
- const baseDirAbsolute = path.resolve(dir, baseDirRelative);
- const entries = await fs.readdir(baseDirAbsolute, {
- withFileTypes: true,
- });
-
- // Filter entries using git-aware filtering
- const filteredEntries = [];
- for (const entry of entries) {
- // Conditionally ignore dotfiles
- if (!prefix.startsWith('.') && entry.name.startsWith('.')) {
- continue;
- }
- if (!entry.name.toLowerCase().startsWith(lowerPrefix)) continue;
-
- const relativePath = path.relative(
- dir,
- path.join(baseDirAbsolute, entry.name),
- );
- if (
- fileDiscoveryService &&
- fileDiscoveryService.shouldIgnoreFile(
- relativePath,
- filterOptions,
- )
- ) {
- continue;
- }
-
- filteredEntries.push(entry);
- }
-
- fetchedSuggestionsPerDir = filteredEntries.map((entry) => {
- const absolutePath = path.resolve(baseDirAbsolute, entry.name);
- const label =
- cwd === dir ? entry.name : path.relative(cwd, absolutePath);
- const suggestionLabel = entry.isDirectory() ? label + '/' : label;
- return {
- label: suggestionLabel,
- value: escapePath(suggestionLabel),
- };
- });
- }
- fetchedSuggestions = [
- ...fetchedSuggestions,
- ...fetchedSuggestionsPerDir,
- ];
- }
-
- // Like glob, we always return forwardslashes, even in windows.
- fetchedSuggestions = fetchedSuggestions.map((suggestion) => ({
- ...suggestion,
- label: suggestion.label.replace(/\\/g, '/'),
- value: suggestion.value.replace(/\\/g, '/'),
- }));
-
- // Sort by depth, then directories first, then alphabetically
- fetchedSuggestions.sort((a, b) => {
- const depthA = (a.label.match(/\//g) || []).length;
- const depthB = (b.label.match(/\//g) || []).length;
-
- if (depthA !== depthB) {
- return depthA - depthB;
- }
-
- const aIsDir = a.label.endsWith('/');
- const bIsDir = b.label.endsWith('/');
- if (aIsDir && !bIsDir) return -1;
- if (!aIsDir && bIsDir) return 1;
-
- // exclude extension when comparing
- const filenameA = a.label.substring(
- 0,
- a.label.length - path.extname(a.label).length,
- );
- const filenameB = b.label.substring(
- 0,
- b.label.length - path.extname(b.label).length,
- );
-
- return (
- filenameA.localeCompare(filenameB) || a.label.localeCompare(b.label)
- );
- });
-
- if (isMounted) {
- setSuggestions(fetchedSuggestions);
- setShowSuggestions(fetchedSuggestions.length > 0);
- setActiveSuggestionIndex(fetchedSuggestions.length > 0 ? 0 : -1);
- setVisibleStartIndex(0);
- }
- } catch (error: unknown) {
- if (isNodeError(error) && error.code === 'ENOENT') {
- if (isMounted) {
- setSuggestions([]);
- setShowSuggestions(false);
- }
- } else {
- console.error(
- `Error fetching completion suggestions for ${partialPath}: ${getErrorMessage(error)}`,
- );
- if (isMounted) {
- resetCompletionState();
- }
- }
- }
- if (isMounted) {
- setIsLoadingSuggestions(false);
- }
- };
-
- const debounceTimeout = setTimeout(fetchSuggestions, 100);
-
- return () => {
- isMounted = false;
- clearTimeout(debounceTimeout);
- };
- }, [
- buffer.text,
- cursorRow,
- cursorCol,
- buffer.lines,
- dirs,
- cwd,
- commandIndex,
- resetCompletionState,
- slashCommands,
- commandContext,
- config,
- ]);
-
- const handleAutocomplete = useCallback(
- (indexToUse: number) => {
- if (indexToUse < 0 || indexToUse >= suggestions.length) {
- return;
- }
- const suggestion = suggestions[indexToUse].value;
-
- if (completionStart.current === -1 || completionEnd.current === -1) {
- return;
- }
-
- const isSlash = (buffer.lines[cursorRow] || '')[commandIndex] === '/';
- let suggestionText = suggestion;
- if (isSlash) {
- // If we are inserting (not replacing), and the preceding character is not a space, add one.
- if (
- completionStart.current === completionEnd.current &&
- completionStart.current > commandIndex + 1 &&
- (buffer.lines[cursorRow] || '')[completionStart.current - 1] !== ' '
- ) {
- suggestionText = ' ' + suggestionText;
- }
- suggestionText += ' ';
- }
-
- buffer.replaceRangeByOffset(
- logicalPosToOffset(buffer.lines, cursorRow, completionStart.current),
- logicalPosToOffset(buffer.lines, cursorRow, completionEnd.current),
- suggestionText,
- );
- resetCompletionState();
- },
- [cursorRow, resetCompletionState, buffer, suggestions, commandIndex],
- );
-
return {
suggestions,
activeSuggestionIndex,
@@ -694,11 +113,16 @@ export function useCompletion(
showSuggestions,
isLoadingSuggestions,
isPerfectMatch,
- setActiveSuggestionIndex,
+
+ setSuggestions,
setShowSuggestions,
+ setActiveSuggestionIndex,
+ setVisibleStartIndex,
+ setIsLoadingSuggestions,
+ setIsPerfectMatch,
+
resetCompletionState,
navigateUp,
navigateDown,
- handleAutocomplete,
};
}