diff options
| author | Ayesha Shafique <[email protected]> | 2025-08-04 00:53:24 +0500 |
|---|---|---|
| committer | GitHub <[email protected]> | 2025-08-03 19:53:24 +0000 |
| commit | 072d8ba2899f2601dad6d4b0333fdcb80555a7dd (patch) | |
| tree | a8333f75184889929b844c115c5fb93555abdf62 /packages/cli/src/ui/hooks/useSlashCompletion.tsx | |
| parent | 03ed37d0dc2b5e2077b53073517abaab3d24d9c2 (diff) | |
feat: Add reverse search capability for shell commands (#4793)
Diffstat (limited to 'packages/cli/src/ui/hooks/useSlashCompletion.tsx')
| -rw-r--r-- | packages/cli/src/ui/hooks/useSlashCompletion.tsx | 654 |
1 files changed, 654 insertions, 0 deletions
diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.tsx b/packages/cli/src/ui/hooks/useSlashCompletion.tsx new file mode 100644 index 00000000..f68d52d8 --- /dev/null +++ b/packages/cli/src/ui/hooks/useSlashCompletion.tsx @@ -0,0 +1,654 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { 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 { 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'; +import { useCompletion } from './useCompletion.js'; + +export interface UseSlashCompletionReturn { + suggestions: Suggestion[]; + activeSuggestionIndex: number; + visibleStartIndex: number; + showSuggestions: boolean; + isLoadingSuggestions: boolean; + isPerfectMatch: boolean; + setActiveSuggestionIndex: React.Dispatch<React.SetStateAction<number>>; + setShowSuggestions: React.Dispatch<React.SetStateAction<boolean>>; + resetCompletionState: () => void; + navigateUp: () => void; + navigateDown: () => void; + handleAutocomplete: (indexToUse: number) => void; +} + +export function useSlashCompletion( + buffer: TextBuffer, + dirs: readonly string[], + cwd: string, + slashCommands: readonly SlashCommand[], + commandContext: CommandContext, + reverseSearchActive: boolean = false, + config?: Config, +): UseSlashCompletionReturn { + const { + suggestions, + activeSuggestionIndex, + visibleStartIndex, + showSuggestions, + isLoadingSuggestions, + isPerfectMatch, + + setSuggestions, + setShowSuggestions, + setActiveSuggestionIndex, + setIsLoadingSuggestions, + setIsPerfectMatch, + setVisibleStartIndex, + + resetCompletionState, + navigateUp, + navigateDown, + } = useCompletion(); + + const completionStart = useRef(-1); + const completionEnd = useRef(-1); + + const cursorRow = buffer.cursor[0]; + const cursorCol = buffer.cursor[1]; + + // 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 || reverseSearchActive) { + 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, + reverseSearchActive, + setSuggestions, + setShowSuggestions, + setActiveSuggestionIndex, + setIsLoadingSuggestions, + setIsPerfectMatch, + setVisibleStartIndex, + ]); + + 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, + visibleStartIndex, + showSuggestions, + isLoadingSuggestions, + isPerfectMatch, + setActiveSuggestionIndex, + setShowSuggestions, + resetCompletionState, + navigateUp, + navigateDown, + handleAutocomplete, + }; +} |
