Download packages/cli/src/ui/hooks/useAtCompletion.ts from SaylorTwift/gemini-cli: direct link, hf CLI and curl.
- Browser
- Download file 13.6 kB
-
https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/cli/src/ui/hooks/useAtCompletion.ts
- Command line
-
hf download hf://SaylorTwift/gemini-cli/packages/cli/src/ui/hooks/useAtCompletion.ts
-
curl -L -o useAtCompletion.ts https://huggingface.co/SaylorTwift/gemini-cli/resolve/main/packages/cli/src/ui/hooks/useAtCompletion.ts
13.6 kB
| /** | |
| * @license | |
| * Copyright 2025 Google LLC | |
| * SPDX-License-Identifier: Apache-2.0 | |
| */ | |
| import { useCallback, useEffect, useReducer, useRef } from 'react'; | |
| import { setTimeout as setTimeoutPromise } from 'node:timers/promises'; | |
| import * as path from 'node:path'; | |
| import { | |
| FileSearchFactory, | |
| escapePath, | |
| FileDiscoveryService, | |
| type Config, | |
| type FileSearch, | |
| } from '@google/gemini-cli-core'; | |
| import { | |
| MAX_SUGGESTIONS_TO_SHOW, | |
| type Suggestion, | |
| } from '../components/SuggestionsDisplay.js'; | |
| import { CommandKind } from '../commands/types.js'; | |
| import { AsyncFzf } from 'fzf'; | |
| const DEFAULT_SEARCH_TIMEOUT_MS = 5000; | |
| export enum AtCompletionStatus { | |
| IDLE = 'idle', | |
| INITIALIZING = 'initializing', | |
| READY = 'ready', | |
| SEARCHING = 'searching', | |
| ERROR = 'error', | |
| } | |
| interface AtCompletionState { | |
| status: AtCompletionStatus; | |
| suggestions: Suggestion[]; | |
| isLoading: boolean; | |
| pattern: string | null; | |
| } | |
| type AtCompletionAction = | |
| | { type: 'INITIALIZE' } | |
| | { type: 'INITIALIZE_SUCCESS' } | |
| | { type: 'SEARCH'; payload: string } | |
| | { type: 'SEARCH_SUCCESS'; payload: Suggestion[] } | |
| | { type: 'SET_LOADING'; payload: boolean } | |
| | { type: 'ERROR' } | |
| | { type: 'RESET' }; | |
| const initialState: AtCompletionState = { | |
| status: AtCompletionStatus.IDLE, | |
| suggestions: [], | |
| isLoading: false, | |
| pattern: null, | |
| }; | |
| function atCompletionReducer( | |
| state: AtCompletionState, | |
| action: AtCompletionAction, | |
| ): AtCompletionState { | |
| switch (action.type) { | |
| case 'INITIALIZE': | |
| return { | |
| ...state, | |
| status: AtCompletionStatus.INITIALIZING, | |
| isLoading: true, | |
| }; | |
| case 'INITIALIZE_SUCCESS': | |
| return { ...state, status: AtCompletionStatus.READY, isLoading: false }; | |
| case 'SEARCH': | |
| // Keep old suggestions, don't set loading immediately | |
| return { | |
| ...state, | |
| status: AtCompletionStatus.SEARCHING, | |
| pattern: action.payload, | |
| }; | |
| case 'SEARCH_SUCCESS': | |
| return { | |
| ...state, | |
| status: AtCompletionStatus.READY, | |
| suggestions: action.payload, | |
| isLoading: false, | |
| }; | |
| case 'SET_LOADING': | |
| // Only show loading if we are still in a searching state | |
| if (state.status === AtCompletionStatus.SEARCHING) { | |
| return { ...state, isLoading: action.payload, suggestions: [] }; | |
| } | |
| return state; | |
| case 'ERROR': | |
| return { | |
| ...state, | |
| status: AtCompletionStatus.ERROR, | |
| isLoading: false, | |
| suggestions: [], | |
| }; | |
| case 'RESET': | |
| return initialState; | |
| default: | |
| return state; | |
| } | |
| } | |
| export interface UseAtCompletionProps { | |
| enabled: boolean; | |
| pattern: string; | |
| config: Config | undefined; | |
| cwd: string; | |
| setSuggestions: (suggestions: Suggestion[]) => void; | |
| setIsLoadingSuggestions: (isLoading: boolean) => void; | |
| } | |
| interface ResourceSuggestionCandidate { | |
| searchKey: string; | |
| suggestion: Suggestion; | |
| } | |
| function buildResourceCandidates( | |
| config?: Config, | |
| ): ResourceSuggestionCandidate[] { | |
| const registry = config?.getResourceRegistry?.(); | |
| if (!registry) { | |
| return []; | |
| } | |
| const resources = registry.getAllResources().map((resource) => { | |
| // Use serverName:uri format to disambiguate resources from different MCP servers | |
| const prefixedUri = `${resource.serverName}:${resource.uri}`; | |
| return { | |
| // Include prefixedUri in searchKey so users can search by the displayed format | |
| searchKey: `${prefixedUri} ${resource.name ?? ''}`.toLowerCase(), | |
| suggestion: { | |
| label: prefixedUri, | |
| value: prefixedUri, | |
| }, | |
| } satisfies ResourceSuggestionCandidate; | |
| }); | |
| return resources; | |
| } | |
| function buildAgentCandidates(config?: Config): Suggestion[] { | |
| const registry = config?.getAgentRegistry?.(); | |
| if (!registry) { | |
| return []; | |
| } | |
| return registry.getAllDefinitions().map((def) => ({ | |
| label: def.name, | |
| value: def.name, | |
| commandKind: CommandKind.AGENT, | |
| })); | |
| } | |
| async function searchResourceCandidates( | |
| pattern: string, | |
| candidates: ResourceSuggestionCandidate[], | |
| ): Promise<Suggestion[]> { | |
| if (candidates.length === 0) { | |
| return []; | |
| } | |
| const normalizedPattern = pattern.toLowerCase(); | |
| if (!normalizedPattern) { | |
| return candidates | |
| .slice(0, MAX_SUGGESTIONS_TO_SHOW) | |
| .map((candidate) => candidate.suggestion); | |
| } | |
| const fzf = new AsyncFzf(candidates, { | |
| selector: (candidate: ResourceSuggestionCandidate) => candidate.searchKey, | |
| }); | |
| // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment | |
| const results: Array<{ item: ResourceSuggestionCandidate }> = await fzf.find( | |
| normalizedPattern, | |
| { | |
| limit: MAX_SUGGESTIONS_TO_SHOW * 3, | |
| }, | |
| ); | |
| return results.map((result) => result.item.suggestion); | |
| } | |
| async function searchAgentCandidates( | |
| pattern: string, | |
| candidates: Suggestion[], | |
| ): Promise<Suggestion[]> { | |
| if (candidates.length === 0) { | |
| return []; | |
| } | |
| const normalizedPattern = pattern.toLowerCase(); | |
| if (!normalizedPattern) { | |
| return candidates.slice(0, MAX_SUGGESTIONS_TO_SHOW); | |
| } | |
| const fzf = new AsyncFzf(candidates, { | |
| selector: (s: Suggestion) => s.label, | |
| }); | |
| // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment | |
| const results: Array<{ item: Suggestion }> = await fzf.find( | |
| normalizedPattern, | |
| { | |
| limit: MAX_SUGGESTIONS_TO_SHOW, | |
| }, | |
| ); | |
| return results.map((r) => r.item); | |
| } | |
| export function useAtCompletion(props: UseAtCompletionProps): void { | |
| const { | |
| enabled, | |
| pattern, | |
| config, | |
| cwd, | |
| setSuggestions, | |
| setIsLoadingSuggestions, | |
| } = props; | |
| const [state, dispatch] = useReducer(atCompletionReducer, initialState); | |
| const fileSearchMap = useRef<Map<string, FileSearch>>(new Map()); | |
| const initEpoch = useRef(0); | |
| const searchAbortController = useRef<AbortController | null>(null); | |
| const slowSearchTimer = useRef<NodeJS.Timeout | null>(null); | |
| useEffect(() => { | |
| setSuggestions(state.suggestions); | |
| }, [state.suggestions, setSuggestions]); | |
| useEffect(() => { | |
| setIsLoadingSuggestions(state.isLoading); | |
| }, [state.isLoading, setIsLoadingSuggestions]); | |
| const disposeFileSearchers = useCallback(async () => { | |
| const searchers = [...fileSearchMap.current.values()]; | |
| fileSearchMap.current.clear(); | |
| initEpoch.current += 1; | |
| const closePromises: Array<Promise<void>> = []; | |
| for (const searcher of searchers) { | |
| if (searcher.close) { | |
| closePromises.push(searcher.close()); | |
| } | |
| } | |
| await Promise.all(closePromises); | |
| }, []); | |
| const resetFileSearchState = useCallback(() => { | |
| void disposeFileSearchers(); | |
| dispatch({ type: 'RESET' }); | |
| }, [disposeFileSearchers]); | |
| useEffect(() => { | |
| resetFileSearchState(); | |
| }, [cwd, config, resetFileSearchState]); | |
| useEffect(() => { | |
| const workspaceContext = config?.getWorkspaceContext?.(); | |
| if (!workspaceContext) return; | |
| const unsubscribe = | |
| workspaceContext.onDirectoriesChanged(resetFileSearchState); | |
| return unsubscribe; | |
| }, [config, resetFileSearchState]); | |
| useEffect( | |
| () => () => { | |
| void disposeFileSearchers(); | |
| searchAbortController.current?.abort(); | |
| if (slowSearchTimer.current) { | |
| clearTimeout(slowSearchTimer.current); | |
| } | |
| }, | |
| [disposeFileSearchers], | |
| ); | |
| // Reacts to user input (`pattern`) ONLY. | |
| useEffect(() => { | |
| if (!enabled) { | |
| // reset when first getting out of completion suggestions | |
| if ( | |
| state.status === AtCompletionStatus.READY || | |
| state.status === AtCompletionStatus.ERROR | |
| ) { | |
| dispatch({ type: 'RESET' }); | |
| } | |
| return; | |
| } | |
| if (pattern === null) { | |
| dispatch({ type: 'RESET' }); | |
| return; | |
| } | |
| if (state.status === AtCompletionStatus.IDLE) { | |
| dispatch({ type: 'INITIALIZE' }); | |
| } else if ( | |
| (state.status === AtCompletionStatus.READY || | |
| state.status === AtCompletionStatus.SEARCHING) && | |
| pattern.toLowerCase() !== state.pattern // Only search if the pattern has changed | |
| ) { | |
| dispatch({ type: 'SEARCH', payload: pattern.toLowerCase() }); | |
| } | |
| }, [enabled, pattern, state.status, state.pattern]); | |
| // The "Worker" that performs async operations based on status. | |
| useEffect(() => { | |
| const initialize = async () => { | |
| const currentEpoch = initEpoch.current; | |
| try { | |
| const directories = config | |
| ?.getWorkspaceContext?.() | |
| ?.getDirectories() ?? [cwd]; | |
| const initPromises: Array<Promise<void>> = []; | |
| for (const dir of directories) { | |
| if (fileSearchMap.current.has(dir)) continue; | |
| const searcher = FileSearchFactory.create({ | |
| projectRoot: dir, | |
| ignoreDirs: [], | |
| fileDiscoveryService: new FileDiscoveryService( | |
| dir, | |
| config?.getFileFilteringOptions(), | |
| ), | |
| cache: true, | |
| cacheTtl: 30, | |
| enableFileWatcher: | |
| config?.getFileFilteringOptions()?.enableFileWatcher ?? false, | |
| enableRecursiveFileSearch: | |
| config?.getEnableRecursiveFileSearch() ?? true, | |
| enableFuzzySearch: | |
| config?.getFileFilteringEnableFuzzySearch() ?? true, | |
| maxFiles: config?.getFileFilteringOptions()?.maxFileCount, | |
| }); | |
| initPromises.push( | |
| searcher.initialize().then(() => { | |
| if (initEpoch.current === currentEpoch) { | |
| fileSearchMap.current.set(dir, searcher); | |
| } | |
| }), | |
| ); | |
| } | |
| await Promise.all(initPromises); | |
| if (initEpoch.current !== currentEpoch) return; | |
| dispatch({ type: 'INITIALIZE_SUCCESS' }); | |
| if (state.pattern !== null) { | |
| dispatch({ type: 'SEARCH', payload: state.pattern }); | |
| } | |
| } catch { | |
| if (initEpoch.current === currentEpoch) { | |
| dispatch({ type: 'ERROR' }); | |
| } | |
| } | |
| }; | |
| const search = async () => { | |
| if (fileSearchMap.current.size === 0 || state.pattern === null) { | |
| return; | |
| } | |
| const currentPattern = state.pattern; | |
| if (slowSearchTimer.current) { | |
| clearTimeout(slowSearchTimer.current); | |
| } | |
| const controller = new AbortController(); | |
| searchAbortController.current = controller; | |
| slowSearchTimer.current = setTimeout(() => { | |
| dispatch({ type: 'SET_LOADING', payload: true }); | |
| }, 200); | |
| const timeoutMs = | |
| config?.getFileFilteringOptions()?.searchTimeout ?? | |
| DEFAULT_SEARCH_TIMEOUT_MS; | |
| // eslint-disable-next-line @typescript-eslint/no-floating-promises | |
| (async () => { | |
| try { | |
| await setTimeoutPromise(timeoutMs, undefined, { | |
| signal: controller.signal, | |
| }); | |
| controller.abort(); | |
| } catch { | |
| // ignore | |
| } | |
| })(); | |
| try { | |
| const directories = config | |
| ?.getWorkspaceContext?.() | |
| ?.getDirectories() ?? [cwd]; | |
| const cwdRealpath = directories[0]; | |
| const allSearchPromises = [...fileSearchMap.current.entries()].map( | |
| async ([dir, searcher]): Promise<string[]> => { | |
| const results = await searcher.search(currentPattern, { | |
| signal: controller.signal, | |
| maxResults: MAX_SUGGESTIONS_TO_SHOW * 3, | |
| }); | |
| if (dir !== cwdRealpath) { | |
| return results.map((p: string) => path.join(dir, p)); | |
| } | |
| return results; | |
| }, | |
| ); | |
| const allResults = await Promise.all(allSearchPromises); | |
| if (slowSearchTimer.current) { | |
| clearTimeout(slowSearchTimer.current); | |
| } | |
| if (controller.signal.aborted) { | |
| return; | |
| } | |
| const mergedResults = allResults.flat(); | |
| const fileSuggestions = mergedResults.map((p) => ({ | |
| label: p, | |
| value: escapePath(p), | |
| })); | |
| const resourceCandidates = buildResourceCandidates(config); | |
| const resourceSuggestions = ( | |
| await searchResourceCandidates( | |
| currentPattern ?? '', | |
| resourceCandidates, | |
| ) | |
| ).map((suggestion) => ({ | |
| ...suggestion, | |
| label: suggestion.label.replace(/^@/, ''), | |
| value: suggestion.value.replace(/^@/, ''), | |
| })); | |
| const agentCandidates = buildAgentCandidates(config); | |
| const agentSuggestions = await searchAgentCandidates( | |
| currentPattern ?? '', | |
| agentCandidates, | |
| ); | |
| // Re-check after resource/agent searches which are not abort-aware | |
| if (controller.signal.aborted) { | |
| return; | |
| } | |
| const combinedSuggestions = [ | |
| ...agentSuggestions, | |
| ...fileSuggestions, | |
| ...resourceSuggestions, | |
| ]; | |
| dispatch({ type: 'SEARCH_SUCCESS', payload: combinedSuggestions }); | |
| } catch (error) { | |
| if (!(error instanceof Error && error.name === 'AbortError')) { | |
| dispatch({ type: 'ERROR' }); | |
| } | |
| } finally { | |
| controller.abort(); | |
| } | |
| }; | |
| if (state.status === AtCompletionStatus.INITIALIZING) { | |
| // eslint-disable-next-line @typescript-eslint/no-floating-promises | |
| initialize(); | |
| } else if (state.status === AtCompletionStatus.SEARCHING) { | |
| // eslint-disable-next-line @typescript-eslint/no-floating-promises | |
| search(); | |
| } | |
| return () => { | |
| searchAbortController.current?.abort(); | |
| if (slowSearchTimer.current) { | |
| clearTimeout(slowSearchTimer.current); | |
| } | |
| }; | |
| }, [state.status, state.pattern, config, cwd]); | |
| } | |