Files
gemini-cli/packages/cli/src/ui/hooks/useCommandCompletion.tsx

488 lines
14 KiB
TypeScript

/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { useCallback, useMemo, useEffect, useState } from 'react';
import type { Suggestion } from '../components/SuggestionsDisplay.js';
import type { CommandContext, SlashCommand } from '../commands/types.js';
import type { TextBuffer } from '../components/shared/text-buffer.js';
import { logicalPosToOffset } from '../components/shared/text-buffer.js';
import { isSlashCommand } from '../utils/commandUtils.js';
import { toCodePoints } from '../utils/textUtils.js';
import { useAtCompletion } from './useAtCompletion.js';
import { useSlashCompletion } from './useSlashCompletion.js';
import { useShellCompletion } from './useShellCompletion.js';
import type { PromptCompletion } from './usePromptCompletion.js';
import {
usePromptCompletion,
PROMPT_COMPLETION_MIN_LENGTH,
} from './usePromptCompletion.js';
import type { Config } from '@google/gemini-cli-core';
import { useCompletion } from './useCompletion.js';
export enum CompletionMode {
IDLE = 'IDLE',
AT = 'AT',
SLASH = 'SLASH',
PROMPT = 'PROMPT',
SHELL = 'SHELL',
}
export interface UseCommandCompletionReturn {
suggestions: Suggestion[];
activeSuggestionIndex: number;
visibleStartIndex: number;
showSuggestions: boolean;
isLoadingSuggestions: boolean;
isPerfectMatch: boolean;
forceShowShellSuggestions: boolean;
setForceShowShellSuggestions: (value: boolean) => void;
isShellSuggestionsVisible: boolean;
setActiveSuggestionIndex: React.Dispatch<React.SetStateAction<number>>;
resetCompletionState: () => void;
navigateUp: () => void;
navigateDown: () => void;
handleAutocomplete: (indexToUse: number) => void;
promptCompletion: PromptCompletion;
getCommandFromSuggestion: (
suggestion: Suggestion,
) => SlashCommand | undefined;
slashCompletionRange: {
completionStart: number;
completionEnd: number;
getCommandFromSuggestion: (
suggestion: Suggestion,
) => SlashCommand | undefined;
isArgumentCompletion: boolean;
leafCommand: SlashCommand | null;
};
getCompletedText: (suggestion: Suggestion) => string | null;
completionMode: CompletionMode;
}
export interface UseCommandCompletionOptions {
buffer: TextBuffer;
cwd: string;
slashCommands: readonly SlashCommand[];
commandContext: CommandContext;
reverseSearchActive?: boolean;
shellModeActive: boolean;
config?: Config;
active: boolean;
}
export function useCommandCompletion({
buffer,
cwd,
slashCommands,
commandContext,
reverseSearchActive = false,
shellModeActive,
config,
active,
}: UseCommandCompletionOptions): UseCommandCompletionReturn {
const [forceShowShellSuggestions, setForceShowShellSuggestions] =
useState(false);
const {
suggestions,
activeSuggestionIndex,
visibleStartIndex,
isLoadingSuggestions,
isPerfectMatch,
setSuggestions,
setActiveSuggestionIndex,
setIsLoadingSuggestions,
setIsPerfectMatch,
setVisibleStartIndex,
resetCompletionState: baseResetCompletionState,
navigateUp,
navigateDown,
} = useCompletion();
const resetCompletionState = useCallback(() => {
baseResetCompletionState();
setForceShowShellSuggestions(false);
}, [baseResetCompletionState]);
const cursorRow = buffer.cursor[0];
const cursorCol = buffer.cursor[1];
const {
completionMode,
query: memoQuery,
completionStart,
completionEnd,
} = useMemo(() => {
const currentLine = buffer.lines[cursorRow] || '';
const codePoints = toCodePoints(currentLine);
if (shellModeActive) {
return {
completionMode:
currentLine.trim().length === 0
? CompletionMode.IDLE
: CompletionMode.SHELL,
query: '',
completionStart: -1,
completionEnd: -1,
};
}
// FIRST: Check for @ completion (scan backwards from cursor)
// This must happen before slash command check so that `/cmd @file`
// triggers file completion, not just slash command completion.
for (let i = cursorCol - 1; i >= 0; i--) {
const char = codePoints[i];
if (char === ' ') {
let backslashCount = 0;
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,
};
}
}
// THEN: Check for slash command (only if no @ completion is active)
if (cursorRow === 0 && isSlashCommand(currentLine.trim())) {
return {
completionMode: CompletionMode.SLASH,
query: currentLine,
completionStart: 0,
completionEnd: currentLine.length,
};
}
// 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,
};
}
return {
completionMode: CompletionMode.IDLE,
query: null,
completionStart: -1,
completionEnd: -1,
};
}, [cursorRow, cursorCol, buffer.lines, buffer.text, shellModeActive]);
useAtCompletion({
enabled: active && completionMode === CompletionMode.AT,
pattern: memoQuery || '',
config,
cwd,
setSuggestions,
setIsLoadingSuggestions,
});
const slashCompletionRange = useSlashCompletion({
enabled:
active && completionMode === CompletionMode.SLASH && !shellModeActive,
query: memoQuery,
slashCommands,
commandContext,
setSuggestions,
setIsLoadingSuggestions,
setIsPerfectMatch,
});
const shellCompletionRange = useShellCompletion({
enabled: active && completionMode === CompletionMode.SHELL,
line: buffer.lines[cursorRow] || '',
cursorCol,
cwd,
setSuggestions,
setIsLoadingSuggestions,
});
const query =
completionMode === CompletionMode.SHELL
? shellCompletionRange.query
: memoQuery;
const basePromptCompletion = usePromptCompletion({
buffer,
});
const isShellSuggestionsVisible =
completionMode !== CompletionMode.SHELL || forceShowShellSuggestions;
const promptCompletion = useMemo(() => {
if (
completionMode === CompletionMode.SHELL &&
suggestions.length === 1 &&
query != null &&
shellCompletionRange.completionStart === shellCompletionRange.activeStart
) {
const suggestion = suggestions[0];
const textToInsertBase = suggestion.value;
if (
textToInsertBase.startsWith(query) &&
textToInsertBase.length > query.length
) {
const currentLine = buffer.lines[cursorRow] || '';
const start = shellCompletionRange.completionStart;
const end = shellCompletionRange.completionEnd;
let textToInsert = textToInsertBase;
const charAfterCompletion = currentLine[end];
if (
charAfterCompletion !== ' ' &&
!textToInsert.endsWith('/') &&
!textToInsert.endsWith('\\')
) {
textToInsert += ' ';
}
const newText =
currentLine.substring(0, start) +
textToInsert +
currentLine.substring(end);
return {
text: newText,
isActive: true,
isLoading: false,
accept: () => {
buffer.replaceRangeByOffset(
logicalPosToOffset(buffer.lines, cursorRow, start),
logicalPosToOffset(buffer.lines, cursorRow, end),
textToInsert,
);
},
clear: () => {},
markSelected: () => {},
};
}
}
return basePromptCompletion;
}, [
completionMode,
suggestions,
query,
basePromptCompletion,
buffer,
cursorRow,
shellCompletionRange,
]);
useEffect(() => {
setActiveSuggestionIndex(suggestions.length > 0 ? 0 : -1);
setVisibleStartIndex(0);
// Generic perfect match detection for non-slash modes or as a fallback
if (completionMode !== CompletionMode.SLASH) {
if (suggestions.length > 0) {
const firstSuggestion = suggestions[0];
setIsPerfectMatch(firstSuggestion.value === query);
} else {
setIsPerfectMatch(false);
}
}
}, [
suggestions,
setActiveSuggestionIndex,
setVisibleStartIndex,
completionMode,
query,
setIsPerfectMatch,
]);
useEffect(() => {
if (
!active ||
completionMode === CompletionMode.IDLE ||
reverseSearchActive
) {
resetCompletionState();
}
}, [active, completionMode, reverseSearchActive, resetCompletionState]);
const showSuggestions =
active &&
completionMode !== CompletionMode.IDLE &&
!reverseSearchActive &&
isShellSuggestionsVisible &&
(isLoadingSuggestions || suggestions.length > 0);
/**
* Gets the completed text by replacing the completion range with the suggestion value.
* This is the core string replacement logic used by both autocomplete and auto-execute.
*
* @param suggestion The suggestion to apply
* @returns The completed text with the suggestion applied, or null if invalid
*/
const getCompletedText = useCallback(
(suggestion: Suggestion): string | null => {
const currentLine = buffer.lines[cursorRow] || '';
let start = completionStart;
let end = completionEnd;
if (completionMode === CompletionMode.SLASH) {
start = slashCompletionRange.completionStart;
end = slashCompletionRange.completionEnd;
} else if (completionMode === CompletionMode.SHELL) {
start = shellCompletionRange.completionStart;
end = shellCompletionRange.completionEnd;
}
if (start === -1 || end === -1) {
return null;
}
// Apply space padding for slash commands (needed for subcommands like "/chat list")
let suggestionText = suggestion.value;
if (completionMode === CompletionMode.SLASH) {
// Add leading space if completing a subcommand (cursor is after parent command with no space)
if (start === end && start > 1 && currentLine[start - 1] !== ' ') {
suggestionText = ' ' + suggestionText;
}
}
// Build the completed text with proper spacing
return (
currentLine.substring(0, start) +
suggestionText +
currentLine.substring(end)
);
},
[
cursorRow,
buffer.lines,
completionMode,
completionStart,
completionEnd,
slashCompletionRange,
shellCompletionRange,
],
);
const handleAutocomplete = useCallback(
(indexToUse: number) => {
if (indexToUse < 0 || indexToUse >= suggestions.length) {
return;
}
const suggestion = suggestions[indexToUse];
const completedText = getCompletedText(suggestion);
if (completedText === null) {
return;
}
let start = completionStart;
let end = completionEnd;
if (completionMode === CompletionMode.SLASH) {
start = slashCompletionRange.completionStart;
end = slashCompletionRange.completionEnd;
} else if (completionMode === CompletionMode.SHELL) {
start = shellCompletionRange.completionStart;
end = shellCompletionRange.completionEnd;
}
// Add space padding for Tab completion (auto-execute gets padding from getCompletedText)
let suggestionText = suggestion.value;
if (completionMode === CompletionMode.SLASH) {
if (
start === end &&
start > 1 &&
(buffer.lines[cursorRow] || '')[start - 1] !== ' '
) {
suggestionText = ' ' + suggestionText;
}
}
const lineCodePoints = toCodePoints(buffer.lines[cursorRow] || '');
const charAfterCompletion = lineCodePoints[end];
if (
charAfterCompletion !== ' ' &&
!suggestionText.endsWith('/') &&
!suggestionText.endsWith('\\')
) {
suggestionText += ' ';
}
buffer.replaceRangeByOffset(
logicalPosToOffset(buffer.lines, cursorRow, start),
logicalPosToOffset(buffer.lines, cursorRow, end),
suggestionText,
);
},
[
cursorRow,
buffer,
suggestions,
completionMode,
completionStart,
completionEnd,
slashCompletionRange,
shellCompletionRange,
getCompletedText,
],
);
return {
suggestions,
activeSuggestionIndex,
visibleStartIndex,
showSuggestions,
isLoadingSuggestions,
isPerfectMatch,
forceShowShellSuggestions,
setForceShowShellSuggestions,
isShellSuggestionsVisible,
setActiveSuggestionIndex,
resetCompletionState,
navigateUp,
navigateDown,
handleAutocomplete,
promptCompletion,
getCommandFromSuggestion: slashCompletionRange.getCommandFromSuggestion,
slashCompletionRange,
getCompletedText,
completionMode,
};
}