summaryrefslogtreecommitdiff
path: root/packages/cli/src/ui/hooks
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli/src/ui/hooks')
-rw-r--r--packages/cli/src/ui/hooks/useCompletion.ts604
-rw-r--r--packages/cli/src/ui/hooks/useReverseSearchCompletion.test.tsx260
-rw-r--r--packages/cli/src/ui/hooks/useReverseSearchCompletion.tsx91
-rw-r--r--packages/cli/src/ui/hooks/useShellHistory.ts37
-rw-r--r--packages/cli/src/ui/hooks/useSlashCompletion.test.ts (renamed from packages/cli/src/ui/hooks/useCompletion.test.ts)123
-rw-r--r--packages/cli/src/ui/hooks/useSlashCompletion.tsx654
6 files changed, 1131 insertions, 638 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,
};
}
diff --git a/packages/cli/src/ui/hooks/useReverseSearchCompletion.test.tsx b/packages/cli/src/ui/hooks/useReverseSearchCompletion.test.tsx
new file mode 100644
index 00000000..373696ce
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useReverseSearchCompletion.test.tsx
@@ -0,0 +1,260 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/** @vitest-environment jsdom */
+
+import { describe, it, expect } from 'vitest';
+import { renderHook, act } from '@testing-library/react';
+import { useReverseSearchCompletion } from './useReverseSearchCompletion.js';
+import { useTextBuffer } from '../components/shared/text-buffer.js';
+
+describe('useReverseSearchCompletion', () => {
+ function useTextBufferForTest(text: string) {
+ return useTextBuffer({
+ initialText: text,
+ initialCursorOffset: text.length,
+ viewport: { width: 80, height: 20 },
+ isValidPath: () => false,
+ onChange: () => {},
+ });
+ }
+
+ describe('Core Hook Behavior', () => {
+ describe('State Management', () => {
+ it('should initialize with default state', () => {
+ const mockShellHistory = ['echo hello'];
+
+ const { result } = renderHook(() =>
+ useReverseSearchCompletion(
+ useTextBufferForTest(''),
+ mockShellHistory,
+ false,
+ ),
+ );
+
+ expect(result.current.suggestions).toEqual([]);
+ expect(result.current.activeSuggestionIndex).toBe(-1);
+ expect(result.current.visibleStartIndex).toBe(0);
+ expect(result.current.showSuggestions).toBe(false);
+ expect(result.current.isLoadingSuggestions).toBe(false);
+ });
+
+ it('should reset state when reverseSearchActive becomes false', () => {
+ const mockShellHistory = ['echo hello'];
+ const { result, rerender } = renderHook(
+ ({ text, active }) => {
+ const textBuffer = useTextBufferForTest(text);
+ return useReverseSearchCompletion(
+ textBuffer,
+ mockShellHistory,
+ active,
+ );
+ },
+ { initialProps: { text: 'echo', active: true } },
+ );
+
+ // Simulate reverseSearchActive becoming false
+ rerender({ text: 'echo', active: false });
+
+ expect(result.current.suggestions).toEqual([]);
+ expect(result.current.activeSuggestionIndex).toBe(-1);
+ expect(result.current.visibleStartIndex).toBe(0);
+ expect(result.current.showSuggestions).toBe(false);
+ });
+
+ describe('Navigation', () => {
+ it('should handle navigateUp with no suggestions', () => {
+ const mockShellHistory = ['echo hello'];
+
+ const { result } = renderHook(() =>
+ useReverseSearchCompletion(
+ useTextBufferForTest('grep'),
+ mockShellHistory,
+ true,
+ ),
+ );
+
+ act(() => {
+ result.current.navigateUp();
+ });
+
+ expect(result.current.activeSuggestionIndex).toBe(-1);
+ });
+
+ it('should handle navigateDown with no suggestions', () => {
+ const mockShellHistory = ['echo hello'];
+ const { result } = renderHook(() =>
+ useReverseSearchCompletion(
+ useTextBufferForTest('grep'),
+ mockShellHistory,
+ true,
+ ),
+ );
+
+ act(() => {
+ result.current.navigateDown();
+ });
+
+ expect(result.current.activeSuggestionIndex).toBe(-1);
+ });
+
+ it('should navigate up through suggestions with wrap-around', () => {
+ const mockShellHistory = [
+ 'ls -l',
+ 'ls -la',
+ 'cd /some/path',
+ 'git status',
+ 'echo "Hello, World!"',
+ 'echo Hi',
+ ];
+
+ const { result } = renderHook(() =>
+ useReverseSearchCompletion(
+ useTextBufferForTest('echo'),
+ mockShellHistory,
+ true,
+ ),
+ );
+
+ expect(result.current.suggestions.length).toBe(2);
+ expect(result.current.activeSuggestionIndex).toBe(0);
+
+ act(() => {
+ result.current.navigateUp();
+ });
+
+ expect(result.current.activeSuggestionIndex).toBe(1);
+ });
+
+ it('should navigate down through suggestions with wrap-around', () => {
+ const mockShellHistory = [
+ 'ls -l',
+ 'ls -la',
+ 'cd /some/path',
+ 'git status',
+ 'echo "Hello, World!"',
+ 'echo Hi',
+ ];
+ const { result } = renderHook(() =>
+ useReverseSearchCompletion(
+ useTextBufferForTest('ls'),
+ mockShellHistory,
+ true,
+ ),
+ );
+
+ expect(result.current.suggestions.length).toBe(2);
+ expect(result.current.activeSuggestionIndex).toBe(0);
+
+ act(() => {
+ result.current.navigateDown();
+ });
+
+ expect(result.current.activeSuggestionIndex).toBe(1);
+ });
+
+ it('should handle navigation with multiple suggestions', () => {
+ const mockShellHistory = [
+ 'ls -l',
+ 'ls -la',
+ 'cd /some/path/l',
+ 'git status',
+ 'echo "Hello, World!"',
+ 'echo "Hi all"',
+ ];
+
+ const { result } = renderHook(() =>
+ useReverseSearchCompletion(
+ useTextBufferForTest('l'),
+ mockShellHistory,
+ true,
+ ),
+ );
+
+ expect(result.current.suggestions.length).toBe(5);
+ expect(result.current.activeSuggestionIndex).toBe(0);
+
+ act(() => {
+ result.current.navigateDown();
+ });
+ expect(result.current.activeSuggestionIndex).toBe(1);
+
+ act(() => {
+ result.current.navigateDown();
+ });
+ expect(result.current.activeSuggestionIndex).toBe(2);
+
+ act(() => {
+ result.current.navigateUp();
+ });
+ expect(result.current.activeSuggestionIndex).toBe(1);
+
+ act(() => {
+ result.current.navigateUp();
+ });
+ expect(result.current.activeSuggestionIndex).toBe(0);
+
+ act(() => {
+ result.current.navigateUp();
+ });
+ expect(result.current.activeSuggestionIndex).toBe(4);
+ });
+
+ it('should handle navigation with large suggestion lists and scrolling', () => {
+ const largeMockCommands = Array.from(
+ { length: 15 },
+ (_, i) => `echo ${i}`,
+ );
+
+ const { result } = renderHook(() =>
+ useReverseSearchCompletion(
+ useTextBufferForTest('echo'),
+ largeMockCommands,
+ true,
+ ),
+ );
+
+ expect(result.current.suggestions.length).toBe(15);
+ expect(result.current.activeSuggestionIndex).toBe(0);
+ expect(result.current.visibleStartIndex).toBe(0);
+
+ act(() => {
+ result.current.navigateUp();
+ });
+
+ expect(result.current.activeSuggestionIndex).toBe(14);
+ expect(result.current.visibleStartIndex).toBe(Math.max(0, 15 - 8));
+ });
+ });
+ });
+ });
+
+ describe('Filtering', () => {
+ it('filters history by buffer.text and sets showSuggestions', () => {
+ const history = ['foo', 'barfoo', 'baz'];
+ const { result } = renderHook(() =>
+ useReverseSearchCompletion(useTextBufferForTest('foo'), history, true),
+ );
+
+ // should only return the two entries containing "foo"
+ expect(result.current.suggestions.map((s) => s.value)).toEqual([
+ 'foo',
+ 'barfoo',
+ ]);
+ expect(result.current.showSuggestions).toBe(true);
+ });
+
+ it('hides suggestions when there are no matches', () => {
+ const history = ['alpha', 'beta'];
+ const { result } = renderHook(() =>
+ useReverseSearchCompletion(useTextBufferForTest('γ'), history, true),
+ );
+
+ expect(result.current.suggestions).toEqual([]);
+ expect(result.current.showSuggestions).toBe(false);
+ });
+ });
+});
diff --git a/packages/cli/src/ui/hooks/useReverseSearchCompletion.tsx b/packages/cli/src/ui/hooks/useReverseSearchCompletion.tsx
new file mode 100644
index 00000000..1cc7e602
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useReverseSearchCompletion.tsx
@@ -0,0 +1,91 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { useEffect, useCallback } from 'react';
+import { useCompletion } from './useCompletion.js';
+import { TextBuffer } from '../components/shared/text-buffer.js';
+import { Suggestion } from '../components/SuggestionsDisplay.js';
+
+export interface UseReverseSearchCompletionReturn {
+ suggestions: Suggestion[];
+ activeSuggestionIndex: number;
+ visibleStartIndex: number;
+ showSuggestions: boolean;
+ isLoadingSuggestions: boolean;
+ navigateUp: () => void;
+ navigateDown: () => void;
+ handleAutocomplete: (i: number) => void;
+ resetCompletionState: () => void;
+}
+
+export function useReverseSearchCompletion(
+ buffer: TextBuffer,
+ shellHistory: readonly string[],
+ reverseSearchActive: boolean,
+): UseReverseSearchCompletionReturn {
+ const {
+ suggestions,
+ activeSuggestionIndex,
+ visibleStartIndex,
+ showSuggestions,
+ isLoadingSuggestions,
+
+ setSuggestions,
+ setShowSuggestions,
+ setActiveSuggestionIndex,
+ resetCompletionState,
+ navigateUp,
+ navigateDown,
+ } = useCompletion();
+
+ // whenever reverseSearchActive is on, filter history
+ useEffect(() => {
+ if (!reverseSearchActive) {
+ resetCompletionState();
+ return;
+ }
+ const q = buffer.text.toLowerCase();
+ const matches = shellHistory.reduce<Suggestion[]>((acc, cmd) => {
+ const idx = cmd.toLowerCase().indexOf(q);
+ if (idx !== -1) {
+ acc.push({ label: cmd, value: cmd, matchedIndex: idx });
+ }
+ return acc;
+ }, []);
+ setSuggestions(matches);
+ setShowSuggestions(matches.length > 0);
+ setActiveSuggestionIndex(matches.length > 0 ? 0 : -1);
+ }, [
+ buffer.text,
+ shellHistory,
+ reverseSearchActive,
+ resetCompletionState,
+ setActiveSuggestionIndex,
+ setShowSuggestions,
+ setSuggestions,
+ ]);
+
+ const handleAutocomplete = useCallback(
+ (i: number) => {
+ if (i < 0 || i >= suggestions.length) return;
+ buffer.setText(suggestions[i].value);
+ resetCompletionState();
+ },
+ [buffer, suggestions, resetCompletionState],
+ );
+
+ return {
+ suggestions,
+ activeSuggestionIndex,
+ visibleStartIndex,
+ showSuggestions,
+ isLoadingSuggestions,
+ navigateUp,
+ navigateDown,
+ handleAutocomplete,
+ resetCompletionState,
+ };
+}
diff --git a/packages/cli/src/ui/hooks/useShellHistory.ts b/packages/cli/src/ui/hooks/useShellHistory.ts
index 61c7207c..2e18dfbd 100644
--- a/packages/cli/src/ui/hooks/useShellHistory.ts
+++ b/packages/cli/src/ui/hooks/useShellHistory.ts
@@ -13,6 +13,7 @@ const HISTORY_FILE = 'shell_history';
const MAX_HISTORY_LENGTH = 100;
export interface UseShellHistoryReturn {
+ history: string[];
addCommandToHistory: (command: string) => void;
getPreviousCommand: () => string | null;
getNextCommand: () => string | null;
@@ -24,15 +25,32 @@ async function getHistoryFilePath(projectRoot: string): Promise<string> {
return path.join(historyDir, HISTORY_FILE);
}
+// Handle multiline commands
async function readHistoryFile(filePath: string): Promise<string[]> {
try {
- const content = await fs.readFile(filePath, 'utf-8');
- return content.split('\n').filter(Boolean);
- } catch (error) {
- if (isNodeError(error) && error.code === 'ENOENT') {
- return [];
+ const text = await fs.readFile(filePath, 'utf-8');
+ const result: string[] = [];
+ let cur = '';
+
+ for (const raw of text.split(/\r?\n/)) {
+ if (!raw.trim()) continue;
+ const line = raw;
+
+ const m = cur.match(/(\\+)$/);
+ if (m && m[1].length % 2) {
+ // odd number of trailing '\'
+ cur = cur.slice(0, -1) + ' ' + line;
+ } else {
+ if (cur) result.push(cur);
+ cur = line;
+ }
}
- console.error('Error reading shell history:', error);
+
+ if (cur) result.push(cur);
+ return result;
+ } catch (err) {
+ if (isNodeError(err) && err.code === 'ENOENT') return [];
+ console.error('Error reading history:', err);
return [];
}
}
@@ -101,10 +119,15 @@ export function useShellHistory(projectRoot: string): UseShellHistoryReturn {
return history[newIndex] ?? null;
}, [history, historyIndex]);
+ const resetHistoryPosition = useCallback(() => {
+ setHistoryIndex(-1);
+ }, []);
+
return {
+ history,
addCommandToHistory,
getPreviousCommand,
getNextCommand,
- resetHistoryPosition: () => setHistoryIndex(-1),
+ resetHistoryPosition,
};
}
diff --git a/packages/cli/src/ui/hooks/useCompletion.test.ts b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts
index 3a401194..13f8c240 100644
--- a/packages/cli/src/ui/hooks/useCompletion.test.ts
+++ b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts
@@ -8,7 +8,7 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { renderHook, act } from '@testing-library/react';
-import { useCompletion } from './useCompletion.js';
+import { useSlashCompletion } from './useSlashCompletion.js';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as os from 'os';
@@ -16,7 +16,7 @@ import { CommandContext, SlashCommand } from '../commands/types.js';
import { Config, FileDiscoveryService } from '@google/gemini-cli-core';
import { useTextBuffer } from '../components/shared/text-buffer.js';
-describe('useCompletion', () => {
+describe('useSlashCompletion', () => {
let testRootDir: string;
let mockConfig: Config;
@@ -50,7 +50,7 @@ describe('useCompletion', () => {
beforeEach(async () => {
testRootDir = await fs.mkdtemp(
- path.join(os.tmpdir(), 'completion-unit-test-'),
+ path.join(os.tmpdir(), 'slash-completion-unit-test-'),
);
testDirs = [testRootDir];
mockConfig = {
@@ -82,12 +82,13 @@ describe('useCompletion', () => {
{ name: 'dummy', description: 'dummy' },
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
- useCompletion(
+ useSlashCompletion(
useTextBufferForTest(''),
testDirs,
testRootDir,
slashCommands,
mockCommandContext,
+ false,
mockConfig,
),
);
@@ -112,12 +113,13 @@ describe('useCompletion', () => {
const { result, rerender } = renderHook(
({ text }) => {
const textBuffer = useTextBufferForTest(text);
- return useCompletion(
+ return useSlashCompletion(
textBuffer,
testDirs,
testRootDir,
slashCommands,
mockCommandContext,
+ false,
mockConfig,
);
},
@@ -143,12 +145,13 @@ describe('useCompletion', () => {
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
- useCompletion(
+ useSlashCompletion(
useTextBufferForTest('/help'),
testDirs,
testRootDir,
slashCommands,
mockCommandContext,
+ false,
mockConfig,
),
);
@@ -176,12 +179,13 @@ describe('useCompletion', () => {
{ name: 'dummy', description: 'dummy' },
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
- useCompletion(
+ useSlashCompletion(
useTextBufferForTest(''),
testDirs,
testRootDir,
slashCommands,
mockCommandContext,
+ false,
mockConfig,
),
);
@@ -198,12 +202,14 @@ describe('useCompletion', () => {
{ name: 'dummy', description: 'dummy' },
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
- useCompletion(
+ useSlashCompletion(
useTextBufferForTest(''),
testDirs,
testRootDir,
slashCommands,
mockCommandContext,
+ false,
+
mockConfig,
),
);
@@ -223,12 +229,14 @@ describe('useCompletion', () => {
},
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
- useCompletion(
+ useSlashCompletion(
useTextBufferForTest('/h'),
testDirs,
testRootDir,
slashCommands,
mockCommandContext,
+ false,
+
mockConfig,
),
);
@@ -251,12 +259,14 @@ describe('useCompletion', () => {
},
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
- useCompletion(
+ useSlashCompletion(
useTextBufferForTest('/h'),
testDirs,
testRootDir,
slashCommands,
mockCommandContext,
+ false,
+
mockConfig,
),
);
@@ -280,12 +290,14 @@ describe('useCompletion', () => {
{ name: 'chat', description: 'Manage chat' },
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
- useCompletion(
+ useSlashCompletion(
useTextBufferForTest('/'),
testDirs,
testRootDir,
slashCommands,
mockCommandContext,
+ false,
+
mockConfig,
),
);
@@ -326,12 +338,14 @@ describe('useCompletion', () => {
})) as unknown as SlashCommand[];
const { result } = renderHook(() =>
- useCompletion(
+ useSlashCompletion(
useTextBufferForTest('/command'),
testDirs,
testRootDir,
largeMockCommands,
mockCommandContext,
+ false,
+
mockConfig,
),
);
@@ -384,7 +398,7 @@ describe('useCompletion', () => {
},
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
- useCompletion(
+ useSlashCompletion(
useTextBufferForTest('/'),
testDirs,
testRootDir,
@@ -407,7 +421,7 @@ describe('useCompletion', () => {
},
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
- useCompletion(
+ useSlashCompletion(
useTextBufferForTest('/mem'),
testDirs,
testRootDir,
@@ -431,7 +445,7 @@ describe('useCompletion', () => {
},
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
- useCompletion(
+ useSlashCompletion(
useTextBufferForTest('/usag'), // part of the word "usage"
testDirs,
testRootDir,
@@ -458,7 +472,7 @@ describe('useCompletion', () => {
},
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
- useCompletion(
+ useSlashCompletion(
useTextBufferForTest('/clear'), // No trailing space
testDirs,
testRootDir,
@@ -490,7 +504,7 @@ describe('useCompletion', () => {
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
- useCompletion(
+ useSlashCompletion(
useTextBufferForTest(query),
testDirs,
testRootDir,
@@ -511,7 +525,7 @@ describe('useCompletion', () => {
},
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
- useCompletion(
+ useSlashCompletion(
useTextBufferForTest('/clear '),
testDirs,
testRootDir,
@@ -532,7 +546,7 @@ describe('useCompletion', () => {
},
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
- useCompletion(
+ useSlashCompletion(
useTextBufferForTest('/unknown-command'),
testDirs,
testRootDir,
@@ -566,7 +580,7 @@ describe('useCompletion', () => {
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
- useCompletion(
+ useSlashCompletion(
useTextBufferForTest('/memory'), // Note: no trailing space
testDirs,
testRootDir,
@@ -604,7 +618,7 @@ describe('useCompletion', () => {
},
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
- useCompletion(
+ useSlashCompletion(
useTextBufferForTest('/memory'),
testDirs,
testRootDir,
@@ -640,7 +654,7 @@ describe('useCompletion', () => {
},
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
- useCompletion(
+ useSlashCompletion(
useTextBufferForTest('/memory a'),
testDirs,
testRootDir,
@@ -672,7 +686,7 @@ describe('useCompletion', () => {
},
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
- useCompletion(
+ useSlashCompletion(
useTextBufferForTest('/memory dothisnow'),
testDirs,
testRootDir,
@@ -715,7 +729,7 @@ describe('useCompletion', () => {
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
- useCompletion(
+ useSlashCompletion(
useTextBufferForTest('/chat resume my-ch'),
testDirs,
testRootDir,
@@ -759,7 +773,7 @@ describe('useCompletion', () => {
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
- useCompletion(
+ useSlashCompletion(
useTextBufferForTest('/chat resume '),
testDirs,
testRootDir,
@@ -794,12 +808,14 @@ describe('useCompletion', () => {
] as unknown as SlashCommand[];
const { result } = renderHook(() =>
- useCompletion(
+ useSlashCompletion(
useTextBufferForTest('/chat resume '),
testDirs,
testRootDir,
slashCommands,
mockCommandContext,
+ false,
+
mockConfig,
),
);
@@ -822,12 +838,14 @@ describe('useCompletion', () => {
await createTestFile('', 'README.md');
const { result } = renderHook(() =>
- useCompletion(
+ useSlashCompletion(
useTextBufferForTest('@s'),
testDirs,
testRootDir,
[],
mockCommandContext,
+ false,
+
mockConfig,
),
);
@@ -856,12 +874,14 @@ describe('useCompletion', () => {
await createTestFile('', 'src', 'index.ts');
const { result } = renderHook(() =>
- useCompletion(
+ useSlashCompletion(
useTextBufferForTest('@src/comp'),
testDirs,
testRootDir,
[],
mockCommandContext,
+ false,
+
mockConfig,
),
);
@@ -882,12 +902,14 @@ describe('useCompletion', () => {
await createTestFile('', 'src', 'index.ts');
const { result } = renderHook(() =>
- useCompletion(
+ useSlashCompletion(
useTextBufferForTest('@.'),
testDirs,
testRootDir,
[],
mockCommandContext,
+ false,
+
mockConfig,
),
);
@@ -914,12 +936,14 @@ describe('useCompletion', () => {
await createEmptyDir('dist');
const { result } = renderHook(() =>
- useCompletion(
+ useSlashCompletion(
useTextBufferForTest('@d'),
testDirs,
testRootDir,
[],
mockCommandContext,
+ false,
+
mockConfigNoRecursive,
),
);
@@ -940,7 +964,7 @@ describe('useCompletion', () => {
await createTestFile('', 'README.md');
const { result } = renderHook(() =>
- useCompletion(
+ useSlashCompletion(
useTextBufferForTest('@'),
testDirs,
testRootDir,
@@ -975,12 +999,14 @@ describe('useCompletion', () => {
.mockImplementation(() => {});
const { result } = renderHook(() =>
- useCompletion(
+ useSlashCompletion(
useTextBufferForTest('@'),
testDirs,
testRootDir,
[],
mockCommandContext,
+ false,
+
mockConfig,
),
);
@@ -1006,12 +1032,14 @@ describe('useCompletion', () => {
await createEmptyDir('data');
const { result } = renderHook(() =>
- useCompletion(
+ useSlashCompletion(
useTextBufferForTest('@d'),
testDirs,
testRootDir,
[],
mockCommandContext,
+ false,
+
mockConfig,
),
);
@@ -1040,12 +1068,14 @@ describe('useCompletion', () => {
await createTestFile('', 'README.md');
const { result } = renderHook(() =>
- useCompletion(
+ useSlashCompletion(
useTextBufferForTest('@'),
testDirs,
testRootDir,
[],
mockCommandContext,
+ false,
+
mockConfig,
),
);
@@ -1073,12 +1103,14 @@ describe('useCompletion', () => {
await createTestFile('', 'temp', 'temp.log');
const { result } = renderHook(() =>
- useCompletion(
+ useSlashCompletion(
useTextBufferForTest('@t'),
testDirs,
testRootDir,
[],
mockCommandContext,
+ false,
+
mockConfig,
),
);
@@ -1116,12 +1148,14 @@ describe('useCompletion', () => {
const { result } = renderHook(() => {
const textBuffer = useTextBufferForTest('/mem');
- const completion = useCompletion(
+ const completion = useSlashCompletion(
textBuffer,
testDirs,
testRootDir,
slashCommands,
mockCommandContext,
+ false,
+
mockConfig,
);
return { ...completion, textBuffer };
@@ -1158,12 +1192,14 @@ describe('useCompletion', () => {
const { result } = renderHook(() => {
const textBuffer = useTextBufferForTest('/memory');
- const completion = useCompletion(
+ const completion = useSlashCompletion(
textBuffer,
testDirs,
testRootDir,
slashCommands,
mockCommandContext,
+ false,
+
mockConfig,
);
return { ...completion, textBuffer };
@@ -1202,12 +1238,14 @@ describe('useCompletion', () => {
const { result } = renderHook(() => {
const textBuffer = useTextBufferForTest('/?');
- const completion = useCompletion(
+ const completion = useSlashCompletion(
textBuffer,
testDirs,
testRootDir,
slashCommands,
mockCommandContext,
+ false,
+
mockConfig,
);
return { ...completion, textBuffer };
@@ -1229,12 +1267,13 @@ describe('useCompletion', () => {
it('should complete a file path', () => {
const { result } = renderHook(() => {
const textBuffer = useTextBufferForTest('@src/fi');
- const completion = useCompletion(
+ const completion = useSlashCompletion(
textBuffer,
testDirs,
testRootDir,
[],
mockCommandContext,
+ false,
mockConfig,
);
return { ...completion, textBuffer };
@@ -1258,12 +1297,13 @@ describe('useCompletion', () => {
const { result } = renderHook(() => {
const textBuffer = useTextBufferForTest(text, cursorOffset);
- const completion = useCompletion(
+ const completion = useSlashCompletion(
textBuffer,
testDirs,
testRootDir,
[],
mockCommandContext,
+ false,
mockConfig,
);
return { ...completion, textBuffer };
@@ -1286,12 +1326,13 @@ describe('useCompletion', () => {
const { result } = renderHook(() => {
const textBuffer = useTextBufferForTest(text);
- const completion = useCompletion(
+ const completion = useSlashCompletion(
textBuffer,
testDirs,
testRootDir,
[],
mockCommandContext,
+ false,
mockConfig,
);
return { ...completion, textBuffer };
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,
+ };
+}