summaryrefslogtreecommitdiff
path: root/packages/cli/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/cli/src')
-rw-r--r--packages/cli/src/ui/hooks/useAtCompletion.test.ts380
-rw-r--r--packages/cli/src/ui/hooks/useAtCompletion.ts228
-rw-r--r--packages/cli/src/ui/hooks/useCommandCompletion.test.ts1538
-rw-r--r--packages/cli/src/ui/hooks/useCommandCompletion.tsx649
-rw-r--r--packages/cli/src/ui/hooks/useReverseSearchCompletion.tsx9
-rw-r--r--packages/cli/src/ui/hooks/useSlashCompletion.test.ts434
-rw-r--r--packages/cli/src/ui/hooks/useSlashCompletion.ts187
7 files changed, 1569 insertions, 1856 deletions
diff --git a/packages/cli/src/ui/hooks/useAtCompletion.test.ts b/packages/cli/src/ui/hooks/useAtCompletion.test.ts
new file mode 100644
index 00000000..bf2453f5
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useAtCompletion.test.ts
@@ -0,0 +1,380 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/** @vitest-environment jsdom */
+
+import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
+import { renderHook, waitFor, act } from '@testing-library/react';
+import { useAtCompletion } from './useAtCompletion.js';
+import { Config, FileSearch } from '@google/gemini-cli-core';
+import {
+ createTmpDir,
+ cleanupTmpDir,
+ FileSystemStructure,
+} from '@google/gemini-cli-test-utils';
+import { useState } from 'react';
+import { Suggestion } from '../components/SuggestionsDisplay.js';
+
+// Test harness to capture the state from the hook's callbacks.
+function useTestHarnessForAtCompletion(
+ enabled: boolean,
+ pattern: string,
+ config: Config | undefined,
+ cwd: string,
+) {
+ const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
+ const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false);
+
+ useAtCompletion({
+ enabled,
+ pattern,
+ config,
+ cwd,
+ setSuggestions,
+ setIsLoadingSuggestions,
+ });
+
+ return { suggestions, isLoadingSuggestions };
+}
+
+describe('useAtCompletion', () => {
+ let testRootDir: string;
+ let mockConfig: Config;
+
+ beforeEach(() => {
+ mockConfig = {
+ getFileFilteringOptions: vi.fn(() => ({
+ respectGitIgnore: true,
+ respectGeminiIgnore: true,
+ })),
+ } as unknown as Config;
+ vi.clearAllMocks();
+ });
+
+ afterEach(async () => {
+ if (testRootDir) {
+ await cleanupTmpDir(testRootDir);
+ }
+ vi.restoreAllMocks();
+ });
+
+ describe('File Search Logic', () => {
+ it('should perform a recursive search for an empty pattern', async () => {
+ const structure: FileSystemStructure = {
+ 'file.txt': '',
+ src: {
+ 'index.js': '',
+ components: ['Button.tsx', 'Button with spaces.tsx'],
+ },
+ };
+ testRootDir = await createTmpDir(structure);
+
+ const { result } = renderHook(() =>
+ useTestHarnessForAtCompletion(true, '', mockConfig, testRootDir),
+ );
+
+ await waitFor(() => {
+ expect(result.current.suggestions.length).toBeGreaterThan(0);
+ });
+
+ expect(result.current.suggestions.map((s) => s.value)).toEqual([
+ 'src/',
+ 'src/components/',
+ 'file.txt',
+ 'src/components/Button\\ with\\ spaces.tsx',
+ 'src/components/Button.tsx',
+ 'src/index.js',
+ ]);
+ });
+
+ it('should correctly filter the recursive list based on a pattern', async () => {
+ const structure: FileSystemStructure = {
+ 'file.txt': '',
+ src: {
+ 'index.js': '',
+ components: {
+ 'Button.tsx': '',
+ },
+ },
+ };
+ testRootDir = await createTmpDir(structure);
+
+ const { result } = renderHook(() =>
+ useTestHarnessForAtCompletion(true, 'src/', mockConfig, testRootDir),
+ );
+
+ await waitFor(() => {
+ expect(result.current.suggestions.length).toBeGreaterThan(0);
+ });
+
+ expect(result.current.suggestions.map((s) => s.value)).toEqual([
+ 'src/',
+ 'src/components/',
+ 'src/components/Button.tsx',
+ 'src/index.js',
+ ]);
+ });
+
+ it('should append a trailing slash to directory paths in suggestions', async () => {
+ const structure: FileSystemStructure = {
+ 'file.txt': '',
+ dir: {},
+ };
+ testRootDir = await createTmpDir(structure);
+
+ const { result } = renderHook(() =>
+ useTestHarnessForAtCompletion(true, '', mockConfig, testRootDir),
+ );
+
+ await waitFor(() => {
+ expect(result.current.suggestions.length).toBeGreaterThan(0);
+ });
+
+ expect(result.current.suggestions.map((s) => s.value)).toEqual([
+ 'dir/',
+ 'file.txt',
+ ]);
+ });
+ });
+
+ describe('UI State and Loading Behavior', () => {
+ it('should be in a loading state during initial file system crawl', async () => {
+ testRootDir = await createTmpDir({});
+ const { result } = renderHook(() =>
+ useTestHarnessForAtCompletion(true, '', mockConfig, testRootDir),
+ );
+
+ // It's initially true because the effect runs synchronously.
+ expect(result.current.isLoadingSuggestions).toBe(true);
+
+ // Wait for the loading to complete.
+ await waitFor(() => {
+ expect(result.current.isLoadingSuggestions).toBe(false);
+ });
+ });
+
+ it('should NOT show a loading indicator for subsequent searches that complete under 100ms', async () => {
+ const structure: FileSystemStructure = { 'a.txt': '', 'b.txt': '' };
+ testRootDir = await createTmpDir(structure);
+
+ const { result, rerender } = renderHook(
+ ({ pattern }) =>
+ useTestHarnessForAtCompletion(true, pattern, mockConfig, testRootDir),
+ { initialProps: { pattern: 'a' } },
+ );
+
+ await waitFor(() => {
+ expect(result.current.suggestions.map((s) => s.value)).toEqual([
+ 'a.txt',
+ ]);
+ });
+ expect(result.current.isLoadingSuggestions).toBe(false);
+
+ rerender({ pattern: 'b' });
+
+ // Wait for the final result
+ await waitFor(() => {
+ expect(result.current.suggestions.map((s) => s.value)).toEqual([
+ 'b.txt',
+ ]);
+ });
+
+ expect(result.current.isLoadingSuggestions).toBe(false);
+ });
+
+ it('should show a loading indicator and clear old suggestions for subsequent searches that take longer than 100ms', async () => {
+ const structure: FileSystemStructure = { 'a.txt': '', 'b.txt': '' };
+ testRootDir = await createTmpDir(structure);
+
+ // Spy on the search method to introduce an artificial delay
+ const originalSearch = FileSearch.prototype.search;
+ vi.spyOn(FileSearch.prototype, 'search').mockImplementation(
+ async function (...args) {
+ await new Promise((resolve) => setTimeout(resolve, 200));
+ return originalSearch.apply(this, args);
+ },
+ );
+
+ const { result, rerender } = renderHook(
+ ({ pattern }) =>
+ useTestHarnessForAtCompletion(true, pattern, mockConfig, testRootDir),
+ { initialProps: { pattern: 'a' } },
+ );
+
+ // Wait for the initial (slow) search to complete
+ await waitFor(() => {
+ expect(result.current.suggestions.map((s) => s.value)).toEqual([
+ 'a.txt',
+ ]);
+ });
+
+ // Now, rerender to trigger the second search
+ rerender({ pattern: 'b' });
+
+ // Wait for the loading indicator to appear
+ await waitFor(() => {
+ expect(result.current.isLoadingSuggestions).toBe(true);
+ });
+
+ // Suggestions should be cleared while loading
+ expect(result.current.suggestions).toEqual([]);
+
+ // Wait for the final (slow) search to complete
+ await waitFor(
+ () => {
+ expect(result.current.suggestions.map((s) => s.value)).toEqual([
+ 'b.txt',
+ ]);
+ },
+ { timeout: 1000 },
+ ); // Increase timeout for the slow search
+
+ expect(result.current.isLoadingSuggestions).toBe(false);
+ });
+
+ it('should abort the previous search when a new one starts', async () => {
+ const structure: FileSystemStructure = { 'a.txt': '', 'b.txt': '' };
+ testRootDir = await createTmpDir(structure);
+
+ const abortSpy = vi.spyOn(AbortController.prototype, 'abort');
+ const searchSpy = vi
+ .spyOn(FileSearch.prototype, 'search')
+ .mockImplementation(async (...args) => {
+ const delay = args[0] === 'a' ? 500 : 50;
+ await new Promise((resolve) => setTimeout(resolve, delay));
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return [args[0] as any];
+ });
+
+ const { result, rerender } = renderHook(
+ ({ pattern }) =>
+ useTestHarnessForAtCompletion(true, pattern, mockConfig, testRootDir),
+ { initialProps: { pattern: 'a' } },
+ );
+
+ // Wait for the hook to be ready (initialization is complete)
+ await waitFor(() => {
+ expect(searchSpy).toHaveBeenCalledWith('a', expect.any(Object));
+ });
+
+ // Now that the first search is in-flight, trigger the second one.
+ act(() => {
+ rerender({ pattern: 'b' });
+ });
+
+ // The abort should have been called for the first search.
+ expect(abortSpy).toHaveBeenCalledTimes(1);
+
+ // Wait for the final result, which should be from the second, faster search.
+ await waitFor(
+ () => {
+ expect(result.current.suggestions.map((s) => s.value)).toEqual(['b']);
+ },
+ { timeout: 1000 },
+ );
+
+ // The search spy should have been called for both patterns.
+ expect(searchSpy).toHaveBeenCalledWith('b', expect.any(Object));
+
+ vi.restoreAllMocks();
+ });
+ });
+
+ describe('Filtering and Configuration', () => {
+ it('should respect .gitignore files', async () => {
+ const gitignoreContent = ['dist/', '*.log'].join('\n');
+ const structure: FileSystemStructure = {
+ '.git': {},
+ '.gitignore': gitignoreContent,
+ dist: {},
+ 'test.log': '',
+ src: {},
+ };
+ testRootDir = await createTmpDir(structure);
+
+ const { result } = renderHook(() =>
+ useTestHarnessForAtCompletion(true, '', mockConfig, testRootDir),
+ );
+
+ await waitFor(() => {
+ expect(result.current.suggestions.length).toBeGreaterThan(0);
+ });
+
+ expect(result.current.suggestions.map((s) => s.value)).toEqual([
+ 'src/',
+ '.gitignore',
+ ]);
+ });
+
+ it('should work correctly when config is undefined', async () => {
+ const structure: FileSystemStructure = {
+ node_modules: {},
+ src: {},
+ };
+ testRootDir = await createTmpDir(structure);
+
+ const { result } = renderHook(() =>
+ useTestHarnessForAtCompletion(true, '', undefined, testRootDir),
+ );
+
+ await waitFor(() => {
+ expect(result.current.suggestions.length).toBeGreaterThan(0);
+ });
+
+ expect(result.current.suggestions.map((s) => s.value)).toEqual([
+ 'node_modules/',
+ 'src/',
+ ]);
+ });
+
+ it('should reset and re-initialize when the cwd changes', async () => {
+ const structure1: FileSystemStructure = { 'file1.txt': '' };
+ const rootDir1 = await createTmpDir(structure1);
+ const structure2: FileSystemStructure = { 'file2.txt': '' };
+ const rootDir2 = await createTmpDir(structure2);
+
+ const { result, rerender } = renderHook(
+ ({ cwd, pattern }) =>
+ useTestHarnessForAtCompletion(true, pattern, mockConfig, cwd),
+ {
+ initialProps: {
+ cwd: rootDir1,
+ pattern: 'file',
+ },
+ },
+ );
+
+ // Wait for initial suggestions from the first directory
+ await waitFor(() => {
+ expect(result.current.suggestions.map((s) => s.value)).toEqual([
+ 'file1.txt',
+ ]);
+ });
+
+ // Change the CWD
+ act(() => {
+ rerender({ cwd: rootDir2, pattern: 'file' });
+ });
+
+ // After CWD changes, suggestions should be cleared and it should load again.
+ await waitFor(() => {
+ expect(result.current.isLoadingSuggestions).toBe(true);
+ expect(result.current.suggestions).toEqual([]);
+ });
+
+ // Wait for the new suggestions from the second directory
+ await waitFor(() => {
+ expect(result.current.suggestions.map((s) => s.value)).toEqual([
+ 'file2.txt',
+ ]);
+ });
+ expect(result.current.isLoadingSuggestions).toBe(false);
+
+ await cleanupTmpDir(rootDir1);
+ await cleanupTmpDir(rootDir2);
+ });
+ });
+});
diff --git a/packages/cli/src/ui/hooks/useAtCompletion.ts b/packages/cli/src/ui/hooks/useAtCompletion.ts
new file mode 100644
index 00000000..eaa2a5e6
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useAtCompletion.ts
@@ -0,0 +1,228 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { useEffect, useReducer, useRef } from 'react';
+import { Config, FileSearch, escapePath } from '@google/gemini-cli-core';
+import {
+ Suggestion,
+ MAX_SUGGESTIONS_TO_SHOW,
+} from '../components/SuggestionsDisplay.js';
+
+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;
+}
+
+export function useAtCompletion(props: UseAtCompletionProps): void {
+ const {
+ enabled,
+ pattern,
+ config,
+ cwd,
+ setSuggestions,
+ setIsLoadingSuggestions,
+ } = props;
+ const [state, dispatch] = useReducer(atCompletionReducer, initialState);
+ const fileSearch = useRef<FileSearch | null>(null);
+ 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]);
+
+ useEffect(() => {
+ dispatch({ type: 'RESET' });
+ }, [cwd, config]);
+
+ // Reacts to user input (`pattern`) ONLY.
+ useEffect(() => {
+ if (!enabled) {
+ 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 !== state.pattern // Only search if the pattern has changed
+ ) {
+ dispatch({ type: 'SEARCH', payload: pattern });
+ }
+ }, [enabled, pattern, state.status, state.pattern]);
+
+ // The "Worker" that performs async operations based on status.
+ useEffect(() => {
+ const initialize = async () => {
+ try {
+ const searcher = new FileSearch({
+ projectRoot: cwd,
+ ignoreDirs: [],
+ useGitignore:
+ config?.getFileFilteringOptions()?.respectGitIgnore ?? true,
+ useGeminiignore:
+ config?.getFileFilteringOptions()?.respectGeminiIgnore ?? true,
+ cache: true,
+ cacheTtl: 30, // 30 seconds
+ });
+ await searcher.initialize();
+ fileSearch.current = searcher;
+ dispatch({ type: 'INITIALIZE_SUCCESS' });
+ if (state.pattern !== null) {
+ dispatch({ type: 'SEARCH', payload: state.pattern });
+ }
+ } catch (_) {
+ dispatch({ type: 'ERROR' });
+ }
+ };
+
+ const search = async () => {
+ if (!fileSearch.current || state.pattern === null) {
+ return;
+ }
+
+ if (slowSearchTimer.current) {
+ clearTimeout(slowSearchTimer.current);
+ }
+
+ const controller = new AbortController();
+ searchAbortController.current = controller;
+
+ slowSearchTimer.current = setTimeout(() => {
+ dispatch({ type: 'SET_LOADING', payload: true });
+ }, 100);
+
+ try {
+ const results = await fileSearch.current.search(state.pattern, {
+ signal: controller.signal,
+ maxResults: MAX_SUGGESTIONS_TO_SHOW * 3,
+ });
+
+ if (slowSearchTimer.current) {
+ clearTimeout(slowSearchTimer.current);
+ }
+
+ if (controller.signal.aborted) {
+ return;
+ }
+
+ const suggestions = results.map((p) => ({
+ label: p,
+ value: escapePath(p),
+ }));
+ dispatch({ type: 'SEARCH_SUCCESS', payload: suggestions });
+ } catch (error) {
+ if (!(error instanceof Error && error.name === 'AbortError')) {
+ dispatch({ type: 'ERROR' });
+ }
+ }
+ };
+
+ if (state.status === AtCompletionStatus.INITIALIZING) {
+ initialize();
+ } else if (state.status === AtCompletionStatus.SEARCHING) {
+ search();
+ }
+
+ return () => {
+ searchAbortController.current?.abort();
+ if (slowSearchTimer.current) {
+ clearTimeout(slowSearchTimer.current);
+ }
+ };
+ }, [state.status, state.pattern, config, cwd]);
+}
diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.test.ts b/packages/cli/src/ui/hooks/useCommandCompletion.test.ts
index 005b4e7d..a3c96935 100644
--- a/packages/cli/src/ui/hooks/useCommandCompletion.test.ts
+++ b/packages/cli/src/ui/hooks/useCommandCompletion.test.ts
@@ -9,33 +9,84 @@
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
import { renderHook, act, waitFor } from '@testing-library/react';
import { useCommandCompletion } from './useCommandCompletion.js';
-import * as fs from 'fs/promises';
-import * as path from 'path';
-import * as os from 'os';
-import { CommandContext, SlashCommand } from '../commands/types.js';
-import { Config, FileDiscoveryService } from '@google/gemini-cli-core';
+import { CommandContext } from '../commands/types.js';
+import { Config } from '@google/gemini-cli-core';
import { useTextBuffer } from '../components/shared/text-buffer.js';
+import { useEffect } from 'react';
+import { Suggestion } from '../components/SuggestionsDisplay.js';
+import { UseAtCompletionProps, useAtCompletion } from './useAtCompletion.js';
+import {
+ UseSlashCompletionProps,
+ useSlashCompletion,
+} from './useSlashCompletion.js';
-describe('useCommandCompletion', () => {
- let testRootDir: string;
- let mockConfig: Config;
+vi.mock('./useAtCompletion', () => ({
+ useAtCompletion: vi.fn(),
+}));
- // A minimal mock is sufficient for these tests.
- const mockCommandContext = {} as CommandContext;
- let testDirs: string[];
+vi.mock('./useSlashCompletion', () => ({
+ useSlashCompletion: vi.fn(() => ({
+ completionStart: 0,
+ completionEnd: 0,
+ })),
+}));
- async function createEmptyDir(...pathSegments: string[]) {
- const fullPath = path.join(testRootDir, ...pathSegments);
- await fs.mkdir(fullPath, { recursive: true });
- return fullPath;
- }
+// Helper to set up mocks in a consistent way for both child hooks
+const setupMocks = ({
+ atSuggestions = [],
+ slashSuggestions = [],
+ isLoading = false,
+ isPerfectMatch = false,
+ slashCompletionRange = { completionStart: 0, completionEnd: 0 },
+}: {
+ atSuggestions?: Suggestion[];
+ slashSuggestions?: Suggestion[];
+ isLoading?: boolean;
+ isPerfectMatch?: boolean;
+ slashCompletionRange?: { completionStart: number; completionEnd: number };
+}) => {
+ // Mock for @-completions
+ (useAtCompletion as vi.Mock).mockImplementation(
+ ({
+ enabled,
+ setSuggestions,
+ setIsLoadingSuggestions,
+ }: UseAtCompletionProps) => {
+ useEffect(() => {
+ if (enabled) {
+ setIsLoadingSuggestions(isLoading);
+ setSuggestions(atSuggestions);
+ }
+ }, [enabled, setSuggestions, setIsLoadingSuggestions]);
+ },
+ );
- async function createTestFile(content: string, ...pathSegments: string[]) {
- const fullPath = path.join(testRootDir, ...pathSegments);
- await fs.mkdir(path.dirname(fullPath), { recursive: true });
- await fs.writeFile(fullPath, content);
- return fullPath;
- }
+ // Mock for /-completions
+ (useSlashCompletion as vi.Mock).mockImplementation(
+ ({
+ enabled,
+ setSuggestions,
+ setIsLoadingSuggestions,
+ setIsPerfectMatch,
+ }: UseSlashCompletionProps) => {
+ useEffect(() => {
+ if (enabled) {
+ setIsLoadingSuggestions(isLoading);
+ setSuggestions(slashSuggestions);
+ setIsPerfectMatch(isPerfectMatch);
+ }
+ }, [enabled, setSuggestions, setIsLoadingSuggestions, setIsPerfectMatch]);
+ // The hook returns a range, which we can mock simply
+ return slashCompletionRange;
+ },
+ );
+};
+
+describe('useCommandCompletion', () => {
+ const mockCommandContext = {} as CommandContext;
+ const mockConfig = {} as Config;
+ const testDirs: string[] = [];
+ const testRootDir = '/';
// Helper to create real TextBuffer objects within renderHook
function useTextBufferForTest(text: string, cursorOffset?: number) {
@@ -48,45 +99,25 @@ describe('useCommandCompletion', () => {
});
}
- beforeEach(async () => {
- testRootDir = await fs.mkdtemp(
- path.join(os.tmpdir(), 'slash-completion-unit-test-'),
- );
- testDirs = [testRootDir];
- mockConfig = {
- getTargetDir: () => testRootDir,
- getWorkspaceContext: () => ({
- getDirectories: () => testDirs,
- }),
- getProjectRoot: () => testRootDir,
- getFileFilteringOptions: vi.fn(() => ({
- respectGitIgnore: true,
- respectGeminiIgnore: true,
- })),
- getEnableRecursiveFileSearch: vi.fn(() => true),
- getFileService: vi.fn(() => new FileDiscoveryService(testRootDir)),
- } as unknown as Config;
-
+ beforeEach(() => {
vi.clearAllMocks();
+ // Reset to default mocks before each test
+ setupMocks({});
});
- afterEach(async () => {
+ afterEach(() => {
vi.restoreAllMocks();
- await fs.rm(testRootDir, { recursive: true, force: true });
});
describe('Core Hook Behavior', () => {
describe('State Management', () => {
it('should initialize with default state', () => {
- const slashCommands = [
- { name: 'dummy', description: 'dummy' },
- ] as unknown as SlashCommand[];
const { result } = renderHook(() =>
useCommandCompletion(
useTextBufferForTest(''),
testDirs,
testRootDir,
- slashCommands,
+ [],
mockCommandContext,
false,
mockConfig,
@@ -100,1056 +131,299 @@ describe('useCommandCompletion', () => {
expect(result.current.isLoadingSuggestions).toBe(false);
});
- it('should reset state when isActive becomes false', () => {
- const slashCommands = [
- {
- name: 'help',
- altNames: ['?'],
- description: 'Show help',
- action: vi.fn(),
- },
- ] as unknown as SlashCommand[];
-
- const { result, rerender } = renderHook(
- ({ text }) => {
- const textBuffer = useTextBufferForTest(text);
- return useCommandCompletion(
- textBuffer,
- testDirs,
- testRootDir,
- slashCommands,
- mockCommandContext,
- false,
- mockConfig,
- );
- },
- { initialProps: { text: '/help' } },
- );
-
- // Inactive because of the leading space
- rerender({ text: ' /help' });
-
- 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 all state to default values', async () => {
- const slashCommands = [
- {
- name: 'help',
- description: 'Show help',
- },
- ] as unknown as SlashCommand[];
+ it('should reset state when completion mode becomes IDLE', async () => {
+ setupMocks({
+ atSuggestions: [{ label: 'src/file.txt', value: 'src/file.txt' }],
+ });
- const { result } = renderHook(() =>
- useCommandCompletion(
- useTextBufferForTest('/help'),
+ const { result } = renderHook(() => {
+ const textBuffer = useTextBufferForTest('@file');
+ const completion = useCommandCompletion(
+ textBuffer,
testDirs,
testRootDir,
- slashCommands,
+ [],
mockCommandContext,
false,
mockConfig,
- ),
- );
-
- act(() => {
- result.current.setActiveSuggestionIndex(5);
- result.current.setShowSuggestions(true);
- });
-
- act(() => {
- result.current.resetCompletionState();
+ );
+ return { completion, textBuffer };
});
- // Wait for async suggestions clearing
await waitFor(() => {
- expect(result.current.suggestions).toEqual([]);
+ expect(result.current.completion.suggestions).toHaveLength(1);
});
- 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);
- });
- });
-
- describe('Navigation', () => {
- it('should handle navigateUp with no suggestions', () => {
- const slashCommands = [
- { name: 'dummy', description: 'dummy' },
- ] as unknown as SlashCommand[];
- const { result } = renderHook(() =>
- useCommandCompletion(
- useTextBufferForTest(''),
- testDirs,
- testRootDir,
- slashCommands,
- mockCommandContext,
- false,
- mockConfig,
- ),
- );
+ expect(result.current.completion.showSuggestions).toBe(true);
act(() => {
- result.current.navigateUp();
+ result.current.textBuffer.replaceRangeByOffset(
+ 0,
+ 5,
+ 'just some text',
+ );
});
- expect(result.current.activeSuggestionIndex).toBe(-1);
- });
-
- it('should handle navigateDown with no suggestions', () => {
- const slashCommands = [
- { name: 'dummy', description: 'dummy' },
- ] as unknown as SlashCommand[];
- const { result } = renderHook(() =>
- useCommandCompletion(
- useTextBufferForTest(''),
- testDirs,
- testRootDir,
- slashCommands,
- mockCommandContext,
- false,
-
- mockConfig,
- ),
- );
-
- act(() => {
- result.current.navigateDown();
+ await waitFor(() => {
+ expect(result.current.completion.showSuggestions).toBe(false);
});
-
- expect(result.current.activeSuggestionIndex).toBe(-1);
});
- it('should navigate up through suggestions with wrap-around', () => {
- const slashCommands = [
- {
- name: 'help',
- description: 'Show help',
- },
- ] as unknown as SlashCommand[];
+ it('should reset all state to default values', () => {
const { result } = renderHook(() =>
useCommandCompletion(
- useTextBufferForTest('/h'),
+ useTextBufferForTest('@files'),
testDirs,
testRootDir,
- slashCommands,
+ [],
mockCommandContext,
false,
-
mockConfig,
),
);
- expect(result.current.suggestions.length).toBe(1);
- expect(result.current.activeSuggestionIndex).toBe(0);
-
act(() => {
- result.current.navigateUp();
+ result.current.setActiveSuggestionIndex(5);
+ result.current.setShowSuggestions(true);
});
- expect(result.current.activeSuggestionIndex).toBe(0);
- });
-
- it('should navigate down through suggestions with wrap-around', () => {
- const slashCommands = [
- {
- name: 'help',
- description: 'Show help',
- },
- ] as unknown as SlashCommand[];
- const { result } = renderHook(() =>
- useCommandCompletion(
- useTextBufferForTest('/h'),
- testDirs,
- testRootDir,
- slashCommands,
- mockCommandContext,
- false,
-
- mockConfig,
- ),
- );
-
- expect(result.current.suggestions.length).toBe(1);
- expect(result.current.activeSuggestionIndex).toBe(0);
-
act(() => {
- result.current.navigateDown();
+ result.current.resetCompletionState();
});
- expect(result.current.activeSuggestionIndex).toBe(0);
+ expect(result.current.activeSuggestionIndex).toBe(-1);
+ expect(result.current.visibleStartIndex).toBe(0);
+ expect(result.current.showSuggestions).toBe(false);
});
- it('should handle navigation with multiple suggestions', () => {
- const slashCommands = [
- { name: 'help', description: 'Show help' },
- { name: 'stats', description: 'Show stats' },
- { name: 'clear', description: 'Clear screen' },
- { name: 'memory', description: 'Manage memory' },
- { name: 'chat', description: 'Manage chat' },
- ] as unknown as SlashCommand[];
- const { result } = renderHook(() =>
+ it('should call useAtCompletion with the correct query for an escaped space', async () => {
+ const text = '@src/a\\ file.txt';
+ renderHook(() =>
useCommandCompletion(
- useTextBufferForTest('/'),
+ useTextBufferForTest(text),
testDirs,
testRootDir,
- slashCommands,
+ [],
mockCommandContext,
false,
-
mockConfig,
),
);
- 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();
+ await waitFor(() => {
+ expect(useAtCompletion).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ enabled: true,
+ pattern: 'src/a\\ file.txt',
+ }),
+ );
});
- expect(result.current.activeSuggestionIndex).toBe(4);
});
- it('should handle navigation with large suggestion lists and scrolling', () => {
- const largeMockCommands = Array.from({ length: 15 }, (_, i) => ({
- name: `command${i}`,
- description: `Command ${i}`,
- })) as unknown as SlashCommand[];
+ it('should correctly identify the completion context with multiple @ symbols', async () => {
+ const text = '@file1 @file2';
+ const cursorOffset = 3; // @fi|le1 @file2
- const { result } = renderHook(() =>
+ renderHook(() =>
useCommandCompletion(
- useTextBufferForTest('/command'),
+ useTextBufferForTest(text, cursorOffset),
testDirs,
testRootDir,
- largeMockCommands,
+ [],
mockCommandContext,
false,
-
mockConfig,
),
);
- 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('Slash Command Completion (`/`)', () => {
- describe('Top-Level Commands', () => {
- it('should suggest all top-level commands for the root slash', async () => {
- const slashCommands = [
- {
- name: 'help',
- altNames: ['?'],
- description: 'Show help',
- },
- {
- name: 'stats',
- altNames: ['usage'],
- description: 'check session stats. Usage: /stats [model|tools]',
- },
- {
- name: 'clear',
- description: 'Clear the screen',
- },
- {
- name: 'memory',
- description: 'Manage memory',
- subCommands: [
- {
- name: 'show',
- description: 'Show memory',
- },
- ],
- },
- {
- name: 'chat',
- description: 'Manage chat history',
- },
- ] as unknown as SlashCommand[];
- const { result } = renderHook(() =>
- useCommandCompletion(
- useTextBufferForTest('/'),
- testDirs,
- testRootDir,
- slashCommands,
- mockCommandContext,
- ),
- );
-
- expect(result.current.suggestions.length).toBe(slashCommands.length);
- expect(result.current.suggestions.map((s) => s.label)).toEqual(
- expect.arrayContaining(['help', 'clear', 'memory', 'chat', 'stats']),
- );
- });
-
- it('should filter commands based on partial input', async () => {
- const slashCommands = [
- {
- name: 'memory',
- description: 'Manage memory',
- },
- ] as unknown as SlashCommand[];
- const { result } = renderHook(() =>
- useCommandCompletion(
- useTextBufferForTest('/mem'),
- testDirs,
- testRootDir,
- slashCommands,
- mockCommandContext,
- ),
- );
-
- expect(result.current.suggestions).toEqual([
- { label: 'memory', value: 'memory', description: 'Manage memory' },
- ]);
- expect(result.current.showSuggestions).toBe(true);
- });
-
- it('should suggest commands based on partial altNames', async () => {
- const slashCommands = [
- {
- name: 'stats',
- altNames: ['usage'],
- description: 'check session stats. Usage: /stats [model|tools]',
- },
- ] as unknown as SlashCommand[];
- const { result } = renderHook(() =>
- useCommandCompletion(
- useTextBufferForTest('/usag'), // part of the word "usage"
- testDirs,
- testRootDir,
- slashCommands,
- mockCommandContext,
- ),
- );
-
- expect(result.current.suggestions).toEqual([
- {
- label: 'stats',
- value: 'stats',
- description: 'check session stats. Usage: /stats [model|tools]',
- },
- ]);
- });
-
- it('should NOT provide suggestions for a perfectly typed command that is a leaf node', async () => {
- const slashCommands = [
- {
- name: 'clear',
- description: 'Clear the screen',
- action: vi.fn(),
- },
- ] as unknown as SlashCommand[];
- const { result } = renderHook(() =>
- useCommandCompletion(
- useTextBufferForTest('/clear'), // No trailing space
- testDirs,
- testRootDir,
- slashCommands,
- mockCommandContext,
- ),
- );
-
- expect(result.current.suggestions).toHaveLength(0);
- expect(result.current.showSuggestions).toBe(false);
- });
-
- it.each([['/?'], ['/usage']])(
- 'should not suggest commands when altNames is fully typed',
- async (query) => {
- const mockSlashCommands = [
- {
- name: 'help',
- altNames: ['?'],
- description: 'Show help',
- action: vi.fn(),
- },
- {
- name: 'stats',
- altNames: ['usage'],
- description: 'check session stats. Usage: /stats [model|tools]',
- action: vi.fn(),
- },
- ] as unknown as SlashCommand[];
-
- const { result } = renderHook(() =>
- useCommandCompletion(
- useTextBufferForTest(query),
- testDirs,
- testRootDir,
- mockSlashCommands,
- mockCommandContext,
- ),
- );
-
- expect(result.current.suggestions).toHaveLength(0);
- },
- );
-
- it('should not provide suggestions for a fully typed command that has no sub-commands or argument completion', async () => {
- const slashCommands = [
- {
- name: 'clear',
- description: 'Clear the screen',
- },
- ] as unknown as SlashCommand[];
- const { result } = renderHook(() =>
- useCommandCompletion(
- useTextBufferForTest('/clear '),
- testDirs,
- testRootDir,
- slashCommands,
- mockCommandContext,
- ),
- );
-
- expect(result.current.suggestions).toHaveLength(0);
- expect(result.current.showSuggestions).toBe(false);
- });
-
- it('should not provide suggestions for an unknown command', async () => {
- const slashCommands = [
- {
- name: 'help',
- description: 'Show help',
- },
- ] as unknown as SlashCommand[];
- const { result } = renderHook(() =>
- useCommandCompletion(
- useTextBufferForTest('/unknown-command'),
- testDirs,
- testRootDir,
- slashCommands,
- mockCommandContext,
- ),
- );
-
- expect(result.current.suggestions).toHaveLength(0);
- expect(result.current.showSuggestions).toBe(false);
- });
- });
-
- describe('Sub-Commands', () => {
- it('should suggest sub-commands for a parent command', async () => {
- const slashCommands = [
- {
- name: 'memory',
- description: 'Manage memory',
- subCommands: [
- {
- name: 'show',
- description: 'Show memory',
- },
- {
- name: 'add',
- description: 'Add to memory',
- },
- ],
- },
- ] as unknown as SlashCommand[];
-
- const { result } = renderHook(() =>
- useCommandCompletion(
- useTextBufferForTest('/memory'), // Note: no trailing space
- testDirs,
- testRootDir,
- slashCommands,
- mockCommandContext,
- ),
- );
-
- // Assert that suggestions for sub-commands are shown immediately
- expect(result.current.suggestions).toHaveLength(2);
- expect(result.current.suggestions).toEqual(
- expect.arrayContaining([
- { label: 'show', value: 'show', description: 'Show memory' },
- { label: 'add', value: 'add', description: 'Add to memory' },
- ]),
- );
- expect(result.current.showSuggestions).toBe(true);
- });
-
- it('should suggest all sub-commands when the query ends with the parent command and a space', async () => {
- const slashCommands = [
- {
- name: 'memory',
- description: 'Manage memory',
- subCommands: [
- {
- name: 'show',
- description: 'Show memory',
- },
- {
- name: 'add',
- description: 'Add to memory',
- },
- ],
- },
- ] as unknown as SlashCommand[];
- const { result } = renderHook(() =>
- useCommandCompletion(
- useTextBufferForTest('/memory'),
- testDirs,
- testRootDir,
- slashCommands,
- mockCommandContext,
- ),
- );
-
- expect(result.current.suggestions).toHaveLength(2);
- expect(result.current.suggestions).toEqual(
- expect.arrayContaining([
- { label: 'show', value: 'show', description: 'Show memory' },
- { label: 'add', value: 'add', description: 'Add to memory' },
- ]),
- );
- });
-
- it('should filter sub-commands by prefix', async () => {
- const slashCommands = [
- {
- name: 'memory',
- description: 'Manage memory',
- subCommands: [
- {
- name: 'show',
- description: 'Show memory',
- },
- {
- name: 'add',
- description: 'Add to memory',
- },
- ],
- },
- ] as unknown as SlashCommand[];
- const { result } = renderHook(() =>
- useCommandCompletion(
- useTextBufferForTest('/memory a'),
- testDirs,
- testRootDir,
- slashCommands,
- mockCommandContext,
- ),
- );
-
- expect(result.current.suggestions).toEqual([
- { label: 'add', value: 'add', description: 'Add to memory' },
- ]);
- });
-
- it('should provide no suggestions for an invalid sub-command', async () => {
- const slashCommands = [
- {
- name: 'memory',
- description: 'Manage memory',
- subCommands: [
- {
- name: 'show',
- description: 'Show memory',
- },
- {
- name: 'add',
- description: 'Add to memory',
- },
- ],
- },
- ] as unknown as SlashCommand[];
- const { result } = renderHook(() =>
- useCommandCompletion(
- useTextBufferForTest('/memory dothisnow'),
- testDirs,
- testRootDir,
- slashCommands,
- mockCommandContext,
- ),
- );
-
- expect(result.current.suggestions).toHaveLength(0);
- expect(result.current.showSuggestions).toBe(false);
- });
- });
-
- describe('Argument Completion', () => {
- it('should call the command.completion function for argument suggestions', async () => {
- const availableTags = [
- 'my-chat-tag-1',
- 'my-chat-tag-2',
- 'another-channel',
- ];
- const mockCompletionFn = vi
- .fn()
- .mockImplementation(
- async (_context: CommandContext, partialArg: string) =>
- availableTags.filter((tag) => tag.startsWith(partialArg)),
+ await waitFor(() => {
+ expect(useAtCompletion).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ enabled: true,
+ pattern: 'file1',
+ }),
);
-
- const slashCommands = [
- {
- name: 'chat',
- description: 'Manage chat history',
- subCommands: [
- {
- name: 'resume',
- description: 'Resume a saved chat',
- completion: mockCompletionFn,
- },
- ],
- },
- ] as unknown as SlashCommand[];
-
- const { result } = renderHook(() =>
- useCommandCompletion(
- useTextBufferForTest('/chat resume my-ch'),
- testDirs,
- testRootDir,
- slashCommands,
- mockCommandContext,
- ),
- );
-
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 150));
});
-
- expect(mockCompletionFn).toHaveBeenCalledWith(
- mockCommandContext,
- 'my-ch',
- );
-
- expect(result.current.suggestions).toEqual([
- { label: 'my-chat-tag-1', value: 'my-chat-tag-1' },
- { label: 'my-chat-tag-2', value: 'my-chat-tag-2' },
- ]);
});
+ });
- it('should call command.completion with an empty string when args start with a space', async () => {
- const mockCompletionFn = vi
- .fn()
- .mockResolvedValue(['my-chat-tag-1', 'my-chat-tag-2', 'my-channel']);
-
- const slashCommands = [
- {
- name: 'chat',
- description: 'Manage chat history',
- subCommands: [
- {
- name: 'resume',
- description: 'Resume a saved chat',
- completion: mockCompletionFn,
- },
- ],
- },
- ] as unknown as SlashCommand[];
-
- const { result } = renderHook(() =>
- useCommandCompletion(
- useTextBufferForTest('/chat resume '),
- testDirs,
- testRootDir,
- slashCommands,
- mockCommandContext,
- ),
- );
-
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 150));
- });
+ describe('Navigation', () => {
+ const mockSuggestions = [
+ { label: 'cmd1', value: 'cmd1' },
+ { label: 'cmd2', value: 'cmd2' },
+ { label: 'cmd3', value: 'cmd3' },
+ { label: 'cmd4', value: 'cmd4' },
+ { label: 'cmd5', value: 'cmd5' },
+ ];
- expect(mockCompletionFn).toHaveBeenCalledWith(mockCommandContext, '');
- expect(result.current.suggestions).toHaveLength(3);
- expect(result.current.showSuggestions).toBe(true);
+ beforeEach(() => {
+ setupMocks({ slashSuggestions: mockSuggestions });
});
- it('should handle completion function that returns null', async () => {
- const completionFn = vi.fn().mockResolvedValue(null);
- const slashCommands = [
- {
- name: 'chat',
- description: 'Manage chat history',
- subCommands: [
- {
- name: 'resume',
- description: 'Resume a saved chat',
- completion: completionFn,
- },
- ],
- },
- ] as unknown as SlashCommand[];
+ it('should handle navigateUp with no suggestions', () => {
+ setupMocks({ slashSuggestions: [] });
const { result } = renderHook(() =>
useCommandCompletion(
- useTextBufferForTest('/chat resume '),
+ useTextBufferForTest('/'),
testDirs,
testRootDir,
- slashCommands,
+ [],
mockCommandContext,
false,
-
mockConfig,
),
);
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 150));
+ act(() => {
+ result.current.navigateUp();
});
- expect(result.current.suggestions).toHaveLength(0);
- expect(result.current.showSuggestions).toBe(false);
+ expect(result.current.activeSuggestionIndex).toBe(-1);
});
- });
- });
-
- describe('File Path Completion (`@`)', () => {
- describe('Basic Completion', () => {
- it('should use glob for top-level @ completions when available', async () => {
- await createTestFile('', 'src', 'index.ts');
- await createTestFile('', 'derp', 'script.ts');
- await createTestFile('', 'README.md');
+ it('should handle navigateDown with no suggestions', () => {
+ setupMocks({ slashSuggestions: [] });
const { result } = renderHook(() =>
useCommandCompletion(
- useTextBufferForTest('@s'),
+ useTextBufferForTest('/'),
testDirs,
testRootDir,
[],
mockCommandContext,
false,
-
mockConfig,
),
);
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 150));
+ act(() => {
+ result.current.navigateDown();
});
- expect(result.current.suggestions).toHaveLength(2);
- expect(result.current.suggestions).toEqual(
- expect.arrayContaining([
- {
- label: 'derp/script.ts',
- value: 'derp/script.ts',
- },
- { label: 'src', value: 'src' },
- ]),
- );
+ expect(result.current.activeSuggestionIndex).toBe(-1);
});
- it('should handle directory-specific completions with git filtering', async () => {
- await createEmptyDir('.git');
- await createTestFile('*.log', '.gitignore');
- await createTestFile('', 'src', 'component.tsx');
- await createTestFile('', 'src', 'temp.log');
- await createTestFile('', 'src', 'index.ts');
-
+ it('should navigate up through suggestions with wrap-around', async () => {
const { result } = renderHook(() =>
useCommandCompletion(
- useTextBufferForTest('@src/comp'),
+ useTextBufferForTest('/'),
testDirs,
testRootDir,
[],
mockCommandContext,
false,
-
mockConfig,
),
);
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 150));
+ await waitFor(() => {
+ expect(result.current.suggestions.length).toBe(5);
});
- // Should filter out .log files but include matching .tsx files
- expect(result.current.suggestions).toEqual([
- { label: 'component.tsx', value: 'component.tsx' },
- ]);
- });
-
- it('should include dotfiles in glob search when input starts with a dot', async () => {
- await createTestFile('', '.env');
- await createTestFile('', '.gitignore');
- await createTestFile('', 'src', 'index.ts');
-
- const { result } = renderHook(() =>
- useCommandCompletion(
- useTextBufferForTest('@.'),
- testDirs,
- testRootDir,
- [],
- mockCommandContext,
- false,
-
- mockConfig,
- ),
- );
+ expect(result.current.activeSuggestionIndex).toBe(0);
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 150));
+ act(() => {
+ result.current.navigateUp();
});
- expect(result.current.suggestions).toEqual([
- { label: '.env', value: '.env' },
- { label: '.gitignore', value: '.gitignore' },
- ]);
+ expect(result.current.activeSuggestionIndex).toBe(4);
});
- });
-
- describe('Configuration-based Behavior', () => {
- it('should not perform recursive search when disabled in config', async () => {
- const mockConfigNoRecursive = {
- ...mockConfig,
- getEnableRecursiveFileSearch: vi.fn(() => false),
- } as unknown as Config;
-
- await createEmptyDir('data');
- await createEmptyDir('dist');
+ it('should navigate down through suggestions with wrap-around', async () => {
const { result } = renderHook(() =>
useCommandCompletion(
- useTextBufferForTest('@d'),
+ useTextBufferForTest('/'),
testDirs,
testRootDir,
[],
mockCommandContext,
false,
-
- mockConfigNoRecursive,
+ mockConfig,
),
);
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 150));
+ await waitFor(() => {
+ expect(result.current.suggestions.length).toBe(5);
});
- expect(result.current.suggestions).toEqual([
- { label: 'data/', value: 'data/' },
- { label: 'dist/', value: 'dist/' },
- ]);
- });
-
- it('should work without config (fallback behavior)', async () => {
- await createEmptyDir('src');
- await createEmptyDir('node_modules');
- await createTestFile('', 'README.md');
-
- const { result } = renderHook(() =>
- useCommandCompletion(
- useTextBufferForTest('@'),
- testDirs,
- testRootDir,
- [],
- mockCommandContext,
- undefined,
- ),
- );
-
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 150));
+ act(() => {
+ result.current.setActiveSuggestionIndex(4);
});
+ expect(result.current.activeSuggestionIndex).toBe(4);
- // Without config, should include all files
- expect(result.current.suggestions).toHaveLength(3);
- expect(result.current.suggestions).toEqual(
- expect.arrayContaining([
- { label: 'src/', value: 'src/' },
- { label: 'node_modules/', value: 'node_modules/' },
- { label: 'README.md', value: 'README.md' },
- ]),
- );
- });
-
- it('should handle git discovery service initialization failure gracefully', async () => {
- // Intentionally don't create a .git directory to cause an initialization failure.
- await createEmptyDir('src');
- await createTestFile('', 'README.md');
-
- const consoleSpy = vi
- .spyOn(console, 'warn')
- .mockImplementation(() => {});
-
- const { result } = renderHook(() =>
- useCommandCompletion(
- useTextBufferForTest('@'),
- testDirs,
- testRootDir,
- [],
- mockCommandContext,
- false,
-
- mockConfig,
- ),
- );
-
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 150));
+ act(() => {
+ result.current.navigateDown();
});
- // Since we use centralized service, initialization errors are handled at config level
- // This test should verify graceful fallback behavior
- expect(result.current.suggestions.length).toBeGreaterThanOrEqual(0);
- // Should still show completions even if git discovery fails
- expect(result.current.suggestions.length).toBeGreaterThan(0);
-
- consoleSpy.mockRestore();
+ expect(result.current.activeSuggestionIndex).toBe(0);
});
- });
-
- describe('Git-Aware Filtering', () => {
- it('should filter git-ignored entries from @ completions', async () => {
- await createEmptyDir('.git');
- await createTestFile('dist', '.gitignore');
- await createEmptyDir('data');
+ it('should handle navigation with multiple suggestions', async () => {
const { result } = renderHook(() =>
useCommandCompletion(
- useTextBufferForTest('@d'),
+ useTextBufferForTest('/'),
testDirs,
testRootDir,
[],
mockCommandContext,
false,
-
mockConfig,
),
);
- // Wait for async operations to complete
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 150)); // Account for debounce
+ await waitFor(() => {
+ expect(result.current.suggestions.length).toBe(5);
});
- expect(result.current.suggestions).toEqual(
- expect.arrayContaining([{ label: 'data', value: 'data' }]),
- );
- expect(result.current.showSuggestions).toBe(true);
- });
-
- it('should filter git-ignored directories from @ completions', async () => {
- await createEmptyDir('.git');
- await createTestFile('node_modules\ndist\n.env', '.gitignore');
- // gitignored entries
- await createEmptyDir('node_modules');
- await createEmptyDir('dist');
- await createTestFile('', '.env');
+ expect(result.current.activeSuggestionIndex).toBe(0);
- // visible
- await createEmptyDir('src');
- await createTestFile('', 'README.md');
+ act(() => result.current.navigateDown());
+ expect(result.current.activeSuggestionIndex).toBe(1);
- const { result } = renderHook(() =>
- useCommandCompletion(
- useTextBufferForTest('@'),
- testDirs,
- testRootDir,
- [],
- mockCommandContext,
- false,
+ act(() => result.current.navigateDown());
+ expect(result.current.activeSuggestionIndex).toBe(2);
- mockConfig,
- ),
- );
+ act(() => result.current.navigateUp());
+ expect(result.current.activeSuggestionIndex).toBe(1);
- // Wait for async operations to complete
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 150)); // Account for debounce
- });
+ act(() => result.current.navigateUp());
+ expect(result.current.activeSuggestionIndex).toBe(0);
- expect(result.current.suggestions).toEqual([
- { label: 'README.md', value: 'README.md' },
- { label: 'src/', value: 'src/' },
- ]);
- expect(result.current.showSuggestions).toBe(true);
+ act(() => result.current.navigateUp());
+ expect(result.current.activeSuggestionIndex).toBe(4);
});
- it('should handle recursive search with git-aware filtering', async () => {
- await createEmptyDir('.git');
- await createTestFile('node_modules/\ntemp/', '.gitignore');
- await createTestFile('', 'data', 'test.txt');
- await createEmptyDir('dist');
- await createEmptyDir('node_modules');
- await createTestFile('', 'src', 'index.ts');
- await createEmptyDir('src', 'components');
- await createTestFile('', 'temp', 'temp.log');
+ it('should automatically select the first item when suggestions are available', async () => {
+ setupMocks({ slashSuggestions: mockSuggestions });
const { result } = renderHook(() =>
useCommandCompletion(
- useTextBufferForTest('@t'),
+ useTextBufferForTest('/'),
testDirs,
testRootDir,
[],
mockCommandContext,
false,
-
mockConfig,
),
);
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 150));
+ await waitFor(() => {
+ expect(result.current.suggestions.length).toBe(
+ mockSuggestions.length,
+ );
+ expect(result.current.activeSuggestionIndex).toBe(0);
});
-
- // Should not include anything from node_modules or dist
- const suggestionLabels = result.current.suggestions.map((s) => s.label);
- expect(suggestionLabels).not.toContain('temp/');
- expect(suggestionLabels).not.toContain('node_modules/');
});
});
});
describe('handleAutocomplete', () => {
- it('should complete a partial command', () => {
- const slashCommands = [
- {
- name: 'memory',
- description: 'Manage memory',
- subCommands: [
- {
- name: 'show',
- description: 'Show memory',
- },
- {
- name: 'add',
- description: 'Add to memory',
- },
- ],
- },
- ] as unknown as SlashCommand[];
+ it('should complete a partial command', async () => {
+ setupMocks({
+ slashSuggestions: [{ label: 'memory', value: 'memory' }],
+ slashCompletionRange: { completionStart: 1, completionEnd: 4 },
+ });
const { result } = renderHook(() => {
const textBuffer = useTextBufferForTest('/mem');
@@ -1157,18 +431,17 @@ describe('useCommandCompletion', () => {
textBuffer,
testDirs,
testRootDir,
- slashCommands,
+ [],
mockCommandContext,
false,
-
mockConfig,
);
return { ...completion, textBuffer };
});
- expect(result.current.suggestions.map((s) => s.value)).toEqual([
- 'memory',
- ]);
+ await waitFor(() => {
+ expect(result.current.suggestions.length).toBe(1);
+ });
act(() => {
result.current.handleAutocomplete(0);
@@ -1177,99 +450,11 @@ describe('useCommandCompletion', () => {
expect(result.current.textBuffer.text).toBe('/memory ');
});
- it('should append a sub-command when the parent is complete', () => {
- const slashCommands = [
- {
- name: 'memory',
- description: 'Manage memory',
- subCommands: [
- {
- name: 'show',
- description: 'Show memory',
- },
- {
- name: 'add',
- description: 'Add to memory',
- },
- ],
- },
- ] as unknown as SlashCommand[];
-
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('/memory');
- const completion = useCommandCompletion(
- textBuffer,
- testDirs,
- testRootDir,
- slashCommands,
- mockCommandContext,
- false,
-
- mockConfig,
- );
- return { ...completion, textBuffer };
+ it('should complete a file path', async () => {
+ setupMocks({
+ atSuggestions: [{ label: 'src/file1.txt', value: 'src/file1.txt' }],
});
- // Suggestions are populated by useEffect
- expect(result.current.suggestions.map((s) => s.value)).toEqual([
- 'show',
- 'add',
- ]);
-
- act(() => {
- result.current.handleAutocomplete(1); // index 1 is 'add'
- });
-
- expect(result.current.textBuffer.text).toBe('/memory add ');
- });
-
- it('should complete a command with an alternative name', () => {
- const slashCommands = [
- {
- name: 'memory',
- description: 'Manage memory',
- subCommands: [
- {
- name: 'show',
- description: 'Show memory',
- },
- {
- name: 'add',
- description: 'Add to memory',
- },
- ],
- },
- ] as unknown as SlashCommand[];
-
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('/?');
- const completion = useCommandCompletion(
- textBuffer,
- testDirs,
- testRootDir,
- slashCommands,
- mockCommandContext,
- false,
-
- mockConfig,
- );
- return { ...completion, textBuffer };
- });
-
- result.current.suggestions.push({
- label: 'help',
- value: 'help',
- description: 'Show help',
- });
-
- act(() => {
- result.current.handleAutocomplete(0);
- });
-
- expect(result.current.textBuffer.text).toBe('/help ');
- });
-
- it('should complete a file path', () => {
const { result } = renderHook(() => {
const textBuffer = useTextBufferForTest('@src/fi');
const completion = useCommandCompletion(
@@ -1284,9 +469,8 @@ describe('useCommandCompletion', () => {
return { ...completion, textBuffer };
});
- result.current.suggestions.push({
- label: 'file1.txt',
- value: 'file1.txt',
+ await waitFor(() => {
+ expect(result.current.suggestions.length).toBe(1);
});
act(() => {
@@ -1296,41 +480,16 @@ describe('useCommandCompletion', () => {
expect(result.current.textBuffer.text).toBe('@src/file1.txt ');
});
- it('should complete a file path when cursor is not at the end of the line', () => {
- const text = '@src/fi le.txt';
+ it('should complete a file path when cursor is not at the end of the line', async () => {
+ const text = '@src/fi is a good file';
const cursorOffset = 7; // after "i"
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest(text, cursorOffset);
- const completion = useCommandCompletion(
- textBuffer,
- testDirs,
- testRootDir,
- [],
- mockCommandContext,
- false,
- mockConfig,
- );
- return { ...completion, textBuffer };
- });
-
- result.current.suggestions.push({
- label: 'file1.txt',
- value: 'file1.txt',
- });
-
- act(() => {
- result.current.handleAutocomplete(0);
+ setupMocks({
+ atSuggestions: [{ label: 'src/file1.txt', value: 'src/file1.txt' }],
});
- expect(result.current.textBuffer.text).toBe('@src/file1.txt le.txt');
- });
-
- it('should complete the correct file path with multiple @-commands', () => {
- const text = '@file1.txt @src/fi';
-
const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest(text);
+ const textBuffer = useTextBufferForTest(text, cursorOffset);
const completion = useCommandCompletion(
textBuffer,
testDirs,
@@ -1343,274 +502,17 @@ describe('useCommandCompletion', () => {
return { ...completion, textBuffer };
});
- result.current.suggestions.push({
- label: 'file2.txt',
- value: 'file2.txt',
+ await waitFor(() => {
+ expect(result.current.suggestions.length).toBe(1);
});
act(() => {
result.current.handleAutocomplete(0);
});
- expect(result.current.textBuffer.text).toBe('@file1.txt @src/file2.txt ');
- });
- });
-
- describe('File Path Escaping', () => {
- it('should escape special characters in file names', async () => {
- await createTestFile('', 'my file.txt');
- await createTestFile('', 'file(1).txt');
- await createTestFile('', 'backup[old].txt');
-
- const { result } = renderHook(() =>
- useCommandCompletion(
- useTextBufferForTest('@my'),
- testDirs,
- testRootDir,
- [],
- mockCommandContext,
- false,
- mockConfig,
- ),
- );
-
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 150));
- });
-
- const suggestion = result.current.suggestions.find(
- (s) => s.label === 'my file.txt',
- );
- expect(suggestion).toBeDefined();
- expect(suggestion!.value).toBe('my\\ file.txt');
- });
-
- it('should escape parentheses in file names', async () => {
- await createTestFile('', 'document(final).docx');
- await createTestFile('', 'script(v2).sh');
-
- const { result } = renderHook(() =>
- useCommandCompletion(
- useTextBufferForTest('@doc'),
- testDirs,
- testRootDir,
- [],
- mockCommandContext,
- false,
- mockConfig,
- ),
- );
-
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 150));
- });
-
- const suggestion = result.current.suggestions.find(
- (s) => s.label === 'document(final).docx',
- );
- expect(suggestion).toBeDefined();
- expect(suggestion!.value).toBe('document\\(final\\).docx');
- });
-
- it('should escape square brackets in file names', async () => {
- await createTestFile('', 'backup[2024-01-01].zip');
- await createTestFile('', 'config[dev].json');
-
- const { result } = renderHook(() =>
- useCommandCompletion(
- useTextBufferForTest('@backup'),
- testDirs,
- testRootDir,
- [],
- mockCommandContext,
- false,
- mockConfig,
- ),
- );
-
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 150));
- });
-
- const suggestion = result.current.suggestions.find(
- (s) => s.label === 'backup[2024-01-01].zip',
- );
- expect(suggestion).toBeDefined();
- expect(suggestion!.value).toBe('backup\\[2024-01-01\\].zip');
- });
-
- it('should escape multiple special characters in file names', async () => {
- await createTestFile('', 'my file (backup) [v1.2].txt');
- await createTestFile('', 'data & config {prod}.json');
-
- const { result } = renderHook(() =>
- useCommandCompletion(
- useTextBufferForTest('@my'),
- testDirs,
- testRootDir,
- [],
- mockCommandContext,
- false,
- mockConfig,
- ),
- );
-
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 150));
- });
-
- const suggestion = result.current.suggestions.find(
- (s) => s.label === 'my file (backup) [v1.2].txt',
- );
- expect(suggestion).toBeDefined();
- expect(suggestion!.value).toBe(
- 'my\\ file\\ \\(backup\\)\\ \\[v1.2\\].txt',
- );
- });
-
- it('should preserve path separators while escaping special characters', async () => {
- await createTestFile(
- '',
- 'projects',
- 'my project (2024)',
- 'file with spaces.txt',
- );
-
- const { result } = renderHook(() =>
- useCommandCompletion(
- useTextBufferForTest('@projects/my'),
- testDirs,
- testRootDir,
- [],
- mockCommandContext,
- false,
- mockConfig,
- ),
- );
-
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 150));
- });
-
- const suggestion = result.current.suggestions.find((s) =>
- s.label.includes('my project'),
- );
- expect(suggestion).toBeDefined();
- // Should escape spaces and parentheses but preserve forward slashes
- expect(suggestion!.value).toMatch(/my\\ project\\ \\\(2024\\\)/);
- expect(suggestion!.value).toContain('/'); // Should contain forward slash for path separator
- });
-
- it('should normalize Windows path separators to forward slashes while preserving escaping', async () => {
- // Create test with complex nested structure
- await createTestFile(
- '',
- 'deep',
- 'nested',
- 'special folder',
- 'file with (parentheses).txt',
- );
-
- const { result } = renderHook(() =>
- useCommandCompletion(
- useTextBufferForTest('@deep/nested/special'),
- testDirs,
- testRootDir,
- [],
- mockCommandContext,
- false,
- mockConfig,
- ),
- );
-
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 150));
- });
-
- const suggestion = result.current.suggestions.find((s) =>
- s.label.includes('special folder'),
- );
- expect(suggestion).toBeDefined();
- // Should use forward slashes for path separators and escape spaces
- expect(suggestion!.value).toContain('special\\ folder/');
- expect(suggestion!.value).not.toContain('\\\\'); // Should not contain double backslashes for path separators
- });
-
- it('should handle directory names with special characters', async () => {
- await createEmptyDir('my documents (personal)');
- await createEmptyDir('config [production]');
- await createEmptyDir('data & logs');
-
- const { result } = renderHook(() =>
- useCommandCompletion(
- useTextBufferForTest('@'),
- testDirs,
- testRootDir,
- [],
- mockCommandContext,
- false,
- mockConfig,
- ),
- );
-
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 150));
- });
-
- const suggestions = result.current.suggestions;
-
- const docSuggestion = suggestions.find(
- (s) => s.label === 'my documents (personal)/',
- );
- expect(docSuggestion).toBeDefined();
- expect(docSuggestion!.value).toBe('my\\ documents\\ \\(personal\\)/');
-
- const configSuggestion = suggestions.find(
- (s) => s.label === 'config [production]/',
- );
- expect(configSuggestion).toBeDefined();
- expect(configSuggestion!.value).toBe('config\\ \\[production\\]/');
-
- const dataSuggestion = suggestions.find(
- (s) => s.label === 'data & logs/',
- );
- expect(dataSuggestion).toBeDefined();
- expect(dataSuggestion!.value).toBe('data\\ \\&\\ logs/');
- });
-
- it('should handle files with various shell metacharacters', async () => {
- await createTestFile('', 'file$var.txt');
- await createTestFile('', 'important!.md');
-
- const { result } = renderHook(() =>
- useCommandCompletion(
- useTextBufferForTest('@'),
- testDirs,
- testRootDir,
- [],
- mockCommandContext,
- false,
- mockConfig,
- ),
- );
-
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 150));
- });
-
- const suggestions = result.current.suggestions;
-
- const dollarSuggestion = suggestions.find(
- (s) => s.label === 'file$var.txt',
- );
- expect(dollarSuggestion).toBeDefined();
- expect(dollarSuggestion!.value).toBe('file\\$var.txt');
-
- const importantSuggestion = suggestions.find(
- (s) => s.label === 'important!.md',
+ expect(result.current.textBuffer.text).toBe(
+ '@src/file1.txt is a good file',
);
- expect(importantSuggestion).toBeDefined();
- expect(importantSuggestion!.value).toBe('important\\!.md');
});
});
});
diff --git a/packages/cli/src/ui/hooks/useCommandCompletion.tsx b/packages/cli/src/ui/hooks/useCommandCompletion.tsx
index 9227be39..07d0e056 100644
--- a/packages/cli/src/ui/hooks/useCommandCompletion.tsx
+++ b/packages/cli/src/ui/hooks/useCommandCompletion.tsx
@@ -4,20 +4,7 @@
* 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,
- SHELL_SPECIAL_CHARS,
-} from '@google/gemini-cli-core';
+import { useCallback, useMemo, useEffect } from 'react';
import { Suggestion } from '../components/SuggestionsDisplay.js';
import { CommandContext, SlashCommand } from '../commands/types.js';
import {
@@ -26,8 +13,17 @@ import {
} 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 { Config } from '@google/gemini-cli-core';
import { useCompletion } from './useCompletion.js';
+export enum CompletionMode {
+ IDLE = 'IDLE',
+ AT = 'AT',
+ SLASH = 'SLASH',
+}
+
export interface UseCommandCompletionReturn {
suggestions: Suggestion[];
activeSuggestionIndex: number;
@@ -72,541 +68,109 @@ export function useCommandCompletion(
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) {
- setTimeout(resetCompletionState, 0);
- 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);
+ const { completionMode, query, completionStart, completionEnd } =
+ useMemo(() => {
+ const currentLine = buffer.lines[cursorRow] || '';
+ if (cursorRow === 0 && isSlashCommand(currentLine.trim())) {
+ return {
+ completionMode: CompletionMode.SLASH,
+ query: currentLine,
+ completionStart: 0,
+ completionEnd: currentLine.length,
};
- 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))),
- );
+ const codePoints = toCodePoints(currentLine);
+ for (let i = cursorCol - 1; i >= 0; i--) {
+ const char = codePoints[i];
- // 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 = [];
+ if (char === ' ') {
+ let backslashCount = 0;
+ for (let j = i - 1; j >= 0 && codePoints[j] === '\\'; j--) {
+ backslashCount++;
}
- }
-
- 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);
+ if (backslashCount % 2 === 0) {
+ return {
+ completionMode: CompletionMode.IDLE,
+ query: null,
+ completionStart: -1,
+ completionEnd: -1,
+ };
}
- 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;
+ } 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 (!entry.name.toLowerCase().startsWith(lowerPrefix)) continue;
- const relativePath = path.relative(
- dir,
- path.join(baseDirAbsolute, entry.name),
- );
- if (
- fileDiscoveryService &&
- fileDiscoveryService.shouldIgnoreFile(
- relativePath,
- filterOptions,
- )
- ) {
- continue;
+ if (backslashCount % 2 === 0) {
+ end = i;
+ break;
}
-
- 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 forward slashes for path separators, even on Windows.
- // But preserve backslash escaping for special characters.
- const specialCharsLookahead = `(?![${SHELL_SPECIAL_CHARS.source.slice(1, -1)}])`;
- const pathSeparatorRegex = new RegExp(
- `\\\\${specialCharsLookahead}`,
- 'g',
- );
- fetchedSuggestions = fetchedSuggestions.map((suggestion) => ({
- ...suggestion,
- label: suggestion.label.replace(pathSeparatorRegex, '/'),
- value: suggestion.value.replace(pathSeparatorRegex, '/'),
- }));
-
- // 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();
}
+ const pathStart = i + 1;
+ const partialPath = currentLine.substring(pathStart, end);
+ return {
+ completionMode: CompletionMode.AT,
+ query: partialPath,
+ completionStart: pathStart,
+ completionEnd: end,
+ };
}
}
- if (isMounted) {
- setIsLoadingSuggestions(false);
- }
- };
-
- const debounceTimeout = setTimeout(fetchSuggestions, 100);
+ return {
+ completionMode: CompletionMode.IDLE,
+ query: null,
+ completionStart: -1,
+ completionEnd: -1,
+ };
+ }, [cursorRow, cursorCol, buffer.lines]);
- return () => {
- isMounted = false;
- clearTimeout(debounceTimeout);
- };
- }, [
- buffer.text,
- cursorRow,
- cursorCol,
- buffer.lines,
- dirs,
+ useAtCompletion({
+ enabled: completionMode === CompletionMode.AT,
+ pattern: query || '',
+ config,
cwd,
- commandIndex,
- resetCompletionState,
+ setSuggestions,
+ setIsLoadingSuggestions,
+ });
+
+ const slashCompletionRange = useSlashCompletion({
+ enabled: completionMode === CompletionMode.SLASH,
+ query,
slashCommands,
commandContext,
- config,
- reverseSearchActive,
setSuggestions,
- setShowSuggestions,
- setActiveSuggestionIndex,
setIsLoadingSuggestions,
setIsPerfectMatch,
- setVisibleStartIndex,
+ });
+
+ useEffect(() => {
+ setActiveSuggestionIndex(suggestions.length > 0 ? 0 : -1);
+ setVisibleStartIndex(0);
+ }, [suggestions, setActiveSuggestionIndex, setVisibleStartIndex]);
+
+ useEffect(() => {
+ if (completionMode === CompletionMode.IDLE || reverseSearchActive) {
+ resetCompletionState();
+ return;
+ }
+ // Show suggestions if we are loading OR if there are results to display.
+ setShowSuggestions(isLoadingSuggestions || suggestions.length > 0);
+ }, [
+ completionMode,
+ suggestions.length,
+ isLoadingSuggestions,
+ reverseSearchActive,
+ resetCompletionState,
+ setShowSuggestions,
]);
const handleAutocomplete = useCallback(
@@ -616,18 +180,23 @@ export function useCommandCompletion(
}
const suggestion = suggestions[indexToUse].value;
- if (completionStart.current === -1 || completionEnd.current === -1) {
+ let start = completionStart;
+ let end = completionEnd;
+ if (completionMode === CompletionMode.SLASH) {
+ start = slashCompletionRange.completionStart;
+ end = slashCompletionRange.completionEnd;
+ }
+
+ if (start === -1 || end === -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 (completionMode === CompletionMode.SLASH) {
if (
- completionStart.current === completionEnd.current &&
- completionStart.current > commandIndex + 1 &&
- (buffer.lines[cursorRow] || '')[completionStart.current - 1] !== ' '
+ start === end &&
+ start > 1 &&
+ (buffer.lines[cursorRow] || '')[start - 1] !== ' '
) {
suggestionText = ' ' + suggestionText;
}
@@ -636,12 +205,20 @@ export function useCommandCompletion(
suggestionText += ' ';
buffer.replaceRangeByOffset(
- logicalPosToOffset(buffer.lines, cursorRow, completionStart.current),
- logicalPosToOffset(buffer.lines, cursorRow, completionEnd.current),
+ logicalPosToOffset(buffer.lines, cursorRow, start),
+ logicalPosToOffset(buffer.lines, cursorRow, end),
suggestionText,
);
},
- [cursorRow, buffer, suggestions, commandIndex],
+ [
+ cursorRow,
+ buffer,
+ suggestions,
+ completionMode,
+ completionStart,
+ completionEnd,
+ slashCompletionRange,
+ ],
);
return {
diff --git a/packages/cli/src/ui/hooks/useReverseSearchCompletion.tsx b/packages/cli/src/ui/hooks/useReverseSearchCompletion.tsx
index 1cc7e602..3fb9217e 100644
--- a/packages/cli/src/ui/hooks/useReverseSearchCompletion.tsx
+++ b/packages/cli/src/ui/hooks/useReverseSearchCompletion.tsx
@@ -41,12 +41,17 @@ export function useReverseSearchCompletion(
navigateDown,
} = useCompletion();
- // whenever reverseSearchActive is on, filter history
useEffect(() => {
if (!reverseSearchActive) {
resetCompletionState();
+ }
+ }, [reverseSearchActive, resetCompletionState]);
+
+ useEffect(() => {
+ if (!reverseSearchActive) {
return;
}
+
const q = buffer.text.toLowerCase();
const matches = shellHistory.reduce<Suggestion[]>((acc, cmd) => {
const idx = cmd.toLowerCase().indexOf(q);
@@ -55,6 +60,7 @@ export function useReverseSearchCompletion(
}
return acc;
}, []);
+
setSuggestions(matches);
setShowSuggestions(matches.length > 0);
setActiveSuggestionIndex(matches.length > 0 ? 0 : -1);
@@ -62,7 +68,6 @@ export function useReverseSearchCompletion(
buffer.text,
shellHistory,
reverseSearchActive,
- resetCompletionState,
setActiveSuggestionIndex,
setShowSuggestions,
setSuggestions,
diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.test.ts b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts
new file mode 100644
index 00000000..ba26f2d2
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useSlashCompletion.test.ts
@@ -0,0 +1,434 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+/** @vitest-environment jsdom */
+
+import { describe, it, expect, vi } from 'vitest';
+import { renderHook, waitFor } from '@testing-library/react';
+import { useSlashCompletion } from './useSlashCompletion.js';
+import { CommandContext, SlashCommand } from '../commands/types.js';
+import { useState } from 'react';
+import { Suggestion } from '../components/SuggestionsDisplay.js';
+
+// Test harness to capture the state from the hook's callbacks.
+function useTestHarnessForSlashCompletion(
+ enabled: boolean,
+ query: string | null,
+ slashCommands: readonly SlashCommand[],
+ commandContext: CommandContext,
+) {
+ const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
+ const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false);
+ const [isPerfectMatch, setIsPerfectMatch] = useState(false);
+
+ const { completionStart, completionEnd } = useSlashCompletion({
+ enabled,
+ query,
+ slashCommands,
+ commandContext,
+ setSuggestions,
+ setIsLoadingSuggestions,
+ setIsPerfectMatch,
+ });
+
+ return {
+ suggestions,
+ isLoadingSuggestions,
+ isPerfectMatch,
+ completionStart,
+ completionEnd,
+ };
+}
+
+describe('useSlashCompletion', () => {
+ // A minimal mock is sufficient for these tests.
+ const mockCommandContext = {} as CommandContext;
+
+ describe('Top-Level Commands', () => {
+ it('should suggest all top-level commands for the root slash', async () => {
+ const slashCommands = [
+ { name: 'help', altNames: ['?'], description: 'Show help' },
+ {
+ name: 'stats',
+ altNames: ['usage'],
+ description: 'check session stats. Usage: /stats [model|tools]',
+ },
+ { name: 'clear', description: 'Clear the screen' },
+ {
+ name: 'memory',
+ description: 'Manage memory',
+ subCommands: [{ name: 'show', description: 'Show memory' }],
+ },
+ { name: 'chat', description: 'Manage chat history' },
+ ] as unknown as SlashCommand[];
+ const { result } = renderHook(() =>
+ useTestHarnessForSlashCompletion(
+ true,
+ '/',
+ slashCommands,
+ mockCommandContext,
+ ),
+ );
+
+ expect(result.current.suggestions.length).toBe(slashCommands.length);
+ expect(result.current.suggestions.map((s) => s.label)).toEqual(
+ expect.arrayContaining(['help', 'clear', 'memory', 'chat', 'stats']),
+ );
+ });
+
+ it('should filter commands based on partial input', async () => {
+ const slashCommands = [
+ { name: 'memory', description: 'Manage memory' },
+ ] as unknown as SlashCommand[];
+ const { result } = renderHook(() =>
+ useTestHarnessForSlashCompletion(
+ true,
+ '/mem',
+ slashCommands,
+ mockCommandContext,
+ ),
+ );
+
+ expect(result.current.suggestions).toEqual([
+ { label: 'memory', value: 'memory', description: 'Manage memory' },
+ ]);
+ });
+
+ it('should suggest commands based on partial altNames', async () => {
+ const slashCommands = [
+ {
+ name: 'stats',
+ altNames: ['usage'],
+ description: 'check session stats. Usage: /stats [model|tools]',
+ },
+ ] as unknown as SlashCommand[];
+ const { result } = renderHook(() =>
+ useTestHarnessForSlashCompletion(
+ true,
+ '/usag',
+ slashCommands,
+ mockCommandContext,
+ ),
+ );
+
+ expect(result.current.suggestions).toEqual([
+ {
+ label: 'stats',
+ value: 'stats',
+ description: 'check session stats. Usage: /stats [model|tools]',
+ },
+ ]);
+ });
+
+ it('should NOT provide suggestions for a perfectly typed command that is a leaf node', async () => {
+ const slashCommands = [
+ { name: 'clear', description: 'Clear the screen', action: vi.fn() },
+ ] as unknown as SlashCommand[];
+ const { result } = renderHook(() =>
+ useTestHarnessForSlashCompletion(
+ true,
+ '/clear',
+ slashCommands,
+ mockCommandContext,
+ ),
+ );
+
+ expect(result.current.suggestions).toHaveLength(0);
+ });
+
+ it.each([['/?'], ['/usage']])(
+ 'should not suggest commands when altNames is fully typed',
+ async (query) => {
+ const mockSlashCommands = [
+ {
+ name: 'help',
+ altNames: ['?'],
+ description: 'Show help',
+ action: vi.fn(),
+ },
+ {
+ name: 'stats',
+ altNames: ['usage'],
+ description: 'check session stats. Usage: /stats [model|tools]',
+ action: vi.fn(),
+ },
+ ] as unknown as SlashCommand[];
+
+ const { result } = renderHook(() =>
+ useTestHarnessForSlashCompletion(
+ true,
+ query,
+ mockSlashCommands,
+ mockCommandContext,
+ ),
+ );
+
+ expect(result.current.suggestions).toHaveLength(0);
+ },
+ );
+
+ it('should not provide suggestions for a fully typed command that has no sub-commands or argument completion', async () => {
+ const slashCommands = [
+ { name: 'clear', description: 'Clear the screen' },
+ ] as unknown as SlashCommand[];
+ const { result } = renderHook(() =>
+ useTestHarnessForSlashCompletion(
+ true,
+ '/clear ',
+ slashCommands,
+ mockCommandContext,
+ ),
+ );
+
+ expect(result.current.suggestions).toHaveLength(0);
+ });
+
+ it('should not provide suggestions for an unknown command', async () => {
+ const slashCommands = [
+ { name: 'help', description: 'Show help' },
+ ] as unknown as SlashCommand[];
+ const { result } = renderHook(() =>
+ useTestHarnessForSlashCompletion(
+ true,
+ '/unknown-command',
+ slashCommands,
+ mockCommandContext,
+ ),
+ );
+
+ expect(result.current.suggestions).toHaveLength(0);
+ });
+ });
+
+ describe('Sub-Commands', () => {
+ it('should suggest sub-commands for a parent command', async () => {
+ const slashCommands = [
+ {
+ name: 'memory',
+ description: 'Manage memory',
+ subCommands: [
+ { name: 'show', description: 'Show memory' },
+ { name: 'add', description: 'Add to memory' },
+ ],
+ },
+ ] as unknown as SlashCommand[];
+
+ const { result } = renderHook(() =>
+ useTestHarnessForSlashCompletion(
+ true,
+ '/memory',
+ slashCommands,
+ mockCommandContext,
+ ),
+ );
+
+ expect(result.current.suggestions).toHaveLength(2);
+ expect(result.current.suggestions).toEqual(
+ expect.arrayContaining([
+ { label: 'show', value: 'show', description: 'Show memory' },
+ { label: 'add', value: 'add', description: 'Add to memory' },
+ ]),
+ );
+ });
+
+ it('should suggest all sub-commands when the query ends with the parent command and a space', async () => {
+ const slashCommands = [
+ {
+ name: 'memory',
+ description: 'Manage memory',
+ subCommands: [
+ { name: 'show', description: 'Show memory' },
+ { name: 'add', description: 'Add to memory' },
+ ],
+ },
+ ] as unknown as SlashCommand[];
+ const { result } = renderHook(() =>
+ useTestHarnessForSlashCompletion(
+ true,
+ '/memory ',
+ slashCommands,
+ mockCommandContext,
+ ),
+ );
+
+ expect(result.current.suggestions).toHaveLength(2);
+ expect(result.current.suggestions).toEqual(
+ expect.arrayContaining([
+ { label: 'show', value: 'show', description: 'Show memory' },
+ { label: 'add', value: 'add', description: 'Add to memory' },
+ ]),
+ );
+ });
+
+ it('should filter sub-commands by prefix', async () => {
+ const slashCommands = [
+ {
+ name: 'memory',
+ description: 'Manage memory',
+ subCommands: [
+ { name: 'show', description: 'Show memory' },
+ { name: 'add', description: 'Add to memory' },
+ ],
+ },
+ ] as unknown as SlashCommand[];
+ const { result } = renderHook(() =>
+ useTestHarnessForSlashCompletion(
+ true,
+ '/memory a',
+ slashCommands,
+ mockCommandContext,
+ ),
+ );
+
+ expect(result.current.suggestions).toEqual([
+ { label: 'add', value: 'add', description: 'Add to memory' },
+ ]);
+ });
+
+ it('should provide no suggestions for an invalid sub-command', async () => {
+ const slashCommands = [
+ {
+ name: 'memory',
+ description: 'Manage memory',
+ subCommands: [
+ { name: 'show', description: 'Show memory' },
+ { name: 'add', description: 'Add to memory' },
+ ],
+ },
+ ] as unknown as SlashCommand[];
+ const { result } = renderHook(() =>
+ useTestHarnessForSlashCompletion(
+ true,
+ '/memory dothisnow',
+ slashCommands,
+ mockCommandContext,
+ ),
+ );
+
+ expect(result.current.suggestions).toHaveLength(0);
+ });
+ });
+
+ describe('Argument Completion', () => {
+ it('should call the command.completion function for argument suggestions', async () => {
+ const availableTags = [
+ 'my-chat-tag-1',
+ 'my-chat-tag-2',
+ 'another-channel',
+ ];
+ const mockCompletionFn = vi
+ .fn()
+ .mockImplementation(
+ async (_context: CommandContext, partialArg: string) =>
+ availableTags.filter((tag) => tag.startsWith(partialArg)),
+ );
+
+ const slashCommands = [
+ {
+ name: 'chat',
+ description: 'Manage chat history',
+ subCommands: [
+ {
+ name: 'resume',
+ description: 'Resume a saved chat',
+ completion: mockCompletionFn,
+ },
+ ],
+ },
+ ] as unknown as SlashCommand[];
+
+ const { result } = renderHook(() =>
+ useTestHarnessForSlashCompletion(
+ true,
+ '/chat resume my-ch',
+ slashCommands,
+ mockCommandContext,
+ ),
+ );
+
+ await waitFor(() => {
+ expect(mockCompletionFn).toHaveBeenCalledWith(
+ mockCommandContext,
+ 'my-ch',
+ );
+ });
+
+ await waitFor(() => {
+ expect(result.current.suggestions).toEqual([
+ { label: 'my-chat-tag-1', value: 'my-chat-tag-1' },
+ { label: 'my-chat-tag-2', value: 'my-chat-tag-2' },
+ ]);
+ });
+ });
+
+ it('should call command.completion with an empty string when args start with a space', async () => {
+ const mockCompletionFn = vi
+ .fn()
+ .mockResolvedValue(['my-chat-tag-1', 'my-chat-tag-2', 'my-channel']);
+
+ const slashCommands = [
+ {
+ name: 'chat',
+ description: 'Manage chat history',
+ subCommands: [
+ {
+ name: 'resume',
+ description: 'Resume a saved chat',
+ completion: mockCompletionFn,
+ },
+ ],
+ },
+ ] as unknown as SlashCommand[];
+
+ const { result } = renderHook(() =>
+ useTestHarnessForSlashCompletion(
+ true,
+ '/chat resume ',
+ slashCommands,
+ mockCommandContext,
+ ),
+ );
+
+ await waitFor(() => {
+ expect(mockCompletionFn).toHaveBeenCalledWith(mockCommandContext, '');
+ });
+
+ await waitFor(() => {
+ expect(result.current.suggestions).toHaveLength(3);
+ });
+ });
+
+ it('should handle completion function that returns null', async () => {
+ const completionFn = vi.fn().mockResolvedValue(null);
+ const slashCommands = [
+ {
+ name: 'chat',
+ description: 'Manage chat history',
+ subCommands: [
+ {
+ name: 'resume',
+ description: 'Resume a saved chat',
+ completion: completionFn,
+ },
+ ],
+ },
+ ] as unknown as SlashCommand[];
+
+ const { result } = renderHook(() =>
+ useTestHarnessForSlashCompletion(
+ true,
+ '/chat resume ',
+ slashCommands,
+ mockCommandContext,
+ ),
+ );
+
+ await waitFor(() => {
+ expect(result.current.suggestions).toHaveLength(0);
+ });
+ });
+ });
+});
diff --git a/packages/cli/src/ui/hooks/useSlashCompletion.ts b/packages/cli/src/ui/hooks/useSlashCompletion.ts
new file mode 100644
index 00000000..9836362f
--- /dev/null
+++ b/packages/cli/src/ui/hooks/useSlashCompletion.ts
@@ -0,0 +1,187 @@
+/**
+ * @license
+ * Copyright 2025 Google LLC
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { useState, useEffect } from 'react';
+import { Suggestion } from '../components/SuggestionsDisplay.js';
+import { CommandContext, SlashCommand } from '../commands/types.js';
+
+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;
+} {
+ const {
+ enabled,
+ query,
+ slashCommands,
+ commandContext,
+ setSuggestions,
+ setIsLoadingSuggestions,
+ setIsPerfectMatch,
+ } = props;
+ const [completionStart, setCompletionStart] = useState(-1);
+ const [completionEnd, setCompletionEnd] = useState(-1);
+
+ useEffect(() => {
+ if (!enabled || query === null) {
+ return;
+ }
+
+ 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) => 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;
+ if (!hasTrailingSpace && currentLevel) {
+ exactMatchAsParent = currentLevel.find(
+ (cmd) =>
+ (cmd.name === partial || cmd.altNames?.includes(partial)) &&
+ cmd.subCommands,
+ );
+
+ if (exactMatchAsParent) {
+ leafCommand = exactMatchAsParent;
+ currentLevel = exactMatchAsParent.subCommands;
+ partial = '';
+ }
+ }
+
+ setIsPerfectMatch(false);
+ if (!hasTrailingSpace) {
+ if (leafCommand && partial === '' && leafCommand.action) {
+ setIsPerfectMatch(true);
+ } else if (currentLevel) {
+ 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 !== ''));
+
+ if (hasTrailingSpace || exactMatchAsParent) {
+ setCompletionStart(query.length);
+ setCompletionEnd(query.length);
+ } else if (partial) {
+ if (isArgumentCompletion) {
+ const commandSoFar = `/${commandPathParts.join(' ')}`;
+ const argStartIndex =
+ commandSoFar.length + (commandPathParts.length > 0 ? 1 : 0);
+ setCompletionStart(argStartIndex);
+ } else {
+ setCompletionStart(query.length - partial.length);
+ }
+ setCompletionEnd(query.length);
+ } else {
+ setCompletionStart(1);
+ setCompletionEnd(query.length);
+ }
+
+ 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);
+ setIsLoadingSuggestions(false);
+ };
+ fetchAndSetSuggestions();
+ return;
+ }
+
+ 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 (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);
+ return;
+ }
+
+ setSuggestions([]);
+ }, [
+ enabled,
+ query,
+ slashCommands,
+ commandContext,
+ setSuggestions,
+ setIsLoadingSuggestions,
+ setIsPerfectMatch,
+ ]);
+
+ return {
+ completionStart,
+ completionEnd,
+ };
+}