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/useCompletion.integration.test.ts854
-rw-r--r--packages/cli/src/ui/hooks/useCompletion.test.ts1942
-rw-r--r--packages/cli/src/ui/hooks/useCompletion.ts20
3 files changed, 954 insertions, 1862 deletions
diff --git a/packages/cli/src/ui/hooks/useCompletion.integration.test.ts b/packages/cli/src/ui/hooks/useCompletion.integration.test.ts
deleted file mode 100644
index d4c66a15..00000000
--- a/packages/cli/src/ui/hooks/useCompletion.integration.test.ts
+++ /dev/null
@@ -1,854 +0,0 @@
-/**
- * @license
- * Copyright 2025 Google LLC
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
-import type { Mocked } from 'vitest';
-import { renderHook, act } from '@testing-library/react';
-import { useCompletion } from './useCompletion.js';
-import * as fs from 'fs/promises';
-import { glob } from 'glob';
-import {
- CommandContext,
- CommandKind,
- SlashCommand,
-} from '../commands/types.js';
-import { Config, FileDiscoveryService } from '@google/gemini-cli-core';
-import { useTextBuffer } from '../components/shared/text-buffer.js';
-
-interface MockConfig {
- getFileFilteringOptions: () => {
- respectGitIgnore: boolean;
- respectGeminiIgnore: boolean;
- };
- getEnableRecursiveFileSearch: () => boolean;
- getFileService: () => FileDiscoveryService | null;
-}
-
-// Helper to create real TextBuffer objects within renderHook
-const useTextBufferForTest = (text: string) => {
- const cursorOffset = text.length;
-
- return useTextBuffer({
- initialText: text,
- initialCursorOffset: cursorOffset,
- viewport: { width: 80, height: 20 },
- isValidPath: () => false,
- onChange: () => {},
- });
-};
-
-// Mock dependencies
-vi.mock('fs/promises');
-vi.mock('@google/gemini-cli-core', async () => {
- const actual = await vi.importActual('@google/gemini-cli-core');
- return {
- ...actual,
- FileDiscoveryService: vi.fn(),
- isNodeError: vi.fn((error) => error.code === 'ENOENT'),
- escapePath: vi.fn((path) => path),
- unescapePath: vi.fn((path) => path),
- getErrorMessage: vi.fn((error) => error.message),
- };
-});
-vi.mock('glob');
-
-describe('useCompletion git-aware filtering integration', () => {
- let mockFileDiscoveryService: Mocked<FileDiscoveryService>;
- let mockConfig: MockConfig;
-
- const testCwd = '/test/project';
- const slashCommands = [
- {
- name: 'help',
- description: 'Show help',
- kind: CommandKind.BUILT_IN,
- action: vi.fn(),
- },
- {
- name: 'clear',
- description: 'Clear screen',
- kind: CommandKind.BUILT_IN,
- action: vi.fn(),
- },
- ];
-
- // A minimal mock is sufficient for these tests.
- const mockCommandContext = {} as CommandContext;
-
- const mockSlashCommands: SlashCommand[] = [
- {
- name: 'help',
- altNames: ['?'],
- description: 'Show help',
- action: vi.fn(),
- kind: CommandKind.BUILT_IN,
- },
- {
- name: 'stats',
- altNames: ['usage'],
- description: 'check session stats. Usage: /stats [model|tools]',
- action: vi.fn(),
- kind: CommandKind.BUILT_IN,
- },
- {
- name: 'clear',
- description: 'Clear the screen',
- action: vi.fn(),
- kind: CommandKind.BUILT_IN,
- },
- {
- name: 'memory',
- description: 'Manage memory',
- kind: CommandKind.BUILT_IN,
- // This command is a parent, no action.
- subCommands: [
- {
- name: 'show',
- description: 'Show memory',
- kind: CommandKind.BUILT_IN,
- action: vi.fn(),
- },
- {
- name: 'add',
- description: 'Add to memory',
- kind: CommandKind.BUILT_IN,
- action: vi.fn(),
- },
- ],
- },
- {
- name: 'chat',
- description: 'Manage chat history',
- kind: CommandKind.BUILT_IN,
- subCommands: [
- {
- name: 'save',
- description: 'Save chat',
- kind: CommandKind.BUILT_IN,
- action: vi.fn(),
- },
- {
- name: 'resume',
- description: 'Resume a saved chat',
- kind: CommandKind.BUILT_IN,
- action: vi.fn(),
- // This command provides its own argument completions
- completion: vi
- .fn()
- .mockResolvedValue([
- 'my-chat-tag-1',
- 'my-chat-tag-2',
- 'my-channel',
- ]),
- },
- ],
- },
- ];
-
- beforeEach(() => {
- mockFileDiscoveryService = {
- shouldGitIgnoreFile: vi.fn(),
- shouldGeminiIgnoreFile: vi.fn(),
- shouldIgnoreFile: vi.fn(),
- filterFiles: vi.fn(),
- getGeminiIgnorePatterns: vi.fn(),
- projectRoot: '',
- gitIgnoreFilter: null,
- geminiIgnoreFilter: null,
- isFileIgnored: vi.fn(),
- } as unknown as Mocked<FileDiscoveryService>;
-
- mockConfig = {
- getFileFilteringOptions: vi.fn(() => ({
- respectGitIgnore: true,
- respectGeminiIgnore: true,
- })),
- getEnableRecursiveFileSearch: vi.fn(() => true),
- getFileService: vi.fn(() => mockFileDiscoveryService),
- };
-
- vi.mocked(FileDiscoveryService).mockImplementation(
- () => mockFileDiscoveryService,
- );
- vi.clearAllMocks();
- });
-
- afterEach(() => {
- vi.restoreAllMocks();
- });
-
- it('should filter git-ignored entries from @ completions', async () => {
- const globResults = [`${testCwd}/data`, `${testCwd}/dist`];
- vi.mocked(glob).mockResolvedValue(globResults);
-
- // Mock git ignore service to ignore certain files
- mockFileDiscoveryService.shouldGitIgnoreFile.mockImplementation(
- (path: string) => path.includes('dist'),
- );
- mockFileDiscoveryService.shouldIgnoreFile.mockImplementation(
- (path: string, options) => {
- if (options?.respectGitIgnore !== false) {
- return mockFileDiscoveryService.shouldGitIgnoreFile(path);
- }
- return false;
- },
- );
-
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('@d');
- return useCompletion(
- textBuffer,
- testCwd,
- slashCommands,
- mockCommandContext,
- mockConfig as Config,
- );
- });
-
- // Wait for async operations to complete
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 150)); // Account for debounce
- });
-
- expect(result.current.suggestions).toHaveLength(1);
- 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 () => {
- // Mock fs.readdir to return both regular and git-ignored directories
- vi.mocked(fs.readdir).mockResolvedValue([
- { name: 'src', isDirectory: () => true },
- { name: 'node_modules', isDirectory: () => true },
- { name: 'dist', isDirectory: () => true },
- { name: 'README.md', isDirectory: () => false },
- { name: '.env', isDirectory: () => false },
- ] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
-
- // Mock ignore service to ignore certain files
- mockFileDiscoveryService.shouldGitIgnoreFile.mockImplementation(
- (path: string) =>
- path.includes('node_modules') ||
- path.includes('dist') ||
- path.includes('.env'),
- );
- mockFileDiscoveryService.shouldIgnoreFile.mockImplementation(
- (path: string, options) => {
- if (
- options?.respectGitIgnore &&
- mockFileDiscoveryService.shouldGitIgnoreFile(path)
- ) {
- return true;
- }
- if (
- options?.respectGeminiIgnore &&
- mockFileDiscoveryService.shouldGeminiIgnoreFile
- ) {
- return mockFileDiscoveryService.shouldGeminiIgnoreFile(path);
- }
- return false;
- },
- );
-
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('@');
- return useCompletion(
- textBuffer,
- testCwd,
- slashCommands,
- mockCommandContext,
- mockConfig as Config,
- );
- });
-
- // Wait for async operations to complete
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 150)); // Account for debounce
- });
-
- expect(result.current.suggestions).toHaveLength(2);
- expect(result.current.suggestions).toEqual(
- expect.arrayContaining([
- { label: 'src/', value: 'src/' },
- { label: 'README.md', value: 'README.md' },
- ]),
- );
- expect(result.current.showSuggestions).toBe(true);
- });
-
- it('should handle recursive search with git-aware filtering', async () => {
- // Mock the recursive file search scenario
- vi.mocked(fs.readdir).mockImplementation(
- async (
- dirPath: string | Buffer | URL,
- options?: { withFileTypes?: boolean },
- ) => {
- const path = dirPath.toString();
- if (options?.withFileTypes) {
- if (path === testCwd) {
- return [
- { name: 'data', isDirectory: () => true },
- { name: 'dist', isDirectory: () => true },
- { name: 'node_modules', isDirectory: () => true },
- { name: 'README.md', isDirectory: () => false },
- { name: '.env', isDirectory: () => false },
- ] as unknown as Awaited<ReturnType<typeof fs.readdir>>;
- }
- if (path.endsWith('/src')) {
- return [
- { name: 'index.ts', isDirectory: () => false },
- { name: 'components', isDirectory: () => true },
- ] as unknown as Awaited<ReturnType<typeof fs.readdir>>;
- }
- if (path.endsWith('/temp')) {
- return [
- { name: 'temp.log', isDirectory: () => false },
- ] as unknown as Awaited<ReturnType<typeof fs.readdir>>;
- }
- }
- return [];
- },
- );
-
- // Mock ignore service
- mockFileDiscoveryService.shouldGitIgnoreFile.mockImplementation(
- (path: string) => path.includes('node_modules') || path.includes('temp'),
- );
- mockFileDiscoveryService.shouldIgnoreFile.mockImplementation(
- (path: string, options) => {
- if (
- options?.respectGitIgnore &&
- mockFileDiscoveryService.shouldGitIgnoreFile(path)
- ) {
- return true;
- }
- if (
- options?.respectGeminiIgnore &&
- mockFileDiscoveryService.shouldGeminiIgnoreFile
- ) {
- return mockFileDiscoveryService.shouldGeminiIgnoreFile(path);
- }
- return false;
- },
- );
-
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('@t');
- return useCompletion(
- textBuffer,
- testCwd,
- slashCommands,
- mockCommandContext,
- mockConfig as Config,
- );
- });
-
- // Wait for async operations to complete
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 150));
- });
-
- // 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.some((l) => l.includes('node_modules'))).toBe(
- false,
- );
- });
-
- it('should not perform recursive search when disabled in config', async () => {
- const globResults = [`${testCwd}/data`, `${testCwd}/dist`];
- vi.mocked(glob).mockResolvedValue(globResults);
-
- // Disable recursive search in the mock config
- const mockConfigNoRecursive = {
- ...mockConfig,
- getEnableRecursiveFileSearch: vi.fn(() => false),
- } as unknown as Config;
-
- vi.mocked(fs.readdir).mockResolvedValue([
- { name: 'data', isDirectory: () => true },
- { name: 'dist', isDirectory: () => true },
- ] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
-
- renderHook(() => {
- const textBuffer = useTextBufferForTest('@d');
- return useCompletion(
- textBuffer,
- testCwd,
- slashCommands,
- mockCommandContext,
- mockConfigNoRecursive,
- );
- });
-
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 150));
- });
-
- // `glob` should not be called because recursive search is disabled
- expect(glob).not.toHaveBeenCalled();
- // `fs.readdir` should be called for the top-level directory instead
- expect(fs.readdir).toHaveBeenCalledWith(testCwd, { withFileTypes: true });
- });
-
- it('should work without config (fallback behavior)', async () => {
- vi.mocked(fs.readdir).mockResolvedValue([
- { name: 'src', isDirectory: () => true },
- { name: 'node_modules', isDirectory: () => true },
- { name: 'README.md', isDirectory: () => false },
- ] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
-
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('@');
- return useCompletion(
- textBuffer,
- testCwd,
- slashCommands,
- mockCommandContext,
- undefined,
- );
- });
-
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 150));
- });
-
- 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 () => {
- vi.mocked(fs.readdir).mockResolvedValue([
- { name: 'src', isDirectory: () => true },
- { name: 'README.md', isDirectory: () => false },
- ] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
-
- const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
-
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('@');
- return useCompletion(
- textBuffer,
- testCwd,
- slashCommands,
- mockCommandContext,
- mockConfig as Config,
- );
- });
-
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 150));
- });
-
- // 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();
- });
-
- it('should handle directory-specific completions with git filtering', async () => {
- vi.mocked(fs.readdir).mockResolvedValue([
- { name: 'component.tsx', isDirectory: () => false },
- { name: 'temp.log', isDirectory: () => false },
- { name: 'index.ts', isDirectory: () => false },
- ] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
-
- mockFileDiscoveryService.shouldGitIgnoreFile.mockImplementation(
- (path: string) => path.includes('.log'),
- );
- mockFileDiscoveryService.shouldIgnoreFile.mockImplementation(
- (path: string, options) => {
- if (options?.respectGitIgnore) {
- return mockFileDiscoveryService.shouldGitIgnoreFile(path);
- }
- if (options?.respectGeminiIgnore) {
- return mockFileDiscoveryService.shouldGeminiIgnoreFile(path);
- }
- return false;
- },
- );
-
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('@src/comp');
- return useCompletion(
- textBuffer,
- testCwd,
- slashCommands,
- mockCommandContext,
- mockConfig as Config,
- );
- });
-
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 150));
- });
-
- // Should filter out .log files but include matching .tsx files
- expect(result.current.suggestions).toEqual([
- { label: 'component.tsx', value: 'component.tsx' },
- ]);
- });
-
- it('should use glob for top-level @ completions when available', async () => {
- const globResults = [`${testCwd}/src/index.ts`, `${testCwd}/README.md`];
- vi.mocked(glob).mockResolvedValue(globResults);
-
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('@s');
- return useCompletion(
- textBuffer,
- testCwd,
- slashCommands,
- mockCommandContext,
- mockConfig as Config,
- );
- });
-
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 150));
- });
-
- expect(glob).toHaveBeenCalledWith('**/s*', {
- cwd: testCwd,
- dot: false,
- nocase: true,
- });
- expect(fs.readdir).not.toHaveBeenCalled(); // Ensure glob is used instead of readdir
- expect(result.current.suggestions).toEqual([
- { label: 'README.md', value: 'README.md' },
- { label: 'src/index.ts', value: 'src/index.ts' },
- ]);
- });
-
- it('should include dotfiles in glob search when input starts with a dot', async () => {
- const globResults = [
- `${testCwd}/.env`,
- `${testCwd}/.gitignore`,
- `${testCwd}/src/index.ts`,
- ];
- vi.mocked(glob).mockResolvedValue(globResults);
-
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('@.');
- return useCompletion(
- textBuffer,
- testCwd,
- slashCommands,
- mockCommandContext,
- mockConfig as Config,
- );
- });
-
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 150));
- });
-
- expect(glob).toHaveBeenCalledWith('**/.*', {
- cwd: testCwd,
- dot: true,
- nocase: true,
- });
- expect(fs.readdir).not.toHaveBeenCalled();
- expect(result.current.suggestions).toEqual([
- { label: '.env', value: '.env' },
- { label: '.gitignore', value: '.gitignore' },
- { label: 'src/index.ts', value: 'src/index.ts' },
- ]);
- });
-
- it('should suggest top-level command names based on partial input', async () => {
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('/mem');
- return useCompletion(
- textBuffer,
- '/test/cwd',
- mockSlashCommands,
- mockCommandContext,
- );
- });
-
- expect(result.current.suggestions).toEqual([
- { label: 'memory', value: 'memory', description: 'Manage memory' },
- ]);
- expect(result.current.showSuggestions).toBe(true);
- });
-
- it.each([['/?'], ['/usage']])(
- 'should not suggest commands when altNames is fully typed',
- async (altName) => {
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest(altName);
- return useCompletion(
- textBuffer,
- '/test/cwd',
- mockSlashCommands,
- mockCommandContext,
- );
- });
-
- expect(result.current.suggestions).toHaveLength(0);
- },
- );
-
- it('should suggest commands based on partial altNames matches', async () => {
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('/usag'); // part of the word "usage"
- return useCompletion(
- textBuffer,
- '/test/cwd',
- mockSlashCommands,
- mockCommandContext,
- );
- });
-
- expect(result.current.suggestions).toEqual([
- {
- label: 'stats',
- value: 'stats',
- description: 'check session stats. Usage: /stats [model|tools]',
- },
- ]);
- });
-
- it('should suggest sub-command names for a parent command', async () => {
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('/memory a');
- return useCompletion(
- textBuffer,
- '/test/cwd',
- mockSlashCommands,
- mockCommandContext,
- );
- });
-
- expect(result.current.suggestions).toEqual([
- { 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 { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('/memory ');
- return useCompletion(
- textBuffer,
- '/test/cwd',
- mockSlashCommands,
- 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 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 mockCommandsWithFiltering = JSON.parse(
- JSON.stringify(mockSlashCommands),
- ) as SlashCommand[];
-
- const chatCmd = mockCommandsWithFiltering.find(
- (cmd) => cmd.name === 'chat',
- );
- if (!chatCmd || !chatCmd.subCommands) {
- throw new Error(
- "Test setup error: Could not find the 'chat' command with subCommands in the mock data.",
- );
- }
-
- const resumeCmd = chatCmd.subCommands.find((sc) => sc.name === 'resume');
- if (!resumeCmd) {
- throw new Error(
- "Test setup error: Could not find the 'resume' sub-command in the mock data.",
- );
- }
-
- resumeCmd.completion = mockCompletionFn;
-
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('/chat resume my-ch');
- return useCompletion(
- textBuffer,
- '/test/cwd',
- mockCommandsWithFiltering,
- 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 not provide suggestions for a fully typed command that has no sub-commands or argument completion', async () => {
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('/clear ');
- return useCompletion(
- textBuffer,
- '/test/cwd',
- mockSlashCommands,
- mockCommandContext,
- );
- });
-
- expect(result.current.suggestions).toHaveLength(0);
- expect(result.current.showSuggestions).toBe(false);
- });
-
- it('should not provide suggestions for an unknown command', async () => {
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('/unknown-command');
- return useCompletion(
- textBuffer,
- '/test/cwd',
- mockSlashCommands,
- mockCommandContext,
- );
- });
-
- expect(result.current.suggestions).toHaveLength(0);
- expect(result.current.showSuggestions).toBe(false);
- });
-
- it('should suggest sub-commands for a fully typed parent command without a trailing space', async () => {
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('/memory'); // Note: no trailing space
- return useCompletion(
- textBuffer,
- '/test/cwd',
- mockSlashCommands,
- 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 NOT provide suggestions for a perfectly typed command that is a leaf node', async () => {
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('/clear'); // No trailing space
- return useCompletion(
- textBuffer,
- '/test/cwd',
- mockSlashCommands,
- mockCommandContext,
- );
- });
-
- expect(result.current.suggestions).toHaveLength(0);
- expect(result.current.showSuggestions).toBe(false);
- });
-
- 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 isolatedMockCommands = JSON.parse(
- JSON.stringify(mockSlashCommands),
- ) as SlashCommand[];
-
- const resumeCommand = isolatedMockCommands
- .find((cmd) => cmd.name === 'chat')
- ?.subCommands?.find((cmd) => cmd.name === 'resume');
-
- if (!resumeCommand) {
- throw new Error(
- 'Test setup failed: could not find resume command in mock',
- );
- }
- resumeCommand.completion = mockCompletionFn;
-
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('/chat resume '); // Trailing space, no partial argument
- return useCompletion(
- textBuffer,
- '/test/cwd',
- isolatedMockCommands,
- mockCommandContext,
- );
- });
-
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 150));
- });
-
- expect(mockCompletionFn).toHaveBeenCalledWith(mockCommandContext, '');
- expect(result.current.suggestions).toHaveLength(3);
- expect(result.current.showSuggestions).toBe(true);
- });
-
- it('should suggest all top-level commands for the root slash', async () => {
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('/');
- return useCompletion(
- textBuffer,
- '/test/cwd',
- mockSlashCommands,
- mockCommandContext,
- );
- });
-
- expect(result.current.suggestions.length).toBe(mockSlashCommands.length);
- expect(result.current.suggestions.map((s) => s.label)).toEqual(
- expect.arrayContaining(['help', 'clear', 'memory', 'chat', 'stats']),
- );
- });
-
- it('should provide no suggestions for an invalid sub-command', async () => {
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('/memory dothisnow');
- return useCompletion(
- textBuffer,
- '/test/cwd',
- mockSlashCommands,
- mockCommandContext,
- );
- });
-
- expect(result.current.suggestions).toHaveLength(0);
- expect(result.current.showSuggestions).toBe(false);
- });
-});
diff --git a/packages/cli/src/ui/hooks/useCompletion.test.ts b/packages/cli/src/ui/hooks/useCompletion.test.ts
index 96e8f156..cd525435 100644
--- a/packages/cli/src/ui/hooks/useCompletion.test.ts
+++ b/packages/cli/src/ui/hooks/useCompletion.test.ts
@@ -7,1068 +7,1086 @@
/** @vitest-environment jsdom */
import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest';
-import type { Mocked } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useCompletion } from './useCompletion.js';
import * as fs from 'fs/promises';
-import { glob } from 'glob';
-import {
- CommandContext,
- CommandKind,
- SlashCommand,
-} from '../commands/types.js';
+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 { useTextBuffer } from '../components/shared/text-buffer.js';
+import { useTextBuffer, TextBuffer } from '../components/shared/text-buffer.js';
-// Helper to create real TextBuffer objects within renderHook
-const useTextBufferForTest = (text: string) => {
- const cursorOffset = text.length;
+describe('useCompletion', () => {
+ let testRootDir: string;
+ let mockConfig: Config;
- return useTextBuffer({
- initialText: text,
- initialCursorOffset: cursorOffset,
- viewport: { width: 80, height: 20 },
- isValidPath: () => false,
- onChange: () => {},
- });
-};
+ // A minimal mock is sufficient for these tests.
+ const mockCommandContext = {} as CommandContext;
-// Mock dependencies
-vi.mock('fs/promises');
-vi.mock('glob');
-vi.mock('@google/gemini-cli-core', async () => {
- const actual = await vi.importActual('@google/gemini-cli-core');
- return {
- ...actual,
- FileDiscoveryService: vi.fn(),
- isNodeError: vi.fn((error) => error.code === 'ENOENT'),
- escapePath: vi.fn((path) => path),
- unescapePath: vi.fn((path) => path),
- getErrorMessage: vi.fn((error) => error.message),
- };
-});
-vi.mock('glob');
+ async function createEmptyDir(...pathSegments: string[]) {
+ const fullPath = path.join(testRootDir, ...pathSegments);
+ await fs.mkdir(fullPath, { recursive: true });
+ return fullPath;
+ }
-describe('useCompletion', () => {
- let mockFileDiscoveryService: Mocked<FileDiscoveryService>;
- let mockConfig: Mocked<Config>;
- let mockCommandContext: CommandContext;
- let mockSlashCommands: SlashCommand[];
+ 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;
+ }
- const testCwd = '/test/project';
-
- beforeEach(() => {
- mockFileDiscoveryService = {
- shouldGitIgnoreFile: vi.fn(),
- shouldGeminiIgnoreFile: vi.fn(),
- shouldIgnoreFile: vi.fn(),
- filterFiles: vi.fn(),
- getGeminiIgnorePatterns: vi.fn(),
- projectRoot: '',
- gitIgnoreFilter: null,
- geminiIgnoreFilter: null,
- } as unknown as Mocked<FileDiscoveryService>;
+ // Helper to create real TextBuffer objects within renderHook
+ function useTextBufferForTest(text: string) {
+ return useTextBuffer({
+ initialText: text,
+ initialCursorOffset: text.length,
+ viewport: { width: 80, height: 20 },
+ isValidPath: () => false,
+ onChange: () => {},
+ });
+ }
+ beforeEach(async () => {
+ testRootDir = await fs.mkdtemp(
+ path.join(os.tmpdir(), 'completion-unit-test-'),
+ );
mockConfig = {
- getFileFilteringRespectGitIgnore: vi.fn(() => true),
- getFileService: vi.fn().mockReturnValue(mockFileDiscoveryService),
- getEnableRecursiveFileSearch: vi.fn(() => true),
+ getTargetDir: () => testRootDir,
+ getProjectRoot: () => testRootDir,
getFileFilteringOptions: vi.fn(() => ({
respectGitIgnore: true,
respectGeminiIgnore: true,
})),
- } as unknown as Mocked<Config>;
-
- mockCommandContext = {} as CommandContext;
-
- mockSlashCommands = [
- {
- name: 'help',
- altNames: ['?'],
- description: 'Show help',
- action: vi.fn(),
- kind: CommandKind.BUILT_IN,
- },
- {
- name: 'stats',
- altNames: ['usage'],
- description: 'check session stats. Usage: /stats [model|tools]',
- action: vi.fn(),
- kind: CommandKind.BUILT_IN,
- },
- {
- name: 'clear',
- description: 'Clear the screen',
- action: vi.fn(),
- kind: CommandKind.BUILT_IN,
- },
- {
- name: 'memory',
- description: 'Manage memory',
- kind: CommandKind.BUILT_IN,
- subCommands: [
- {
- name: 'show',
- description: 'Show memory',
- kind: CommandKind.BUILT_IN,
- action: vi.fn(),
- },
- {
- name: 'add',
- description: 'Add to memory',
- kind: CommandKind.BUILT_IN,
- action: vi.fn(),
- },
- ],
- },
- {
- name: 'chat',
- description: 'Manage chat history',
- kind: CommandKind.BUILT_IN,
- subCommands: [
- {
- name: 'save',
- description: 'Save chat',
- kind: CommandKind.BUILT_IN,
-
- action: vi.fn(),
- },
- {
- name: 'resume',
- description: 'Resume a saved chat',
- kind: CommandKind.BUILT_IN,
-
- action: vi.fn(),
- completion: vi.fn().mockResolvedValue(['chat1', 'chat2']),
- },
- ],
- },
- ];
+ getEnableRecursiveFileSearch: vi.fn(() => true),
+ getFileService: vi.fn(() => new FileDiscoveryService(testRootDir)),
+ } as unknown as Config;
vi.clearAllMocks();
});
- afterEach(() => {
+ afterEach(async () => {
vi.restoreAllMocks();
+ await fs.rm(testRootDir, { recursive: true, force: true });
});
- describe('Hook initialization and state', () => {
- it('should initialize with default state', () => {
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('');
- return useCompletion(
- textBuffer,
- testCwd,
- mockSlashCommands,
- mockCommandContext,
- mockConfig,
- );
- });
-
- expect(result.current.suggestions).toEqual([]);
- expect(result.current.activeSuggestionIndex).toBe(-1);
- expect(result.current.visibleStartIndex).toBe(0);
- expect(result.current.showSuggestions).toBe(false);
- expect(result.current.isLoadingSuggestions).toBe(false);
- });
-
- it('should reset state when query becomes inactive', () => {
- const { result, rerender } = renderHook(
- ({ text }) => {
- const textBuffer = useTextBufferForTest(text);
- return useCompletion(
- textBuffer,
- testCwd,
- mockSlashCommands,
+ 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(() =>
+ useCompletion(
+ useTextBufferForTest(''),
+ testRootDir,
+ slashCommands,
mockCommandContext,
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 provide required functions', () => {
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('');
- return useCompletion(
- textBuffer,
- testCwd,
- mockSlashCommands,
- mockCommandContext,
- mockConfig,
+ ),
);
+
+ 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);
});
- expect(typeof result.current.setActiveSuggestionIndex).toBe('function');
- expect(typeof result.current.setShowSuggestions).toBe('function');
- expect(typeof result.current.resetCompletionState).toBe('function');
- expect(typeof result.current.navigateUp).toBe('function');
- expect(typeof result.current.navigateDown).toBe('function');
- });
- });
+ it('should reset state when isActive becomes false', () => {
+ const slashCommands = [
+ {
+ name: 'help',
+ altNames: ['?'],
+ description: 'Show help',
+ action: vi.fn(),
+ },
+ ] as unknown as SlashCommand[];
- describe('resetCompletionState', () => {
- it('should reset all state to default values', () => {
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('/help');
- return useCompletion(
- textBuffer,
- testCwd,
- mockSlashCommands,
- mockCommandContext,
- mockConfig,
+ const { result, rerender } = renderHook(
+ ({ text }) => {
+ const textBuffer = useTextBufferForTest(text);
+ return useCompletion(
+ textBuffer,
+ testRootDir,
+ slashCommands,
+ mockCommandContext,
+ mockConfig,
+ );
+ },
+ { initialProps: { text: '/help' } },
);
- });
- act(() => {
- result.current.setActiveSuggestionIndex(5);
- result.current.setShowSuggestions(true);
- });
+ // Inactive because of the leading space
+ rerender({ text: ' /help' });
- act(() => {
- result.current.resetCompletionState();
+ 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);
});
- 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', () => {
+ const slashCommands = [
+ {
+ name: 'help',
+ description: 'Show help',
+ },
+ ] as unknown as SlashCommand[];
- describe('Navigation functions', () => {
- it('should handle navigateUp with no suggestions', () => {
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('');
- return useCompletion(
- textBuffer,
- testCwd,
- mockSlashCommands,
- mockCommandContext,
- mockConfig,
+ const { result } = renderHook(() =>
+ useCompletion(
+ useTextBufferForTest('/help'),
+ testRootDir,
+ slashCommands,
+ mockCommandContext,
+ mockConfig,
+ ),
);
- });
- act(() => {
- result.current.navigateUp();
- });
-
- expect(result.current.activeSuggestionIndex).toBe(-1);
- });
+ act(() => {
+ result.current.setActiveSuggestionIndex(5);
+ result.current.setShowSuggestions(true);
+ });
- it('should handle navigateDown with no suggestions', () => {
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('');
- return useCompletion(
- textBuffer,
- testCwd,
- mockSlashCommands,
- mockCommandContext,
- mockConfig,
- );
- });
+ act(() => {
+ result.current.resetCompletionState();
+ });
- act(() => {
- result.current.navigateDown();
+ 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);
});
-
- expect(result.current.activeSuggestionIndex).toBe(-1);
});
- it('should navigate up through suggestions with wrap-around', () => {
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('/h');
- return useCompletion(
- textBuffer,
- testCwd,
- mockSlashCommands,
- mockCommandContext,
- mockConfig,
+ describe('Navigation', () => {
+ it('should handle navigateUp with no suggestions', () => {
+ const slashCommands = [
+ { name: 'dummy', description: 'dummy' },
+ ] as unknown as SlashCommand[];
+ const { result } = renderHook(() =>
+ useCompletion(
+ useTextBufferForTest(''),
+ testRootDir,
+ slashCommands,
+ mockCommandContext,
+ mockConfig,
+ ),
);
- });
- expect(result.current.suggestions.length).toBe(1);
- expect(result.current.activeSuggestionIndex).toBe(0);
+ act(() => {
+ result.current.navigateUp();
+ });
- act(() => {
- result.current.navigateUp();
+ expect(result.current.activeSuggestionIndex).toBe(-1);
});
- expect(result.current.activeSuggestionIndex).toBe(0);
- });
-
- it('should navigate down through suggestions with wrap-around', () => {
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('/h');
- return useCompletion(
- textBuffer,
- testCwd,
- mockSlashCommands,
- mockCommandContext,
- mockConfig,
+ it('should handle navigateDown with no suggestions', () => {
+ const slashCommands = [
+ { name: 'dummy', description: 'dummy' },
+ ] as unknown as SlashCommand[];
+ const { result } = renderHook(() =>
+ useCompletion(
+ useTextBufferForTest(''),
+ testRootDir,
+ slashCommands,
+ mockCommandContext,
+ mockConfig,
+ ),
);
- });
- expect(result.current.suggestions.length).toBe(1);
- expect(result.current.activeSuggestionIndex).toBe(0);
+ act(() => {
+ result.current.navigateDown();
+ });
- act(() => {
- result.current.navigateDown();
+ expect(result.current.activeSuggestionIndex).toBe(-1);
});
- expect(result.current.activeSuggestionIndex).toBe(0);
- });
-
- it('should handle navigation with multiple suggestions', () => {
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('/');
- return useCompletion(
- textBuffer,
- testCwd,
- mockSlashCommands,
- mockCommandContext,
- mockConfig,
+ it('should navigate up through suggestions with wrap-around', () => {
+ const slashCommands = [
+ {
+ name: 'help',
+ description: 'Show help',
+ },
+ ] as unknown as SlashCommand[];
+ const { result } = renderHook(() =>
+ useCompletion(
+ useTextBufferForTest('/h'),
+ testRootDir,
+ slashCommands,
+ mockCommandContext,
+ mockConfig,
+ ),
);
- });
- expect(result.current.suggestions.length).toBe(5);
- expect(result.current.activeSuggestionIndex).toBe(0);
+ expect(result.current.suggestions.length).toBe(1);
+ expect(result.current.activeSuggestionIndex).toBe(0);
- act(() => {
- result.current.navigateDown();
- });
- expect(result.current.activeSuggestionIndex).toBe(1);
+ act(() => {
+ result.current.navigateUp();
+ });
- act(() => {
- result.current.navigateDown();
+ expect(result.current.activeSuggestionIndex).toBe(0);
});
- expect(result.current.activeSuggestionIndex).toBe(2);
- act(() => {
- result.current.navigateUp();
- });
- expect(result.current.activeSuggestionIndex).toBe(1);
+ it('should navigate down through suggestions with wrap-around', () => {
+ const slashCommands = [
+ {
+ name: 'help',
+ description: 'Show help',
+ },
+ ] as unknown as SlashCommand[];
+ const { result } = renderHook(() =>
+ useCompletion(
+ useTextBufferForTest('/h'),
+ testRootDir,
+ slashCommands,
+ mockCommandContext,
+ mockConfig,
+ ),
+ );
- act(() => {
- result.current.navigateUp();
- });
- expect(result.current.activeSuggestionIndex).toBe(0);
+ expect(result.current.suggestions.length).toBe(1);
+ expect(result.current.activeSuggestionIndex).toBe(0);
- act(() => {
- result.current.navigateUp();
- });
- expect(result.current.activeSuggestionIndex).toBe(4);
- });
+ act(() => {
+ result.current.navigateDown();
+ });
- it('should handle navigation with large suggestion lists and scrolling', () => {
- const largeMockCommands = Array.from({ length: 15 }, (_, i) => ({
- name: `command${i}`,
- description: `Command ${i}`,
- kind: CommandKind.BUILT_IN,
- action: vi.fn(),
- }));
+ expect(result.current.activeSuggestionIndex).toBe(0);
+ });
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('/command');
- return useCompletion(
- textBuffer,
- testCwd,
- largeMockCommands,
- mockCommandContext,
- mockConfig,
+ 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(() =>
+ useCompletion(
+ useTextBufferForTest('/'),
+ testRootDir,
+ slashCommands,
+ mockCommandContext,
+ mockConfig,
+ ),
);
- });
- expect(result.current.suggestions.length).toBe(15);
- expect(result.current.activeSuggestionIndex).toBe(0);
- expect(result.current.visibleStartIndex).toBe(0);
+ expect(result.current.suggestions.length).toBe(5);
+ expect(result.current.activeSuggestionIndex).toBe(0);
- act(() => {
- result.current.navigateUp();
- });
+ act(() => {
+ result.current.navigateDown();
+ });
+ expect(result.current.activeSuggestionIndex).toBe(1);
- expect(result.current.activeSuggestionIndex).toBe(14);
- expect(result.current.visibleStartIndex).toBe(Math.max(0, 15 - 8));
- });
- });
+ act(() => {
+ result.current.navigateDown();
+ });
+ expect(result.current.activeSuggestionIndex).toBe(2);
- describe('Slash command completion', () => {
- it('should show all commands for root slash', () => {
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('/');
- return useCompletion(
- textBuffer,
- testCwd,
- mockSlashCommands,
- mockCommandContext,
- mockConfig,
- );
- });
+ act(() => {
+ result.current.navigateUp();
+ });
+ expect(result.current.activeSuggestionIndex).toBe(1);
- expect(result.current.suggestions).toHaveLength(5);
- expect(result.current.suggestions.map((s) => s.label)).toEqual(
- expect.arrayContaining(['help', 'clear', 'memory', 'chat', 'stats']),
- );
- expect(result.current.showSuggestions).toBe(true);
- expect(result.current.activeSuggestionIndex).toBe(0);
- });
+ act(() => {
+ result.current.navigateUp();
+ });
+ expect(result.current.activeSuggestionIndex).toBe(0);
- it('should filter commands by prefix', () => {
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('/h');
- return useCompletion(
- textBuffer,
- testCwd,
- mockSlashCommands,
- mockCommandContext,
- mockConfig,
- );
+ act(() => {
+ result.current.navigateUp();
+ });
+ expect(result.current.activeSuggestionIndex).toBe(4);
});
- expect(result.current.suggestions).toHaveLength(1);
- expect(result.current.suggestions[0].label).toBe('help');
- expect(result.current.suggestions[0].description).toBe('Show help');
- });
+ 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.each([['/?'], ['/usage']])(
- 'should not suggest commands when altNames is fully typed',
- (altName) => {
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest(altName);
- return useCompletion(
- textBuffer,
- testCwd,
- mockSlashCommands,
+ const { result } = renderHook(() =>
+ useCompletion(
+ useTextBufferForTest('/command'),
+ testRootDir,
+ largeMockCommands,
mockCommandContext,
mockConfig,
- );
- });
-
- expect(result.current.suggestions).toHaveLength(0);
- },
- );
-
- it('should suggest commands based on partial altNames matches', () => {
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('/usag'); // part of the word "usage"
- return useCompletion(
- textBuffer,
- testCwd,
- mockSlashCommands,
- mockCommandContext,
- mockConfig,
+ ),
);
- });
- expect(result.current.suggestions).toHaveLength(1);
- expect(result.current.suggestions[0].label).toBe('stats');
- });
+ expect(result.current.suggestions.length).toBe(15);
+ expect(result.current.activeSuggestionIndex).toBe(0);
+ expect(result.current.visibleStartIndex).toBe(0);
- it('should not show suggestions for exact leaf command match', () => {
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('/clear');
- return useCompletion(
- textBuffer,
- testCwd,
- mockSlashCommands,
- mockCommandContext,
- mockConfig,
- );
- });
-
- expect(result.current.suggestions).toHaveLength(0);
- expect(result.current.showSuggestions).toBe(false);
- });
+ act(() => {
+ result.current.navigateUp();
+ });
- it('should show sub-commands for parent commands', () => {
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('/memory');
- return useCompletion(
- textBuffer,
- testCwd,
- mockSlashCommands,
- mockCommandContext,
- mockConfig,
- );
+ expect(result.current.activeSuggestionIndex).toBe(14);
+ expect(result.current.visibleStartIndex).toBe(Math.max(0, 15 - 8));
});
-
- expect(result.current.suggestions).toHaveLength(2);
- expect(result.current.suggestions.map((s) => s.label)).toEqual(
- expect.arrayContaining(['show', 'add']),
- );
});
+ });
- it('should show all sub-commands after parent command with space', () => {
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('/memory ');
- return useCompletion(
- textBuffer,
- testCwd,
- mockSlashCommands,
- mockCommandContext,
- mockConfig,
+ 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(() =>
+ useCompletion(
+ useTextBufferForTest('/'),
+ testRootDir,
+ slashCommands,
+ mockCommandContext,
+ ),
);
- });
- expect(result.current.suggestions).toHaveLength(2);
- expect(result.current.suggestions.map((s) => s.label)).toEqual(
- expect.arrayContaining(['show', 'add']),
- );
- });
-
- it('should filter sub-commands by prefix', () => {
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('/memory a');
- return useCompletion(
- textBuffer,
- testCwd,
- mockSlashCommands,
- mockCommandContext,
- mockConfig,
+ expect(result.current.suggestions.length).toBe(slashCommands.length);
+ expect(result.current.suggestions.map((s) => s.label)).toEqual(
+ expect.arrayContaining(['help', 'clear', 'memory', 'chat', 'stats']),
);
});
- expect(result.current.suggestions).toHaveLength(1);
- expect(result.current.suggestions[0].label).toBe('add');
- });
-
- it('should handle unknown command gracefully', () => {
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('/unknown');
- return useCompletion(
- textBuffer,
- testCwd,
- mockSlashCommands,
- mockCommandContext,
- mockConfig,
+ it('should filter commands based on partial input', async () => {
+ const slashCommands = [
+ {
+ name: 'memory',
+ description: 'Manage memory',
+ },
+ ] as unknown as SlashCommand[];
+ const { result } = renderHook(() =>
+ useCompletion(
+ useTextBufferForTest('/mem'),
+ testRootDir,
+ slashCommands,
+ mockCommandContext,
+ ),
);
+
+ expect(result.current.suggestions).toEqual([
+ { label: 'memory', value: 'memory', description: 'Manage memory' },
+ ]);
+ expect(result.current.showSuggestions).toBe(true);
});
- expect(result.current.suggestions).toHaveLength(0);
- expect(result.current.showSuggestions).toBe(false);
- });
- });
+ 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(() =>
+ useCompletion(
+ useTextBufferForTest('/usag'), // part of the word "usage"
+ testRootDir,
+ slashCommands,
+ mockCommandContext,
+ ),
+ );
- describe('Command argument completion', () => {
- it('should call completion function for command arguments', async () => {
- const completionFn = vi.fn().mockResolvedValue(['arg1', 'arg2']);
- const commandsWithCompletion = [...mockSlashCommands];
- const chatCommand = commandsWithCompletion.find(
- (cmd) => cmd.name === 'chat',
- );
- const resumeCommand = chatCommand?.subCommands?.find(
- (cmd) => cmd.name === 'resume',
- );
- if (resumeCommand) {
- resumeCommand.completion = completionFn;
- }
+ expect(result.current.suggestions).toEqual([
+ {
+ label: 'stats',
+ value: 'stats',
+ description: 'check session stats. Usage: /stats [model|tools]',
+ },
+ ]);
+ });
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('/chat resume ');
- return useCompletion(
- textBuffer,
- testCwd,
- commandsWithCompletion,
- mockCommandContext,
- mockConfig,
+ 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(() =>
+ useCompletion(
+ useTextBufferForTest('/clear'), // No trailing space
+ testRootDir,
+ slashCommands,
+ mockCommandContext,
+ ),
);
- });
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 150));
+ expect(result.current.suggestions).toHaveLength(0);
+ expect(result.current.showSuggestions).toBe(false);
});
- expect(completionFn).toHaveBeenCalledWith(mockCommandContext, '');
- expect(result.current.suggestions).toHaveLength(2);
- expect(result.current.suggestions.map((s) => s.label)).toEqual([
- 'arg1',
- 'arg2',
- ]);
- });
+ 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[];
- it('should call completion function with partial argument', async () => {
- const completionFn = vi.fn().mockResolvedValue(['arg1', 'arg2']);
- const commandsWithCompletion = [...mockSlashCommands];
- const chatCommand = commandsWithCompletion.find(
- (cmd) => cmd.name === 'chat',
- );
- const resumeCommand = chatCommand?.subCommands?.find(
- (cmd) => cmd.name === 'resume',
+ const { result } = renderHook(() =>
+ useCompletion(
+ useTextBufferForTest(query),
+ testRootDir,
+ mockSlashCommands,
+ mockCommandContext,
+ ),
+ );
+
+ expect(result.current.suggestions).toHaveLength(0);
+ },
);
- if (resumeCommand) {
- resumeCommand.completion = completionFn;
- }
- renderHook(() => {
- const textBuffer = useTextBufferForTest('/chat resume ar');
- return useCompletion(
- textBuffer,
- testCwd,
- commandsWithCompletion,
- mockCommandContext,
- mockConfig,
+ 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(() =>
+ useCompletion(
+ useTextBufferForTest('/clear '),
+ testRootDir,
+ slashCommands,
+ mockCommandContext,
+ ),
);
- });
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 150));
+ expect(result.current.suggestions).toHaveLength(0);
+ expect(result.current.showSuggestions).toBe(false);
});
- expect(completionFn).toHaveBeenCalledWith(mockCommandContext, 'ar');
- });
-
- it('should handle completion function that returns null', async () => {
- const completionFn = vi.fn().mockResolvedValue(null);
- const commandsWithCompletion = [...mockSlashCommands];
- const chatCommand = commandsWithCompletion.find(
- (cmd) => cmd.name === 'chat',
- );
- const resumeCommand = chatCommand?.subCommands?.find(
- (cmd) => cmd.name === 'resume',
- );
- if (resumeCommand) {
- resumeCommand.completion = completionFn;
- }
-
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('/chat resume ');
- return useCompletion(
- textBuffer,
- testCwd,
- commandsWithCompletion,
- mockCommandContext,
- mockConfig,
+ it('should not provide suggestions for an unknown command', async () => {
+ const slashCommands = [
+ {
+ name: 'help',
+ description: 'Show help',
+ },
+ ] as unknown as SlashCommand[];
+ const { result } = renderHook(() =>
+ useCompletion(
+ useTextBufferForTest('/unknown-command'),
+ testRootDir,
+ slashCommands,
+ mockCommandContext,
+ ),
);
- });
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 150));
+ expect(result.current.suggestions).toHaveLength(0);
+ expect(result.current.showSuggestions).toBe(false);
});
-
- expect(result.current.suggestions).toHaveLength(0);
- expect(result.current.showSuggestions).toBe(false);
});
- });
- describe('Slash command completion with namespaced names', () => {
- let commandsWithNamespaces: SlashCommand[];
+ 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[];
- beforeEach(() => {
- commandsWithNamespaces = [
- ...mockSlashCommands,
- {
- name: 'git:commit',
- description: 'A namespaced git command',
- kind: CommandKind.FILE,
- action: vi.fn(),
- },
- {
- name: 'git:push',
- description: 'Another namespaced git command',
- kind: CommandKind.FILE,
- action: vi.fn(),
- },
- {
- name: 'docker:build',
- description: 'A docker command',
- kind: CommandKind.FILE,
- action: vi.fn(),
- },
- ];
- });
+ const { result } = renderHook(() =>
+ useCompletion(
+ useTextBufferForTest('/memory'), // Note: no trailing space
+ testRootDir,
+ slashCommands,
+ mockCommandContext,
+ ),
+ );
- it('should suggest a namespaced command based on a partial match', () => {
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('/git:co');
- return useCompletion(
- textBuffer,
- testCwd,
- commandsWithNamespaces,
- mockCommandContext,
- mockConfig,
+ // 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);
});
- expect(result.current.suggestions).toHaveLength(1);
- expect(result.current.suggestions[0].label).toBe('git:commit');
- });
+ 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(() =>
+ useCompletion(
+ useTextBufferForTest('/memory'),
+ testRootDir,
+ slashCommands,
+ mockCommandContext,
+ ),
+ );
- it('should suggest all commands within a namespace when the namespace prefix is typed', () => {
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('/git:');
- return useCompletion(
- textBuffer,
- testCwd,
- commandsWithNamespaces,
- mockCommandContext,
- mockConfig,
+ 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.suggestions).toHaveLength(2);
- expect(result.current.suggestions.map((s) => s.label)).toEqual(
- expect.arrayContaining(['git:commit', 'git:push']),
- );
-
- expect(result.current.suggestions.map((s) => s.label)).not.toContain(
- 'docker:build',
- );
- });
-
- it('should not provide suggestions if the namespaced command is a perfect leaf match', () => {
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('/git:commit');
- return useCompletion(
- textBuffer,
- testCwd,
- commandsWithNamespaces,
- mockCommandContext,
- mockConfig,
+ 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(() =>
+ useCompletion(
+ useTextBufferForTest('/memory a'),
+ testRootDir,
+ slashCommands,
+ mockCommandContext,
+ ),
);
+
+ expect(result.current.suggestions).toEqual([
+ { label: 'add', value: 'add', description: 'Add to memory' },
+ ]);
});
- expect(result.current.showSuggestions).toBe(false);
- expect(result.current.suggestions).toHaveLength(0);
- });
- });
+ 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(() =>
+ useCompletion(
+ useTextBufferForTest('/memory dothisnow'),
+ testRootDir,
+ slashCommands,
+ mockCommandContext,
+ ),
+ );
- describe('File path completion (@-syntax)', () => {
- beforeEach(() => {
- vi.mocked(fs.readdir).mockResolvedValue([
- { name: 'file1.txt', isDirectory: () => false },
- { name: 'file2.js', isDirectory: () => false },
- { name: 'folder1', isDirectory: () => true },
- { name: '.hidden', isDirectory: () => false },
- ] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
+ expect(result.current.suggestions).toHaveLength(0);
+ expect(result.current.showSuggestions).toBe(false);
+ });
});
- it('should show file completions for @ prefix', async () => {
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('@');
- return useCompletion(
- textBuffer,
- testCwd,
- mockSlashCommands,
- mockCommandContext,
- mockConfig,
- );
- });
+ 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 act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 150));
- });
+ const slashCommands = [
+ {
+ name: 'chat',
+ description: 'Manage chat history',
+ subCommands: [
+ {
+ name: 'resume',
+ description: 'Resume a saved chat',
+ completion: mockCompletionFn,
+ },
+ ],
+ },
+ ] as unknown as SlashCommand[];
- expect(result.current.suggestions).toHaveLength(3);
- expect(result.current.suggestions.map((s) => s.label)).toEqual(
- expect.arrayContaining(['file1.txt', 'file2.js', 'folder1/']),
- );
- });
+ const { result } = renderHook(() =>
+ useCompletion(
+ useTextBufferForTest('/chat resume my-ch'),
+ testRootDir,
+ slashCommands,
+ mockCommandContext,
+ ),
+ );
- it('should filter files by prefix', async () => {
- // Mock for recursive search since enableRecursiveFileSearch is true
- vi.mocked(glob).mockResolvedValue([
- `${testCwd}/file1.txt`,
- `${testCwd}/file2.js`,
- ]);
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 150));
+ });
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('@file');
- return useCompletion(
- textBuffer,
- testCwd,
- mockSlashCommands,
+ expect(mockCompletionFn).toHaveBeenCalledWith(
mockCommandContext,
- mockConfig,
+ 'my-ch',
);
- });
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 150));
+ 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' },
+ ]);
});
- expect(result.current.suggestions).toHaveLength(2);
- expect(result.current.suggestions.map((s) => s.label)).toEqual(
- expect.arrayContaining(['file1.txt', 'file2.js']),
- );
- });
+ 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']);
- it('should include hidden files when prefix starts with dot', async () => {
- // Mock for recursive search since enableRecursiveFileSearch is true
- vi.mocked(glob).mockResolvedValue([`${testCwd}/.hidden`]);
+ 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(() => {
- const textBuffer = useTextBufferForTest('@.');
- return useCompletion(
- textBuffer,
- testCwd,
- mockSlashCommands,
- mockCommandContext,
- mockConfig,
+ const { result } = renderHook(() =>
+ useCompletion(
+ useTextBufferForTest('/chat resume '),
+ testRootDir,
+ slashCommands,
+ mockCommandContext,
+ ),
);
- });
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 150));
- });
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 150));
+ });
- expect(result.current.suggestions).toHaveLength(1);
- expect(result.current.suggestions[0].label).toBe('.hidden');
- });
+ expect(mockCompletionFn).toHaveBeenCalledWith(mockCommandContext, '');
+ expect(result.current.suggestions).toHaveLength(3);
+ expect(result.current.showSuggestions).toBe(true);
+ });
- it('should handle ENOENT error gracefully', async () => {
- const enoentError = new Error('No such file or directory');
- (enoentError as Error & { code: string }).code = 'ENOENT';
- vi.mocked(fs.readdir).mockRejectedValue(enoentError);
+ 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(() => {
- const textBuffer = useTextBufferForTest('@nonexistent');
- return useCompletion(
- textBuffer,
- testCwd,
- mockSlashCommands,
- mockCommandContext,
- mockConfig,
+ const { result } = renderHook(() =>
+ useCompletion(
+ useTextBufferForTest('/chat resume '),
+ testRootDir,
+ slashCommands,
+ mockCommandContext,
+ mockConfig,
+ ),
);
- });
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 150));
- });
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 150));
+ });
- expect(result.current.suggestions).toHaveLength(0);
- expect(result.current.showSuggestions).toBe(false);
+ expect(result.current.suggestions).toHaveLength(0);
+ expect(result.current.showSuggestions).toBe(false);
+ });
});
+ });
- it('should handle other errors by resetting state', async () => {
- const consoleErrorSpy = vi
- .spyOn(console, 'error')
- .mockImplementation(() => {});
- vi.mocked(fs.readdir).mockRejectedValue(new Error('Permission denied'));
+ 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');
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('@');
- return useCompletion(
- textBuffer,
- testCwd,
- mockSlashCommands,
- mockCommandContext,
- mockConfig,
+ const { result } = renderHook(() =>
+ useCompletion(
+ useTextBufferForTest('@s'),
+ testRootDir,
+ [],
+ mockCommandContext,
+ mockConfig,
+ ),
);
- });
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 150));
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 150));
+ });
+
+ 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(consoleErrorSpy).toHaveBeenCalled();
- expect(result.current.suggestions).toHaveLength(0);
- expect(result.current.showSuggestions).toBe(false);
- expect(result.current.isLoadingSuggestions).toBe(false);
+ 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');
- consoleErrorSpy.mockRestore();
- });
- });
+ const { result } = renderHook(() =>
+ useCompletion(
+ useTextBufferForTest('@src/comp'),
+ testRootDir,
+ [],
+ mockCommandContext,
+ mockConfig,
+ ),
+ );
- describe('Debouncing', () => {
- it('should debounce file completion requests', async () => {
- // Mock for recursive search since enableRecursiveFileSearch is true
- vi.mocked(glob).mockResolvedValue([`${testCwd}/file1.txt`]);
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 150));
+ });
+
+ // Should filter out .log files but include matching .tsx files
+ expect(result.current.suggestions).toEqual([
+ { label: 'component.tsx', value: 'component.tsx' },
+ ]);
+ });
- const { rerender } = renderHook(
- ({ text }) => {
- const textBuffer = useTextBufferForTest(text);
- return useCompletion(
- textBuffer,
- testCwd,
- mockSlashCommands,
+ 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(() =>
+ useCompletion(
+ useTextBufferForTest('@.'),
+ testRootDir,
+ [],
mockCommandContext,
mockConfig,
- );
- },
- { initialProps: { text: '@f' } },
- );
+ ),
+ );
- rerender({ text: '@fi' });
- rerender({ text: '@fil' });
- rerender({ text: '@file' });
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 150));
+ });
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 150));
+ expect(result.current.suggestions).toEqual([
+ { label: '.env', value: '.env' },
+ { label: '.gitignore', value: '.gitignore' },
+ ]);
});
-
- expect(glob).toHaveBeenCalledTimes(1);
});
- });
- describe('Query handling edge cases', () => {
- it('should handle empty query', () => {
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('');
- return useCompletion(
- textBuffer,
- testCwd,
- mockSlashCommands,
- mockCommandContext,
- mockConfig,
+ 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');
+
+ const { result } = renderHook(() =>
+ useCompletion(
+ useTextBufferForTest('@d'),
+ testRootDir,
+ [],
+ mockCommandContext,
+ mockConfigNoRecursive,
+ ),
);
+
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 150));
+ });
+
+ expect(result.current.suggestions).toEqual([
+ { label: 'data/', value: 'data/' },
+ { label: 'dist/', value: 'dist/' },
+ ]);
});
- expect(result.current.suggestions).toHaveLength(0);
- expect(result.current.showSuggestions).toBe(false);
- });
+ it('should work without config (fallback behavior)', async () => {
+ await createEmptyDir('src');
+ await createEmptyDir('node_modules');
+ await createTestFile('', 'README.md');
- it('should handle query without slash or @', () => {
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('regular text');
- return useCompletion(
- textBuffer,
- testCwd,
- mockSlashCommands,
- mockCommandContext,
- mockConfig,
+ const { result } = renderHook(() =>
+ useCompletion(
+ useTextBufferForTest('@'),
+ testRootDir,
+ [],
+ mockCommandContext,
+ undefined,
+ ),
);
- });
- expect(result.current.suggestions).toHaveLength(0);
- expect(result.current.showSuggestions).toBe(false);
- });
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 150));
+ });
- it('should handle query with whitespace', () => {
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest(' /hel');
- return useCompletion(
- textBuffer,
- testCwd,
- mockSlashCommands,
- mockCommandContext,
- mockConfig,
+ // 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' },
+ ]),
);
});
- expect(result.current.suggestions).toHaveLength(1);
- expect(result.current.suggestions[0].label).toBe('help');
- });
+ 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');
- it('should handle @ at the end of query', async () => {
- // Mock for recursive search since enableRecursiveFileSearch is true
- vi.mocked(glob).mockResolvedValue([`${testCwd}/file1.txt`]);
+ const consoleSpy = vi
+ .spyOn(console, 'warn')
+ .mockImplementation(() => {});
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('some text @');
- return useCompletion(
- textBuffer,
- testCwd,
- mockSlashCommands,
- mockCommandContext,
- mockConfig,
+ const { result } = renderHook(() =>
+ useCompletion(
+ useTextBufferForTest('@'),
+ testRootDir,
+ [],
+ mockCommandContext,
+ mockConfig,
+ ),
);
- });
- // Wait for completion
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 150));
- });
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 150));
+ });
- // Should process the @ query and get suggestions
- expect(result.current.isLoadingSuggestions).toBe(false);
- expect(result.current.suggestions.length).toBeGreaterThanOrEqual(0);
+ // 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();
+ });
});
- });
- describe('File sorting behavior', () => {
- it('should prioritize source files over test files with same base name', async () => {
- // Mock glob to return files with same base name but different extensions
- vi.mocked(glob).mockResolvedValue([
- `${testCwd}/component.test.ts`,
- `${testCwd}/component.ts`,
- `${testCwd}/utils.spec.js`,
- `${testCwd}/utils.js`,
- `${testCwd}/api.test.tsx`,
- `${testCwd}/api.tsx`,
- ]);
+ describe('Git-Aware Filtering', () => {
+ it('should filter git-ignored entries from @ completions', async () => {
+ await createEmptyDir('.git');
+ await createTestFile('dist', '.gitignore');
+ await createEmptyDir('data');
+
+ const { result } = renderHook(() =>
+ useCompletion(
+ useTextBufferForTest('@d'),
+ testRootDir,
+ [],
+ mockCommandContext,
+ mockConfig,
+ ),
+ );
- mockFileDiscoveryService.shouldIgnoreFile.mockReturnValue(false);
+ // Wait for async operations to complete
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 150)); // Account for debounce
+ });
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('@comp');
- return useCompletion(
- textBuffer,
- testCwd,
- mockSlashCommands,
- mockCommandContext,
- mockConfig,
+ expect(result.current.suggestions).toEqual(
+ expect.arrayContaining([{ label: 'data', value: 'data' }]),
);
+ expect(result.current.showSuggestions).toBe(true);
});
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 150));
+ 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');
+
+ // visible
+ await createEmptyDir('src');
+ await createTestFile('', 'README.md');
+
+ const { result } = renderHook(() =>
+ useCompletion(
+ useTextBufferForTest('@'),
+ testRootDir,
+ [],
+ mockCommandContext,
+ mockConfig,
+ ),
+ );
+
+ // Wait for async operations to complete
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 150)); // Account for debounce
+ });
+
+ expect(result.current.suggestions).toEqual([
+ { label: 'README.md', value: 'README.md' },
+ { label: 'src/', value: 'src/' },
+ ]);
+ expect(result.current.showSuggestions).toBe(true);
});
- expect(result.current.suggestions).toHaveLength(6);
+ 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');
- // Extract labels for easier testing
- const labels = result.current.suggestions.map((s) => s.label);
+ const { result } = renderHook(() =>
+ useCompletion(
+ useTextBufferForTest('@t'),
+ testRootDir,
+ [],
+ mockCommandContext,
+ mockConfig,
+ ),
+ );
- // Verify the exact sorted order: source files should come before their test counterparts
- expect(labels).toEqual([
- 'api.tsx',
- 'api.test.tsx',
- 'component.ts',
- 'component.test.ts',
- 'utils.js',
- 'utils.spec.js',
- ]);
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 150));
+ });
+
+ // 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[];
// Create a mock buffer that we can spy on directly
const mockBuffer = {
text: '/mem',
- lines: ['/mem'],
- cursor: [0, 4],
- preferredCol: null,
- selectionAnchor: null,
- allVisualLines: ['/mem'],
- viewportVisualLines: ['/mem'],
- visualCursor: [0, 4],
- visualScrollRow: 0,
setText: vi.fn(),
- insert: vi.fn(),
- newline: vi.fn(),
- backspace: vi.fn(),
- del: vi.fn(),
- move: vi.fn(),
- undo: vi.fn(),
- redo: vi.fn(),
- replaceRange: vi.fn(),
- replaceRangeByOffset: vi.fn(),
- moveToOffset: vi.fn(),
- deleteWordLeft: vi.fn(),
- deleteWordRight: vi.fn(),
- killLineRight: vi.fn(),
- killLineLeft: vi.fn(),
- handleInput: vi.fn(),
- openInExternalEditor: vi.fn(),
- };
+ } as unknown as TextBuffer;
const { result } = renderHook(() =>
useCompletion(
mockBuffer,
- testCwd,
- mockSlashCommands,
+ testRootDir,
+ slashCommands,
mockCommandContext,
mockConfig,
),
@@ -1087,39 +1105,31 @@ describe('useCompletion', () => {
it('should append a sub-command when the parent is complete', () => {
const mockBuffer = {
- text: '/memory ',
- lines: ['/memory '],
- cursor: [0, 8],
- preferredCol: null,
- selectionAnchor: null,
- allVisualLines: ['/memory '],
- viewportVisualLines: ['/memory '],
- visualCursor: [0, 8],
- visualScrollRow: 0,
+ text: '/memory',
setText: vi.fn(),
- insert: vi.fn(),
- newline: vi.fn(),
- backspace: vi.fn(),
- del: vi.fn(),
- move: vi.fn(),
- undo: vi.fn(),
- redo: vi.fn(),
- replaceRange: vi.fn(),
- replaceRangeByOffset: vi.fn(),
- moveToOffset: vi.fn(),
- deleteWordLeft: vi.fn(),
- deleteWordRight: vi.fn(),
- killLineRight: vi.fn(),
- killLineLeft: vi.fn(),
- handleInput: vi.fn(),
- openInExternalEditor: vi.fn(),
- };
+ } as unknown as TextBuffer;
+ 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(() =>
useCompletion(
mockBuffer,
- testCwd,
- mockSlashCommands,
+ testRootDir,
+ slashCommands,
mockCommandContext,
mockConfig,
),
@@ -1141,38 +1151,30 @@ describe('useCompletion', () => {
it('should complete a command with an alternative name', () => {
const mockBuffer = {
text: '/?',
- lines: ['/?'],
- cursor: [0, 2],
- preferredCol: null,
- selectionAnchor: null,
- allVisualLines: ['/?'],
- viewportVisualLines: ['/?'],
- visualCursor: [0, 2],
- visualScrollRow: 0,
setText: vi.fn(),
- insert: vi.fn(),
- newline: vi.fn(),
- backspace: vi.fn(),
- del: vi.fn(),
- move: vi.fn(),
- undo: vi.fn(),
- redo: vi.fn(),
- replaceRange: vi.fn(),
- replaceRangeByOffset: vi.fn(),
- moveToOffset: vi.fn(),
- deleteWordLeft: vi.fn(),
- deleteWordRight: vi.fn(),
- killLineRight: vi.fn(),
- killLineLeft: vi.fn(),
- handleInput: vi.fn(),
- openInExternalEditor: vi.fn(),
- };
+ } as unknown as TextBuffer;
+ 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(() =>
useCompletion(
mockBuffer,
- testCwd,
- mockSlashCommands,
+ testRootDir,
+ slashCommands,
mockCommandContext,
mockConfig,
),
@@ -1196,36 +1198,31 @@ describe('useCompletion', () => {
text: '@src/fi',
lines: ['@src/fi'],
cursor: [0, 7],
- preferredCol: null,
- selectionAnchor: null,
- allVisualLines: ['@src/fi'],
- viewportVisualLines: ['@src/fi'],
- visualCursor: [0, 7],
- visualScrollRow: 0,
setText: vi.fn(),
- insert: vi.fn(),
- newline: vi.fn(),
- backspace: vi.fn(),
- del: vi.fn(),
- move: vi.fn(),
- undo: vi.fn(),
- redo: vi.fn(),
- replaceRange: vi.fn(),
replaceRangeByOffset: vi.fn(),
- moveToOffset: vi.fn(),
- deleteWordLeft: vi.fn(),
- deleteWordRight: vi.fn(),
- killLineRight: vi.fn(),
- killLineLeft: vi.fn(),
- handleInput: vi.fn(),
- openInExternalEditor: vi.fn(),
- };
+ } as unknown as TextBuffer;
+ 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(() =>
useCompletion(
mockBuffer,
- testCwd,
- mockSlashCommands,
+ testRootDir,
+ slashCommands,
mockCommandContext,
mockConfig,
),
@@ -1247,59 +1244,4 @@ describe('useCompletion', () => {
);
});
});
-
- describe('Config and FileDiscoveryService integration', () => {
- it('should work without config', async () => {
- vi.mocked(fs.readdir).mockResolvedValue([
- { name: 'file1.txt', isDirectory: () => false },
- ] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
-
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('@');
- return useCompletion(
- textBuffer,
- testCwd,
- mockSlashCommands,
- mockCommandContext,
- undefined,
- );
- });
-
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 150));
- });
-
- expect(result.current.suggestions).toHaveLength(1);
- expect(result.current.suggestions[0].label).toBe('file1.txt');
- });
-
- it('should respect file filtering when config is provided', async () => {
- vi.mocked(fs.readdir).mockResolvedValue([
- { name: 'file1.txt', isDirectory: () => false },
- { name: 'ignored.log', isDirectory: () => false },
- ] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
-
- mockFileDiscoveryService.shouldIgnoreFile.mockImplementation(
- (path: string) => path.includes('.log'),
- );
-
- const { result } = renderHook(() => {
- const textBuffer = useTextBufferForTest('@');
- return useCompletion(
- textBuffer,
- testCwd,
- mockSlashCommands,
- mockCommandContext,
- mockConfig,
- );
- });
-
- await act(async () => {
- await new Promise((resolve) => setTimeout(resolve, 150));
- });
-
- expect(result.current.suggestions).toHaveLength(1);
- expect(result.current.suggestions[0].label).toBe('file1.txt');
- });
- });
});
diff --git a/packages/cli/src/ui/hooks/useCompletion.ts b/packages/cli/src/ui/hooks/useCompletion.ts
index f4ebfac3..dc45222d 100644
--- a/packages/cli/src/ui/hooks/useCompletion.ts
+++ b/packages/cli/src/ui/hooks/useCompletion.ts
@@ -427,13 +427,10 @@ export function useCompletion(
});
const suggestions: Suggestion[] = files
- .map((file: string) => {
- const relativePath = path.relative(cwd, file);
- return {
- label: relativePath,
- value: escapePath(relativePath),
- };
- })
+ .map((file: string) => ({
+ label: file,
+ value: escapePath(file),
+ }))
.filter((s) => {
if (fileDiscoveryService) {
return !fileDiscoveryService.shouldIgnoreFile(
@@ -475,7 +472,7 @@ export function useCompletion(
fetchedSuggestions = await findFilesRecursively(
cwd,
prefix,
- fileDiscoveryService,
+ null,
filterOptions,
);
}
@@ -518,6 +515,13 @@ export function useCompletion(
});
}
+ // Like glob, we always return forwardslashes, even in windows.
+ fetchedSuggestions = fetchedSuggestions.map((suggestion) => ({
+ ...suggestion,
+ label: suggestion.label.replace(/\\/g, '/'),
+ value: suggestion.value.replace(/\\/g, '/'),
+ }));
+
// Sort by depth, then directories first, then alphabetically
fetchedSuggestions.sort((a, b) => {
const depthA = (a.label.match(/\//g) || []).length;