Files
gemini-cli/packages/cli/src/ui/hooks/useSlashCompletion.ts
2025-12-01 17:29:03 +00:00

572 lines
16 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useState, useEffect, useMemo } from 'react';
import { AsyncFzf } from 'fzf';
import type { Suggestion } from '../components/SuggestionsDisplay.js';
import {
CommandKind,
type CommandContext,
type SlashCommand,
} from '../commands/types.js';
import { debugLogger } from '@google/gemini-cli-core';
// Type alias for improved type safety based on actual fzf result structure
type FzfCommandResult = {
item: string;
start: number;
end: number;
score: number;
positions?: number[]; // Optional - fzf doesn't always provide match positions depending on algorithm/options used
};
// Interface for FZF command cache entry
interface FzfCommandCacheEntry {
fzf: AsyncFzf<string[]>;
commandMap: Map<string, SlashCommand>;
}
// Utility function to safely handle errors without information disclosure
function logErrorSafely(error: unknown, context: string): void {
if (error instanceof Error) {
// Log full error details securely for debugging
debugLogger.warn(`[${context}]`, error);
} else {
debugLogger.warn(`[${context}] Non-error thrown:`, error);
}
}
// Shared utility function for command matching logic
function matchesCommand(cmd: SlashCommand, query: string): boolean {
return (
cmd.name.toLowerCase() === query.toLowerCase() ||
cmd.altNames?.some((alt) => alt.toLowerCase() === query.toLowerCase()) ||
false
);
}
interface CommandParserResult {
hasTrailingSpace: boolean;
commandPathParts: string[];
partial: string;
currentLevel: readonly SlashCommand[] | undefined;
leafCommand: SlashCommand | null;
exactMatchAsParent: SlashCommand | undefined;
isArgumentCompletion: boolean;
}
function useCommandParser(
query: string | null,
slashCommands: readonly SlashCommand[],
): CommandParserResult {
return useMemo(() => {
if (!query) {
return {
hasTrailingSpace: false,
commandPathParts: [],
partial: '',
currentLevel: slashCommands,
leafCommand: null,
exactMatchAsParent: undefined,
isArgumentCompletion: false,
};
}
const fullPath = query.substring(1) || '';
const hasTrailingSpace = !!query.endsWith(' ');
const rawParts = fullPath.split(/\s+/).filter((p) => p);
let commandPathParts = rawParts;
let partial = '';
if (!hasTrailingSpace && rawParts.length > 0) {
partial = rawParts[rawParts.length - 1];
commandPathParts = rawParts.slice(0, -1);
}
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) =>
matchesCommand(cmd, part),
);
if (found) {
leafCommand = found;
currentLevel = found.subCommands as readonly SlashCommand[] | undefined;
if (found.kind === CommandKind.MCP_PROMPT) {
break;
}
} else {
leafCommand = null;
currentLevel = [];
break;
}
}
let exactMatchAsParent: SlashCommand | undefined;
if (!hasTrailingSpace && currentLevel) {
exactMatchAsParent = currentLevel.find(
(cmd) => matchesCommand(cmd, partial) && cmd.subCommands,
);
if (exactMatchAsParent) {
leafCommand = exactMatchAsParent;
currentLevel = exactMatchAsParent.subCommands;
partial = '';
}
}
const depth = commandPathParts.length;
const isArgumentCompletion = !!(
leafCommand?.completion &&
(hasTrailingSpace ||
(rawParts.length > depth && depth > 0 && partial !== ''))
);
return {
hasTrailingSpace,
commandPathParts,
partial,
currentLevel,
leafCommand,
exactMatchAsParent,
isArgumentCompletion,
};
}, [query, slashCommands]);
}
interface SuggestionsResult {
suggestions: Suggestion[];
isLoading: boolean;
}
interface CompletionPositions {
start: number;
end: number;
}
interface PerfectMatchResult {
isPerfectMatch: boolean;
}
function useCommandSuggestions(
query: string | null,
parserResult: CommandParserResult,
commandContext: CommandContext,
getFzfForCommands: (
commands: readonly SlashCommand[],
) => FzfCommandCacheEntry | null,
getPrefixSuggestions: (
commands: readonly SlashCommand[],
partial: string,
) => SlashCommand[],
): SuggestionsResult {
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
const abortController = new AbortController();
const { signal } = abortController;
const {
isArgumentCompletion,
leafCommand,
commandPathParts,
partial,
currentLevel,
} = parserResult;
if (isArgumentCompletion) {
const fetchAndSetSuggestions = async () => {
if (signal.aborted) return;
// Safety check: ensure leafCommand and completion exist
if (!leafCommand?.completion) {
debugLogger.warn(
'Attempted argument completion without completion function',
);
return;
}
setIsLoading(true);
try {
const rawParts = [...commandPathParts];
if (partial) rawParts.push(partial);
const depth = commandPathParts.length;
const argString = rawParts.slice(depth).join(' ');
const results =
(await leafCommand.completion(
{
...commandContext,
invocation: {
raw: query || `/${rawParts.join(' ')}`,
name: leafCommand.name,
args: argString,
},
},
argString,
)) || [];
if (!signal.aborted) {
const finalSuggestions = results.map((s) => ({
label: s,
value: s,
}));
setSuggestions(finalSuggestions);
setIsLoading(false);
}
} catch (error) {
if (!signal.aborted) {
logErrorSafely(error, 'Argument completion');
setSuggestions([]);
setIsLoading(false);
}
}
};
fetchAndSetSuggestions();
return () => abortController.abort();
}
const commandsToSearch = currentLevel || [];
if (commandsToSearch.length > 0) {
const performFuzzySearch = async () => {
if (signal.aborted) return;
let potentialSuggestions: SlashCommand[] = [];
if (partial === '') {
// If no partial query, show all available commands
potentialSuggestions = commandsToSearch.filter(
(cmd) => cmd.description && !cmd.hidden,
);
} else {
// Use fuzzy search for non-empty partial queries with fallback
const fzfInstance = getFzfForCommands(commandsToSearch);
if (fzfInstance) {
try {
const fzfResults = await fzfInstance.fzf.find(partial);
if (signal.aborted) return;
const uniqueCommands = new Set<SlashCommand>();
fzfResults.forEach((result: FzfCommandResult) => {
const cmd = fzfInstance.commandMap.get(result.item);
if (cmd && cmd.description) {
uniqueCommands.add(cmd);
}
});
potentialSuggestions = Array.from(uniqueCommands);
} catch (error) {
logErrorSafely(
error,
'Fuzzy search - falling back to prefix matching',
);
// Fallback to prefix-based filtering
potentialSuggestions = getPrefixSuggestions(
commandsToSearch,
partial,
);
}
} else {
// Fallback to prefix-based filtering when fzf instance creation fails
potentialSuggestions = getPrefixSuggestions(
commandsToSearch,
partial,
);
}
}
if (!signal.aborted) {
const finalSuggestions = potentialSuggestions.map((cmd) => ({
label: cmd.name,
value: cmd.name,
description: cmd.description,
commandKind: cmd.kind,
}));
setSuggestions(finalSuggestions);
}
};
performFuzzySearch().catch((error) => {
logErrorSafely(error, 'Unexpected fuzzy search error');
if (!signal.aborted) {
// Ultimate fallback: show no suggestions rather than confusing the user
// with all available commands when their query clearly doesn't match anything
setSuggestions([]);
}
});
return () => abortController.abort();
}
setSuggestions([]);
return () => abortController.abort();
}, [
query,
parserResult,
commandContext,
getFzfForCommands,
getPrefixSuggestions,
]);
return { suggestions, isLoading };
}
function useCompletionPositions(
query: string | null,
parserResult: CommandParserResult,
): CompletionPositions {
return useMemo(() => {
if (!query) {
return { start: -1, end: -1 };
}
const { hasTrailingSpace, partial, exactMatchAsParent } = parserResult;
// Set completion start/end positions
if (hasTrailingSpace || exactMatchAsParent) {
return { start: query.length, end: query.length };
} else if (partial) {
if (parserResult.isArgumentCompletion) {
const commandSoFar = `/${parserResult.commandPathParts.join(' ')}`;
const argStartIndex =
commandSoFar.length +
(parserResult.commandPathParts.length > 0 ? 1 : 0);
return { start: argStartIndex, end: query.length };
} else {
return { start: query.length - partial.length, end: query.length };
}
} else {
return { start: 1, end: query.length };
}
}, [query, parserResult]);
}
function usePerfectMatch(
parserResult: CommandParserResult,
): PerfectMatchResult {
return useMemo(() => {
const { hasTrailingSpace, partial, leafCommand, currentLevel } =
parserResult;
if (hasTrailingSpace) {
return { isPerfectMatch: false };
}
if (leafCommand && partial === '' && leafCommand.action) {
return { isPerfectMatch: true };
}
if (currentLevel) {
const perfectMatch = currentLevel.find(
(cmd) => matchesCommand(cmd, partial) && cmd.action,
);
if (perfectMatch) {
return { isPerfectMatch: true };
}
}
return { isPerfectMatch: false };
}, [parserResult]);
}
/**
* Gets the SlashCommand object for a given suggestion by navigating the command hierarchy
* based on the current parser state.
* @param suggestion The suggestion object
* @param parserResult The current parser result with hierarchy information
* @returns The matching SlashCommand or undefined
*/
function getCommandFromSuggestion(
suggestion: Suggestion,
parserResult: CommandParserResult,
): SlashCommand | undefined {
const { currentLevel } = parserResult;
if (!currentLevel) {
return undefined;
}
// suggestion.value is just the command name at the current level (e.g., "list")
// Find it in the current level's commands
const command = currentLevel.find((cmd) =>
matchesCommand(cmd, suggestion.value),
);
return command;
}
export interface UseSlashCompletionProps {
enabled: boolean;
query: string | null;
slashCommands: readonly SlashCommand[];
commandContext: CommandContext;
setSuggestions: (suggestions: Suggestion[]) => void;
setIsLoadingSuggestions: (isLoading: boolean) => void;
setIsPerfectMatch: (isMatch: boolean) => void;
}
export function useSlashCompletion(props: UseSlashCompletionProps): {
completionStart: number;
completionEnd: number;
getCommandFromSuggestion: (
suggestion: Suggestion,
) => SlashCommand | undefined;
} {
const {
enabled,
query,
slashCommands,
commandContext,
setSuggestions,
setIsLoadingSuggestions,
setIsPerfectMatch,
} = props;
const [completionStart, setCompletionStart] = useState(-1);
const [completionEnd, setCompletionEnd] = useState(-1);
// Simplified cache for AsyncFzf instances - WeakMap handles automatic cleanup
const fzfInstanceCache = useMemo(
() => new WeakMap<readonly SlashCommand[], FzfCommandCacheEntry>(),
[],
);
// Helper function to create or retrieve cached AsyncFzf instance for a command level
const getFzfForCommands = useMemo(
() => (commands: readonly SlashCommand[]) => {
if (!commands || commands.length === 0) {
return null;
}
// Check if we already have a cached instance
const cached = fzfInstanceCache.get(commands);
if (cached) {
return cached;
}
// Create new fzf instance
const commandItems: string[] = [];
const commandMap = new Map<string, SlashCommand>();
commands.forEach((cmd) => {
if (cmd.description && !cmd.hidden) {
commandItems.push(cmd.name);
commandMap.set(cmd.name, cmd);
if (cmd.altNames) {
cmd.altNames.forEach((alt) => {
commandItems.push(alt);
commandMap.set(alt, cmd);
});
}
}
});
if (commandItems.length === 0) {
return null;
}
try {
const instance: FzfCommandCacheEntry = {
fzf: new AsyncFzf(commandItems, {
fuzzy: 'v2',
casing: 'case-insensitive', // Explicitly enforce case-insensitivity
}),
commandMap,
};
// Cache the instance - WeakMap will handle automatic cleanup
fzfInstanceCache.set(commands, instance);
return instance;
} catch (error) {
logErrorSafely(error, 'FZF instance creation');
return null;
}
},
[fzfInstanceCache],
);
// Memoized helper function for prefix-based filtering to improve performance
const getPrefixSuggestions = useMemo(
() => (commands: readonly SlashCommand[], partial: string) =>
commands.filter(
(cmd) =>
cmd.description &&
!cmd.hidden &&
(cmd.name.toLowerCase().startsWith(partial.toLowerCase()) ||
cmd.altNames?.some((alt) =>
alt.toLowerCase().startsWith(partial.toLowerCase()),
)),
),
[],
);
// Use extracted hooks for better separation of concerns
const parserResult = useCommandParser(query, slashCommands);
const { suggestions: hookSuggestions, isLoading } = useCommandSuggestions(
query,
parserResult,
commandContext,
getFzfForCommands,
getPrefixSuggestions,
);
const { start: calculatedStart, end: calculatedEnd } = useCompletionPositions(
query,
parserResult,
);
const { isPerfectMatch } = usePerfectMatch(parserResult);
// Clear internal state when disabled
useEffect(() => {
if (!enabled) {
setSuggestions([]);
setIsLoadingSuggestions(false);
setIsPerfectMatch(false);
setCompletionStart(-1);
setCompletionEnd(-1);
}
}, [enabled, setSuggestions, setIsLoadingSuggestions, setIsPerfectMatch]);
// Update external state only when enabled
useEffect(() => {
if (!enabled || query === null) {
return;
}
if (isPerfectMatch) {
setSuggestions([]);
} else {
setSuggestions(hookSuggestions);
}
setIsLoadingSuggestions(isLoading);
setIsPerfectMatch(isPerfectMatch);
setCompletionStart(calculatedStart);
setCompletionEnd(calculatedEnd);
}, [
enabled,
query,
hookSuggestions,
isLoading,
isPerfectMatch,
calculatedStart,
calculatedEnd,
setSuggestions,
setIsLoadingSuggestions,
setIsPerfectMatch,
]);
return {
completionStart,
completionEnd,
getCommandFromSuggestion: (suggestion: Suggestion) =>
getCommandFromSuggestion(suggestion, parserResult),
};
}