prefactor(commands): Command Service Prefactor for Extensible Commands (#4511)

This commit is contained in:
Abhi 2025-07-20 16:57:34 -04:00 committed by GitHub
parent 7a9821607b
commit 2a95c8287e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 919 additions and 720 deletions

View File

@ -0,0 +1,118 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
vi.mock('../ui/commands/aboutCommand.js', async () => {
const { CommandKind } = await import('../ui/commands/types.js');
return {
aboutCommand: {
name: 'about',
description: 'About the CLI',
kind: CommandKind.BUILT_IN,
},
};
});
vi.mock('../ui/commands/ideCommand.js', () => ({ ideCommand: vi.fn() }));
vi.mock('../ui/commands/restoreCommand.js', () => ({
restoreCommand: vi.fn(),
}));
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
import { BuiltinCommandLoader } from './BuiltinCommandLoader.js';
import { Config } from '@google/gemini-cli-core';
import { CommandKind } from '../ui/commands/types.js';
import { ideCommand } from '../ui/commands/ideCommand.js';
import { restoreCommand } from '../ui/commands/restoreCommand.js';
vi.mock('../ui/commands/authCommand.js', () => ({ authCommand: {} }));
vi.mock('../ui/commands/bugCommand.js', () => ({ bugCommand: {} }));
vi.mock('../ui/commands/chatCommand.js', () => ({ chatCommand: {} }));
vi.mock('../ui/commands/clearCommand.js', () => ({ clearCommand: {} }));
vi.mock('../ui/commands/compressCommand.js', () => ({ compressCommand: {} }));
vi.mock('../ui/commands/corgiCommand.js', () => ({ corgiCommand: {} }));
vi.mock('../ui/commands/docsCommand.js', () => ({ docsCommand: {} }));
vi.mock('../ui/commands/editorCommand.js', () => ({ editorCommand: {} }));
vi.mock('../ui/commands/extensionsCommand.js', () => ({
extensionsCommand: {},
}));
vi.mock('../ui/commands/helpCommand.js', () => ({ helpCommand: {} }));
vi.mock('../ui/commands/mcpCommand.js', () => ({ mcpCommand: {} }));
vi.mock('../ui/commands/memoryCommand.js', () => ({ memoryCommand: {} }));
vi.mock('../ui/commands/privacyCommand.js', () => ({ privacyCommand: {} }));
vi.mock('../ui/commands/quitCommand.js', () => ({ quitCommand: {} }));
vi.mock('../ui/commands/statsCommand.js', () => ({ statsCommand: {} }));
vi.mock('../ui/commands/themeCommand.js', () => ({ themeCommand: {} }));
vi.mock('../ui/commands/toolsCommand.js', () => ({ toolsCommand: {} }));
describe('BuiltinCommandLoader', () => {
let mockConfig: Config;
const ideCommandMock = ideCommand as Mock;
const restoreCommandMock = restoreCommand as Mock;
beforeEach(() => {
vi.clearAllMocks();
mockConfig = { some: 'config' } as unknown as Config;
ideCommandMock.mockReturnValue({
name: 'ide',
description: 'IDE command',
kind: CommandKind.BUILT_IN,
});
restoreCommandMock.mockReturnValue({
name: 'restore',
description: 'Restore command',
kind: CommandKind.BUILT_IN,
});
});
it('should correctly pass the config object to command factory functions', async () => {
const loader = new BuiltinCommandLoader(mockConfig);
await loader.loadCommands();
expect(ideCommandMock).toHaveBeenCalledTimes(1);
expect(ideCommandMock).toHaveBeenCalledWith(mockConfig);
expect(restoreCommandMock).toHaveBeenCalledTimes(1);
expect(restoreCommandMock).toHaveBeenCalledWith(mockConfig);
});
it('should filter out null command definitions returned by factories', async () => {
// Override the mock's behavior for this specific test.
ideCommandMock.mockReturnValue(null);
const loader = new BuiltinCommandLoader(mockConfig);
const commands = await loader.loadCommands();
// The 'ide' command should be filtered out.
const ideCmd = commands.find((c) => c.name === 'ide');
expect(ideCmd).toBeUndefined();
// Other commands should still be present.
const aboutCmd = commands.find((c) => c.name === 'about');
expect(aboutCmd).toBeDefined();
});
it('should handle a null config gracefully when calling factories', async () => {
const loader = new BuiltinCommandLoader(null);
await loader.loadCommands();
expect(ideCommandMock).toHaveBeenCalledTimes(1);
expect(ideCommandMock).toHaveBeenCalledWith(null);
expect(restoreCommandMock).toHaveBeenCalledTimes(1);
expect(restoreCommandMock).toHaveBeenCalledWith(null);
});
it('should return a list of all loaded commands', async () => {
const loader = new BuiltinCommandLoader(mockConfig);
const commands = await loader.loadCommands();
const aboutCmd = commands.find((c) => c.name === 'about');
expect(aboutCmd).toBeDefined();
expect(aboutCmd?.kind).toBe(CommandKind.BUILT_IN);
const ideCmd = commands.find((c) => c.name === 'ide');
expect(ideCmd).toBeDefined();
});
});

View File

@ -0,0 +1,73 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { ICommandLoader } from './types.js';
import { SlashCommand } from '../ui/commands/types.js';
import { Config } from '@google/gemini-cli-core';
import { aboutCommand } from '../ui/commands/aboutCommand.js';
import { authCommand } from '../ui/commands/authCommand.js';
import { bugCommand } from '../ui/commands/bugCommand.js';
import { chatCommand } from '../ui/commands/chatCommand.js';
import { clearCommand } from '../ui/commands/clearCommand.js';
import { compressCommand } from '../ui/commands/compressCommand.js';
import { copyCommand } from '../ui/commands/copyCommand.js';
import { corgiCommand } from '../ui/commands/corgiCommand.js';
import { docsCommand } from '../ui/commands/docsCommand.js';
import { editorCommand } from '../ui/commands/editorCommand.js';
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
import { helpCommand } from '../ui/commands/helpCommand.js';
import { ideCommand } from '../ui/commands/ideCommand.js';
import { mcpCommand } from '../ui/commands/mcpCommand.js';
import { memoryCommand } from '../ui/commands/memoryCommand.js';
import { privacyCommand } from '../ui/commands/privacyCommand.js';
import { quitCommand } from '../ui/commands/quitCommand.js';
import { restoreCommand } from '../ui/commands/restoreCommand.js';
import { statsCommand } from '../ui/commands/statsCommand.js';
import { themeCommand } from '../ui/commands/themeCommand.js';
import { toolsCommand } from '../ui/commands/toolsCommand.js';
/**
* Loads the core, hard-coded slash commands that are an integral part
* of the Gemini CLI application.
*/
export class BuiltinCommandLoader implements ICommandLoader {
constructor(private config: Config | null) {}
/**
* Gathers all raw built-in command definitions, injects dependencies where
* needed (e.g., config) and filters out any that are not available.
*
* @param _signal An AbortSignal (unused for this synchronous loader).
* @returns A promise that resolves to an array of `SlashCommand` objects.
*/
async loadCommands(_signal: AbortSignal): Promise<SlashCommand[]> {
const allDefinitions: Array<SlashCommand | null> = [
aboutCommand,
authCommand,
bugCommand,
chatCommand,
clearCommand,
compressCommand,
copyCommand,
corgiCommand,
docsCommand,
editorCommand,
extensionsCommand,
helpCommand,
ideCommand(this.config),
mcpCommand,
memoryCommand,
privacyCommand,
quitCommand,
restoreCommand(this.config),
statsCommand,
themeCommand,
toolsCommand,
];
return allDefinitions.filter((cmd): cmd is SlashCommand => cmd !== null);
}
}

View File

@ -4,254 +4,177 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { vi, describe, it, expect, beforeEach, type Mocked } from 'vitest'; import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
import { CommandService } from './CommandService.js'; import { CommandService } from './CommandService.js';
import { type Config } from '@google/gemini-cli-core'; import { type ICommandLoader } from './types.js';
import { type SlashCommand } from '../ui/commands/types.js'; import { CommandKind, type SlashCommand } from '../ui/commands/types.js';
import { memoryCommand } from '../ui/commands/memoryCommand.js';
import { helpCommand } from '../ui/commands/helpCommand.js';
import { clearCommand } from '../ui/commands/clearCommand.js';
import { copyCommand } from '../ui/commands/copyCommand.js';
import { corgiCommand } from '../ui/commands/corgiCommand.js';
import { docsCommand } from '../ui/commands/docsCommand.js';
import { chatCommand } from '../ui/commands/chatCommand.js';
import { authCommand } from '../ui/commands/authCommand.js';
import { themeCommand } from '../ui/commands/themeCommand.js';
import { statsCommand } from '../ui/commands/statsCommand.js';
import { privacyCommand } from '../ui/commands/privacyCommand.js';
import { aboutCommand } from '../ui/commands/aboutCommand.js';
import { ideCommand } from '../ui/commands/ideCommand.js';
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
import { toolsCommand } from '../ui/commands/toolsCommand.js';
import { compressCommand } from '../ui/commands/compressCommand.js';
import { mcpCommand } from '../ui/commands/mcpCommand.js';
import { editorCommand } from '../ui/commands/editorCommand.js';
import { bugCommand } from '../ui/commands/bugCommand.js';
import { quitCommand } from '../ui/commands/quitCommand.js';
import { restoreCommand } from '../ui/commands/restoreCommand.js';
// Mock the command modules to isolate the service from the command implementations. const createMockCommand = (name: string, kind: CommandKind): SlashCommand => ({
vi.mock('../ui/commands/memoryCommand.js', () => ({ name,
memoryCommand: { name: 'memory', description: 'Mock Memory' }, description: `Description for ${name}`,
})); kind,
vi.mock('../ui/commands/helpCommand.js', () => ({ action: vi.fn(),
helpCommand: { name: 'help', description: 'Mock Help' }, });
}));
vi.mock('../ui/commands/clearCommand.js', () => ({ const mockCommandA = createMockCommand('command-a', CommandKind.BUILT_IN);
clearCommand: { name: 'clear', description: 'Mock Clear' }, const mockCommandB = createMockCommand('command-b', CommandKind.BUILT_IN);
})); const mockCommandC = createMockCommand('command-c', CommandKind.FILE);
vi.mock('../ui/commands/corgiCommand.js', () => ({ const mockCommandB_Override = createMockCommand('command-b', CommandKind.FILE);
corgiCommand: { name: 'corgi', description: 'Mock Corgi' },
})); class MockCommandLoader implements ICommandLoader {
vi.mock('../ui/commands/docsCommand.js', () => ({ private commandsToLoad: SlashCommand[];
docsCommand: { name: 'docs', description: 'Mock Docs' },
})); constructor(commandsToLoad: SlashCommand[]) {
vi.mock('../ui/commands/authCommand.js', () => ({ this.commandsToLoad = commandsToLoad;
authCommand: { name: 'auth', description: 'Mock Auth' }, }
}));
vi.mock('../ui/commands/themeCommand.js', () => ({ loadCommands = vi.fn(
themeCommand: { name: 'theme', description: 'Mock Theme' }, async (): Promise<SlashCommand[]> => Promise.resolve(this.commandsToLoad),
})); );
vi.mock('../ui/commands/copyCommand.js', () => ({ }
copyCommand: { name: 'copy', description: 'Mock Copy' },
}));
vi.mock('../ui/commands/privacyCommand.js', () => ({
privacyCommand: { name: 'privacy', description: 'Mock Privacy' },
}));
vi.mock('../ui/commands/statsCommand.js', () => ({
statsCommand: { name: 'stats', description: 'Mock Stats' },
}));
vi.mock('../ui/commands/aboutCommand.js', () => ({
aboutCommand: { name: 'about', description: 'Mock About' },
}));
vi.mock('../ui/commands/ideCommand.js', () => ({
ideCommand: vi.fn(),
}));
vi.mock('../ui/commands/extensionsCommand.js', () => ({
extensionsCommand: { name: 'extensions', description: 'Mock Extensions' },
}));
vi.mock('../ui/commands/toolsCommand.js', () => ({
toolsCommand: { name: 'tools', description: 'Mock Tools' },
}));
vi.mock('../ui/commands/compressCommand.js', () => ({
compressCommand: { name: 'compress', description: 'Mock Compress' },
}));
vi.mock('../ui/commands/mcpCommand.js', () => ({
mcpCommand: { name: 'mcp', description: 'Mock MCP' },
}));
vi.mock('../ui/commands/editorCommand.js', () => ({
editorCommand: { name: 'editor', description: 'Mock Editor' },
}));
vi.mock('../ui/commands/bugCommand.js', () => ({
bugCommand: { name: 'bug', description: 'Mock Bug' },
}));
vi.mock('../ui/commands/quitCommand.js', () => ({
quitCommand: { name: 'quit', description: 'Mock Quit' },
}));
vi.mock('../ui/commands/restoreCommand.js', () => ({
restoreCommand: vi.fn(),
}));
describe('CommandService', () => { describe('CommandService', () => {
const subCommandLen = 19;
let mockConfig: Mocked<Config>;
beforeEach(() => { beforeEach(() => {
mockConfig = { vi.spyOn(console, 'debug').mockImplementation(() => {});
getIdeMode: vi.fn(),
getCheckpointingEnabled: vi.fn(),
} as unknown as Mocked<Config>;
vi.mocked(ideCommand).mockReturnValue(null);
vi.mocked(restoreCommand).mockReturnValue(null);
}); });
describe('when using default production loader', () => { afterEach(() => {
let commandService: CommandService; vi.restoreAllMocks();
beforeEach(() => {
commandService = new CommandService(mockConfig);
}); });
it('should initialize with an empty command tree', () => { it('should load commands from a single loader', async () => {
const tree = commandService.getCommands(); const mockLoader = new MockCommandLoader([mockCommandA, mockCommandB]);
expect(tree).toBeInstanceOf(Array); const service = await CommandService.create(
expect(tree.length).toBe(0); [mockLoader],
new AbortController().signal,
);
const commands = service.getCommands();
expect(mockLoader.loadCommands).toHaveBeenCalledTimes(1);
expect(commands).toHaveLength(2);
expect(commands).toEqual(
expect.arrayContaining([mockCommandA, mockCommandB]),
);
}); });
describe('loadCommands', () => { it('should aggregate commands from multiple loaders', async () => {
it('should load the built-in commands into the command tree', async () => { const loader1 = new MockCommandLoader([mockCommandA]);
// Pre-condition check const loader2 = new MockCommandLoader([mockCommandC]);
expect(commandService.getCommands().length).toBe(0); const service = await CommandService.create(
[loader1, loader2],
new AbortController().signal,
);
// Action const commands = service.getCommands();
await commandService.loadCommands();
const tree = commandService.getCommands();
// Post-condition assertions expect(loader1.loadCommands).toHaveBeenCalledTimes(1);
expect(tree.length).toBe(subCommandLen); expect(loader2.loadCommands).toHaveBeenCalledTimes(1);
expect(commands).toHaveLength(2);
const commandNames = tree.map((cmd) => cmd.name); expect(commands).toEqual(
expect(commandNames).toContain('auth'); expect.arrayContaining([mockCommandA, mockCommandC]),
expect(commandNames).toContain('bug'); );
expect(commandNames).toContain('memory');
expect(commandNames).toContain('help');
expect(commandNames).toContain('clear');
expect(commandNames).toContain('copy');
expect(commandNames).toContain('compress');
expect(commandNames).toContain('corgi');
expect(commandNames).toContain('docs');
expect(commandNames).toContain('chat');
expect(commandNames).toContain('theme');
expect(commandNames).toContain('stats');
expect(commandNames).toContain('privacy');
expect(commandNames).toContain('about');
expect(commandNames).toContain('extensions');
expect(commandNames).toContain('tools');
expect(commandNames).toContain('mcp');
expect(commandNames).not.toContain('ide');
}); });
it('should include ide command when ideMode is on', async () => { it('should override commands from earlier loaders with those from later loaders', async () => {
mockConfig.getIdeMode.mockReturnValue(true); const loader1 = new MockCommandLoader([mockCommandA, mockCommandB]);
vi.mocked(ideCommand).mockReturnValue({ const loader2 = new MockCommandLoader([
name: 'ide', mockCommandB_Override,
description: 'Mock IDE', mockCommandC,
});
await commandService.loadCommands();
const tree = commandService.getCommands();
expect(tree.length).toBe(subCommandLen + 1);
const commandNames = tree.map((cmd) => cmd.name);
expect(commandNames).toContain('ide');
expect(commandNames).toContain('editor');
expect(commandNames).toContain('quit');
});
it('should include restore command when checkpointing is on', async () => {
mockConfig.getCheckpointingEnabled.mockReturnValue(true);
vi.mocked(restoreCommand).mockReturnValue({
name: 'restore',
description: 'Mock Restore',
});
await commandService.loadCommands();
const tree = commandService.getCommands();
expect(tree.length).toBe(subCommandLen + 1);
const commandNames = tree.map((cmd) => cmd.name);
expect(commandNames).toContain('restore');
});
it('should overwrite any existing commands when called again', async () => {
// Load once
await commandService.loadCommands();
expect(commandService.getCommands().length).toBe(subCommandLen);
// Load again
await commandService.loadCommands();
const tree = commandService.getCommands();
// Should not append, but overwrite
expect(tree.length).toBe(subCommandLen);
});
});
describe('getCommandTree', () => {
it('should return the current command tree', async () => {
const initialTree = commandService.getCommands();
expect(initialTree).toEqual([]);
await commandService.loadCommands();
const loadedTree = commandService.getCommands();
expect(loadedTree.length).toBe(subCommandLen);
expect(loadedTree).toEqual([
aboutCommand,
authCommand,
bugCommand,
chatCommand,
clearCommand,
copyCommand,
compressCommand,
corgiCommand,
docsCommand,
editorCommand,
extensionsCommand,
helpCommand,
mcpCommand,
memoryCommand,
privacyCommand,
quitCommand,
statsCommand,
themeCommand,
toolsCommand,
]); ]);
}); const service = await CommandService.create(
}); [loader1, loader2],
new AbortController().signal,
);
const commands = service.getCommands();
expect(commands).toHaveLength(3); // Should be A, C, and the overridden B.
// The final list should contain the override from the *last* loader.
const commandB = commands.find((cmd) => cmd.name === 'command-b');
expect(commandB).toBeDefined();
expect(commandB?.kind).toBe(CommandKind.FILE); // Verify it's the overridden version.
expect(commandB).toEqual(mockCommandB_Override);
// Ensure the other commands are still present.
expect(commands).toEqual(
expect.arrayContaining([
mockCommandA,
mockCommandC,
mockCommandB_Override,
]),
);
}); });
describe('when initialized with an injected loader function', () => { it('should handle loaders that return an empty array of commands gracefully', async () => {
it('should use the provided loader instead of the built-in one', async () => { const loader1 = new MockCommandLoader([mockCommandA]);
// Arrange: Create a set of mock commands. const emptyLoader = new MockCommandLoader([]);
const mockCommands: SlashCommand[] = [ const loader3 = new MockCommandLoader([mockCommandB]);
{ name: 'injected-test-1', description: 'injected 1' }, const service = await CommandService.create(
{ name: 'injected-test-2', description: 'injected 2' }, [loader1, emptyLoader, loader3],
]; new AbortController().signal,
);
// Arrange: Create a mock loader FUNCTION that resolves with our mock commands. const commands = service.getCommands();
const mockLoader = vi.fn().mockResolvedValue(mockCommands);
// Act: Instantiate the service WITH the injected loader function. expect(emptyLoader.loadCommands).toHaveBeenCalledTimes(1);
const commandService = new CommandService(mockConfig, mockLoader); expect(commands).toHaveLength(2);
await commandService.loadCommands(); expect(commands).toEqual(
const tree = commandService.getCommands(); expect.arrayContaining([mockCommandA, mockCommandB]),
);
});
// Assert: The tree should contain ONLY our injected commands. it('should load commands from successful loaders even if one fails', async () => {
expect(mockLoader).toHaveBeenCalled(); // Verify our mock loader was actually called. const successfulLoader = new MockCommandLoader([mockCommandA]);
expect(tree.length).toBe(2); const failingLoader = new MockCommandLoader([]);
expect(tree).toEqual(mockCommands); const error = new Error('Loader failed');
vi.spyOn(failingLoader, 'loadCommands').mockRejectedValue(error);
const commandNames = tree.map((cmd) => cmd.name); const service = await CommandService.create(
expect(commandNames).not.toContain('memory'); // Verify it didn't load production commands. [successfulLoader, failingLoader],
}); new AbortController().signal,
);
const commands = service.getCommands();
expect(commands).toHaveLength(1);
expect(commands).toEqual([mockCommandA]);
expect(console.debug).toHaveBeenCalledWith(
'A command loader failed:',
error,
);
});
it('getCommands should return a readonly array that cannot be mutated', async () => {
const service = await CommandService.create(
[new MockCommandLoader([mockCommandA])],
new AbortController().signal,
);
const commands = service.getCommands();
// Expect it to throw a TypeError at runtime because the array is frozen.
expect(() => {
// @ts-expect-error - Testing immutability is intentional here.
commands.push(mockCommandB);
}).toThrow();
// Verify the original array was not mutated.
expect(service.getCommands()).toHaveLength(1);
});
it('should pass the abort signal to all loaders', async () => {
const controller = new AbortController();
const signal = controller.signal;
const loader1 = new MockCommandLoader([mockCommandA]);
const loader2 = new MockCommandLoader([mockCommandB]);
await CommandService.create([loader1, loader2], signal);
expect(loader1.loadCommands).toHaveBeenCalledTimes(1);
expect(loader1.loadCommands).toHaveBeenCalledWith(signal);
expect(loader2.loadCommands).toHaveBeenCalledTimes(1);
expect(loader2.loadCommands).toHaveBeenCalledWith(signal);
}); });
}); });

View File

@ -4,81 +4,80 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { Config } from '@google/gemini-cli-core';
import { SlashCommand } from '../ui/commands/types.js'; import { SlashCommand } from '../ui/commands/types.js';
import { memoryCommand } from '../ui/commands/memoryCommand.js'; import { ICommandLoader } from './types.js';
import { helpCommand } from '../ui/commands/helpCommand.js';
import { clearCommand } from '../ui/commands/clearCommand.js';
import { copyCommand } from '../ui/commands/copyCommand.js';
import { corgiCommand } from '../ui/commands/corgiCommand.js';
import { docsCommand } from '../ui/commands/docsCommand.js';
import { mcpCommand } from '../ui/commands/mcpCommand.js';
import { authCommand } from '../ui/commands/authCommand.js';
import { themeCommand } from '../ui/commands/themeCommand.js';
import { editorCommand } from '../ui/commands/editorCommand.js';
import { chatCommand } from '../ui/commands/chatCommand.js';
import { statsCommand } from '../ui/commands/statsCommand.js';
import { privacyCommand } from '../ui/commands/privacyCommand.js';
import { aboutCommand } from '../ui/commands/aboutCommand.js';
import { extensionsCommand } from '../ui/commands/extensionsCommand.js';
import { toolsCommand } from '../ui/commands/toolsCommand.js';
import { compressCommand } from '../ui/commands/compressCommand.js';
import { ideCommand } from '../ui/commands/ideCommand.js';
import { bugCommand } from '../ui/commands/bugCommand.js';
import { quitCommand } from '../ui/commands/quitCommand.js';
import { restoreCommand } from '../ui/commands/restoreCommand.js';
const loadBuiltInCommands = async (
config: Config | null,
): Promise<SlashCommand[]> => {
const allCommands = [
aboutCommand,
authCommand,
bugCommand,
chatCommand,
clearCommand,
copyCommand,
compressCommand,
corgiCommand,
docsCommand,
editorCommand,
extensionsCommand,
helpCommand,
ideCommand(config),
mcpCommand,
memoryCommand,
privacyCommand,
quitCommand,
restoreCommand(config),
statsCommand,
themeCommand,
toolsCommand,
];
return allCommands.filter(
(command): command is SlashCommand => command !== null,
);
};
/**
* Orchestrates the discovery and loading of all slash commands for the CLI.
*
* This service operates on a provider-based loader pattern. It is initialized
* with an array of `ICommandLoader` instances, each responsible for fetching
* commands from a specific source (e.g., built-in code, local files).
*
* The CommandService is responsible for invoking these loaders, aggregating their
* results, and resolving any name conflicts. This architecture allows the command
* system to be extended with new sources without modifying the service itself.
*/
export class CommandService { export class CommandService {
private commands: SlashCommand[] = []; /**
* Private constructor to enforce the use of the async factory.
* @param commands A readonly array of the fully loaded and de-duplicated commands.
*/
private constructor(private readonly commands: readonly SlashCommand[]) {}
constructor( /**
private config: Config | null, * Asynchronously creates and initializes a new CommandService instance.
private commandLoader: ( *
config: Config | null, * This factory method orchestrates the entire command loading process. It
) => Promise<SlashCommand[]> = loadBuiltInCommands, * runs all provided loaders in parallel, aggregates their results, handles
) { * name conflicts by letting the last-loaded command win, and then returns a
// The constructor can be used for dependency injection in the future. * fully constructed `CommandService` instance.
*
* @param loaders An array of objects that conform to the `ICommandLoader`
* interface. The order of loaders is significant: if multiple loaders
* provide a command with the same name, the command from the loader that
* appears later in the array will take precedence.
* @param signal An AbortSignal to cancel the loading process.
* @returns A promise that resolves to a new, fully initialized `CommandService` instance.
*/
static async create(
loaders: ICommandLoader[],
signal: AbortSignal,
): Promise<CommandService> {
const results = await Promise.allSettled(
loaders.map((loader) => loader.loadCommands(signal)),
);
const allCommands: SlashCommand[] = [];
for (const result of results) {
if (result.status === 'fulfilled') {
allCommands.push(...result.value);
} else {
console.debug('A command loader failed:', result.reason);
}
} }
async loadCommands(): Promise<void> { // De-duplicate commands using a Map. The last one found with a given name wins.
// For now, we only load the built-in commands. // This creates a natural override system based on the order of the loaders
// File-based and remote commands will be added later. // passed to the constructor.
this.commands = await this.commandLoader(this.config); const commandMap = new Map<string, SlashCommand>();
for (const cmd of allCommands) {
commandMap.set(cmd.name, cmd);
} }
getCommands(): SlashCommand[] { const finalCommands = Object.freeze(Array.from(commandMap.values()));
return new CommandService(finalCommands);
}
/**
* Retrieves the currently loaded and de-duplicated list of slash commands.
*
* This method is a safe accessor for the service's state. It returns a
* readonly array, preventing consumers from modifying the service's internal state.
*
* @returns A readonly, unified array of available `SlashCommand` objects.
*/
getCommands(): readonly SlashCommand[] {
return this.commands; return this.commands;
} }
} }

View File

@ -0,0 +1,24 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { SlashCommand } from '../ui/commands/types.js';
/**
* Defines the contract for any class that can load and provide slash commands.
* This allows the CommandService to be extended with new command sources
* (e.g., file-based, remote APIs) without modification.
*
* Loaders should receive any necessary dependencies (like Config) via their
* constructor.
*/
export interface ICommandLoader {
/**
* Discovers and returns a list of slash commands from the loader's source.
* @param signal An AbortSignal to allow cancellation.
* @returns A promise that resolves to an array of SlashCommand objects.
*/
loadCommands(signal: AbortSignal): Promise<SlashCommand[]>;
}

View File

@ -5,13 +5,14 @@
*/ */
import { getCliVersion } from '../../utils/version.js'; import { getCliVersion } from '../../utils/version.js';
import { SlashCommand } from './types.js'; import { CommandKind, SlashCommand } from './types.js';
import process from 'node:process'; import process from 'node:process';
import { MessageType, type HistoryItemAbout } from '../types.js'; import { MessageType, type HistoryItemAbout } from '../types.js';
export const aboutCommand: SlashCommand = { export const aboutCommand: SlashCommand = {
name: 'about', name: 'about',
description: 'show version info', description: 'show version info',
kind: CommandKind.BUILT_IN,
action: async (context) => { action: async (context) => {
const osVersion = process.platform; const osVersion = process.platform;
let sandboxEnv = 'no sandbox'; let sandboxEnv = 'no sandbox';

View File

@ -4,11 +4,12 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { OpenDialogActionReturn, SlashCommand } from './types.js'; import { CommandKind, OpenDialogActionReturn, SlashCommand } from './types.js';
export const authCommand: SlashCommand = { export const authCommand: SlashCommand = {
name: 'auth', name: 'auth',
description: 'change the auth method', description: 'change the auth method',
kind: CommandKind.BUILT_IN,
action: (_context, _args): OpenDialogActionReturn => ({ action: (_context, _args): OpenDialogActionReturn => ({
type: 'dialog', type: 'dialog',
dialog: 'auth', dialog: 'auth',

View File

@ -6,7 +6,11 @@
import open from 'open'; import open from 'open';
import process from 'node:process'; import process from 'node:process';
import { type CommandContext, type SlashCommand } from './types.js'; import {
type CommandContext,
type SlashCommand,
CommandKind,
} from './types.js';
import { MessageType } from '../types.js'; import { MessageType } from '../types.js';
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js'; import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
import { formatMemoryUsage } from '../utils/formatters.js'; import { formatMemoryUsage } from '../utils/formatters.js';
@ -15,6 +19,7 @@ import { getCliVersion } from '../../utils/version.js';
export const bugCommand: SlashCommand = { export const bugCommand: SlashCommand = {
name: 'bug', name: 'bug',
description: 'submit a bug report', description: 'submit a bug report',
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext, args?: string): Promise<void> => { action: async (context: CommandContext, args?: string): Promise<void> => {
const bugDescription = (args || '').trim(); const bugDescription = (args || '').trim();
const { config } = context.services; const { config } = context.services;

View File

@ -5,7 +5,12 @@
*/ */
import * as fsPromises from 'fs/promises'; import * as fsPromises from 'fs/promises';
import { CommandContext, SlashCommand, MessageActionReturn } from './types.js'; import {
CommandContext,
SlashCommand,
MessageActionReturn,
CommandKind,
} from './types.js';
import path from 'path'; import path from 'path';
import { HistoryItemWithoutId, MessageType } from '../types.js'; import { HistoryItemWithoutId, MessageType } from '../types.js';
@ -54,6 +59,7 @@ const getSavedChatTags = async (
const listCommand: SlashCommand = { const listCommand: SlashCommand = {
name: 'list', name: 'list',
description: 'List saved conversation checkpoints', description: 'List saved conversation checkpoints',
kind: CommandKind.BUILT_IN,
action: async (context): Promise<MessageActionReturn> => { action: async (context): Promise<MessageActionReturn> => {
const chatDetails = await getSavedChatTags(context, false); const chatDetails = await getSavedChatTags(context, false);
if (chatDetails.length === 0) { if (chatDetails.length === 0) {
@ -81,6 +87,7 @@ const saveCommand: SlashCommand = {
name: 'save', name: 'save',
description: description:
'Save the current conversation as a checkpoint. Usage: /chat save <tag>', 'Save the current conversation as a checkpoint. Usage: /chat save <tag>',
kind: CommandKind.BUILT_IN,
action: async (context, args): Promise<MessageActionReturn> => { action: async (context, args): Promise<MessageActionReturn> => {
const tag = args.trim(); const tag = args.trim();
if (!tag) { if (!tag) {
@ -122,9 +129,10 @@ const saveCommand: SlashCommand = {
const resumeCommand: SlashCommand = { const resumeCommand: SlashCommand = {
name: 'resume', name: 'resume',
altName: 'load', altNames: ['load'],
description: description:
'Resume a conversation from a checkpoint. Usage: /chat resume <tag>', 'Resume a conversation from a checkpoint. Usage: /chat resume <tag>',
kind: CommandKind.BUILT_IN,
action: async (context, args) => { action: async (context, args) => {
const tag = args.trim(); const tag = args.trim();
if (!tag) { if (!tag) {
@ -193,5 +201,6 @@ const resumeCommand: SlashCommand = {
export const chatCommand: SlashCommand = { export const chatCommand: SlashCommand = {
name: 'chat', name: 'chat',
description: 'Manage conversation history.', description: 'Manage conversation history.',
kind: CommandKind.BUILT_IN,
subCommands: [listCommand, saveCommand, resumeCommand], subCommands: [listCommand, saveCommand, resumeCommand],
}; };

View File

@ -5,11 +5,12 @@
*/ */
import { uiTelemetryService } from '@google/gemini-cli-core'; import { uiTelemetryService } from '@google/gemini-cli-core';
import { SlashCommand } from './types.js'; import { CommandKind, SlashCommand } from './types.js';
export const clearCommand: SlashCommand = { export const clearCommand: SlashCommand = {
name: 'clear', name: 'clear',
description: 'clear the screen and conversation history', description: 'clear the screen and conversation history',
kind: CommandKind.BUILT_IN,
action: async (context, _args) => { action: async (context, _args) => {
const geminiClient = context.services.config?.getGeminiClient(); const geminiClient = context.services.config?.getGeminiClient();

View File

@ -5,12 +5,13 @@
*/ */
import { HistoryItemCompression, MessageType } from '../types.js'; import { HistoryItemCompression, MessageType } from '../types.js';
import { SlashCommand } from './types.js'; import { CommandKind, SlashCommand } from './types.js';
export const compressCommand: SlashCommand = { export const compressCommand: SlashCommand = {
name: 'compress', name: 'compress',
altName: 'summarize', altNames: ['summarize'],
description: 'Compresses the context by replacing it with a summary.', description: 'Compresses the context by replacing it with a summary.',
kind: CommandKind.BUILT_IN,
action: async (context) => { action: async (context) => {
const { ui } = context; const { ui } = context;
if (ui.pendingItem) { if (ui.pendingItem) {

View File

@ -5,11 +5,16 @@
*/ */
import { copyToClipboard } from '../utils/commandUtils.js'; import { copyToClipboard } from '../utils/commandUtils.js';
import { SlashCommand, SlashCommandActionReturn } from './types.js'; import {
CommandKind,
SlashCommand,
SlashCommandActionReturn,
} from './types.js';
export const copyCommand: SlashCommand = { export const copyCommand: SlashCommand = {
name: 'copy', name: 'copy',
description: 'Copy the last result or code snippet to clipboard', description: 'Copy the last result or code snippet to clipboard',
kind: CommandKind.BUILT_IN,
action: async (context, _args): Promise<SlashCommandActionReturn | void> => { action: async (context, _args): Promise<SlashCommandActionReturn | void> => {
const chat = await context.services.config?.getGeminiClient()?.getChat(); const chat = await context.services.config?.getGeminiClient()?.getChat();
const history = chat?.getHistory(); const history = chat?.getHistory();

View File

@ -4,11 +4,12 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { type SlashCommand } from './types.js'; import { CommandKind, type SlashCommand } from './types.js';
export const corgiCommand: SlashCommand = { export const corgiCommand: SlashCommand = {
name: 'corgi', name: 'corgi',
description: 'Toggles corgi mode.', description: 'Toggles corgi mode.',
kind: CommandKind.BUILT_IN,
action: (context, _args) => { action: (context, _args) => {
context.ui.toggleCorgiMode(); context.ui.toggleCorgiMode();
}, },

View File

@ -6,12 +6,17 @@
import open from 'open'; import open from 'open';
import process from 'node:process'; import process from 'node:process';
import { type CommandContext, type SlashCommand } from './types.js'; import {
type CommandContext,
type SlashCommand,
CommandKind,
} from './types.js';
import { MessageType } from '../types.js'; import { MessageType } from '../types.js';
export const docsCommand: SlashCommand = { export const docsCommand: SlashCommand = {
name: 'docs', name: 'docs',
description: 'open full Gemini CLI documentation in your browser', description: 'open full Gemini CLI documentation in your browser',
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext): Promise<void> => { action: async (context: CommandContext): Promise<void> => {
const docsUrl = 'https://goo.gle/gemini-cli-docs'; const docsUrl = 'https://goo.gle/gemini-cli-docs';

View File

@ -4,11 +4,16 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { type OpenDialogActionReturn, type SlashCommand } from './types.js'; import {
CommandKind,
type OpenDialogActionReturn,
type SlashCommand,
} from './types.js';
export const editorCommand: SlashCommand = { export const editorCommand: SlashCommand = {
name: 'editor', name: 'editor',
description: 'set external editor preference', description: 'set external editor preference',
kind: CommandKind.BUILT_IN,
action: (): OpenDialogActionReturn => ({ action: (): OpenDialogActionReturn => ({
type: 'dialog', type: 'dialog',
dialog: 'editor', dialog: 'editor',

View File

@ -4,12 +4,17 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { type CommandContext, type SlashCommand } from './types.js'; import {
type CommandContext,
type SlashCommand,
CommandKind,
} from './types.js';
import { MessageType } from '../types.js'; import { MessageType } from '../types.js';
export const extensionsCommand: SlashCommand = { export const extensionsCommand: SlashCommand = {
name: 'extensions', name: 'extensions',
description: 'list active extensions', description: 'list active extensions',
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext): Promise<void> => { action: async (context: CommandContext): Promise<void> => {
const activeExtensions = context.services.config const activeExtensions = context.services.config
?.getExtensions() ?.getExtensions()

View File

@ -32,9 +32,9 @@ describe('helpCommand', () => {
}); });
it("should also be triggered by its alternative name '?'", () => { it("should also be triggered by its alternative name '?'", () => {
// This test is more conceptual. The routing of altName to the command // This test is more conceptual. The routing of altNames to the command
// is handled by the slash command processor, but we can assert the // is handled by the slash command processor, but we can assert the
// altName is correctly defined on the command object itself. // altNames is correctly defined on the command object itself.
expect(helpCommand.altName).toBe('?'); expect(helpCommand.altNames).toContain('?');
}); });
}); });

View File

@ -4,12 +4,13 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { OpenDialogActionReturn, SlashCommand } from './types.js'; import { CommandKind, OpenDialogActionReturn, SlashCommand } from './types.js';
export const helpCommand: SlashCommand = { export const helpCommand: SlashCommand = {
name: 'help', name: 'help',
altName: '?', altNames: ['?'],
description: 'for help on gemini-cli', description: 'for help on gemini-cli',
kind: CommandKind.BUILT_IN,
action: (_context, _args): OpenDialogActionReturn => { action: (_context, _args): OpenDialogActionReturn => {
console.debug('Opening help UI ...'); console.debug('Opening help UI ...');
return { return {

View File

@ -17,6 +17,7 @@ import {
CommandContext, CommandContext,
SlashCommand, SlashCommand,
SlashCommandActionReturn, SlashCommandActionReturn,
CommandKind,
} from './types.js'; } from './types.js';
import * as child_process from 'child_process'; import * as child_process from 'child_process';
import * as process from 'process'; import * as process from 'process';
@ -48,10 +49,12 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
return { return {
name: 'ide', name: 'ide',
description: 'manage IDE integration', description: 'manage IDE integration',
kind: CommandKind.BUILT_IN,
subCommands: [ subCommands: [
{ {
name: 'status', name: 'status',
description: 'check status of IDE integration', description: 'check status of IDE integration',
kind: CommandKind.BUILT_IN,
action: (_context: CommandContext): SlashCommandActionReturn => { action: (_context: CommandContext): SlashCommandActionReturn => {
const status = getMCPServerStatus(IDE_SERVER_NAME); const status = getMCPServerStatus(IDE_SERVER_NAME);
const discoveryState = getMCPDiscoveryState(); const discoveryState = getMCPDiscoveryState();
@ -89,6 +92,7 @@ export const ideCommand = (config: Config | null): SlashCommand | null => {
{ {
name: 'install', name: 'install',
description: 'install required VS Code companion extension', description: 'install required VS Code companion extension',
kind: CommandKind.BUILT_IN,
action: async (context) => { action: async (context) => {
if (!isVSCodeInstalled()) { if (!isVSCodeInstalled()) {
context.ui.addItem( context.ui.addItem(

View File

@ -8,6 +8,7 @@ import {
SlashCommand, SlashCommand,
SlashCommandActionReturn, SlashCommandActionReturn,
CommandContext, CommandContext,
CommandKind,
} from './types.js'; } from './types.js';
import { import {
DiscoveredMCPTool, DiscoveredMCPTool,
@ -229,6 +230,7 @@ const getMcpStatus = async (
export const mcpCommand: SlashCommand = { export const mcpCommand: SlashCommand = {
name: 'mcp', name: 'mcp',
description: 'list configured MCP servers and tools', description: 'list configured MCP servers and tools',
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext, args: string) => { action: async (context: CommandContext, args: string) => {
const lowerCaseArgs = args.toLowerCase().split(/\s+/).filter(Boolean); const lowerCaseArgs = args.toLowerCase().split(/\s+/).filter(Boolean);

View File

@ -6,15 +6,21 @@
import { getErrorMessage } from '@google/gemini-cli-core'; import { getErrorMessage } from '@google/gemini-cli-core';
import { MessageType } from '../types.js'; import { MessageType } from '../types.js';
import { SlashCommand, SlashCommandActionReturn } from './types.js'; import {
CommandKind,
SlashCommand,
SlashCommandActionReturn,
} from './types.js';
export const memoryCommand: SlashCommand = { export const memoryCommand: SlashCommand = {
name: 'memory', name: 'memory',
description: 'Commands for interacting with memory.', description: 'Commands for interacting with memory.',
kind: CommandKind.BUILT_IN,
subCommands: [ subCommands: [
{ {
name: 'show', name: 'show',
description: 'Show the current memory contents.', description: 'Show the current memory contents.',
kind: CommandKind.BUILT_IN,
action: async (context) => { action: async (context) => {
const memoryContent = context.services.config?.getUserMemory() || ''; const memoryContent = context.services.config?.getUserMemory() || '';
const fileCount = context.services.config?.getGeminiMdFileCount() || 0; const fileCount = context.services.config?.getGeminiMdFileCount() || 0;
@ -36,6 +42,7 @@ export const memoryCommand: SlashCommand = {
{ {
name: 'add', name: 'add',
description: 'Add content to the memory.', description: 'Add content to the memory.',
kind: CommandKind.BUILT_IN,
action: (context, args): SlashCommandActionReturn | void => { action: (context, args): SlashCommandActionReturn | void => {
if (!args || args.trim() === '') { if (!args || args.trim() === '') {
return { return {
@ -63,6 +70,7 @@ export const memoryCommand: SlashCommand = {
{ {
name: 'refresh', name: 'refresh',
description: 'Refresh the memory from the source.', description: 'Refresh the memory from the source.',
kind: CommandKind.BUILT_IN,
action: async (context) => { action: async (context) => {
context.ui.addItem( context.ui.addItem(
{ {

View File

@ -4,11 +4,12 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { OpenDialogActionReturn, SlashCommand } from './types.js'; import { CommandKind, OpenDialogActionReturn, SlashCommand } from './types.js';
export const privacyCommand: SlashCommand = { export const privacyCommand: SlashCommand = {
name: 'privacy', name: 'privacy',
description: 'display the privacy notice', description: 'display the privacy notice',
kind: CommandKind.BUILT_IN,
action: (): OpenDialogActionReturn => ({ action: (): OpenDialogActionReturn => ({
type: 'dialog', type: 'dialog',
dialog: 'privacy', dialog: 'privacy',

View File

@ -5,12 +5,13 @@
*/ */
import { formatDuration } from '../utils/formatters.js'; import { formatDuration } from '../utils/formatters.js';
import { type SlashCommand } from './types.js'; import { CommandKind, type SlashCommand } from './types.js';
export const quitCommand: SlashCommand = { export const quitCommand: SlashCommand = {
name: 'quit', name: 'quit',
altName: 'exit', altNames: ['exit'],
description: 'exit the cli', description: 'exit the cli',
kind: CommandKind.BUILT_IN,
action: (context) => { action: (context) => {
const now = Date.now(); const now = Date.now();
const { sessionStartTime } = context.session.stats; const { sessionStartTime } = context.session.stats;

View File

@ -10,6 +10,7 @@ import {
type CommandContext, type CommandContext,
type SlashCommand, type SlashCommand,
type SlashCommandActionReturn, type SlashCommandActionReturn,
CommandKind,
} from './types.js'; } from './types.js';
import { Config } from '@google/gemini-cli-core'; import { Config } from '@google/gemini-cli-core';
@ -149,6 +150,7 @@ export const restoreCommand = (config: Config | null): SlashCommand | null => {
name: 'restore', name: 'restore',
description: description:
'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested', 'Restore a tool call. This will reset the conversation and file history to the state it was in when the tool call was suggested',
kind: CommandKind.BUILT_IN,
action: restoreAction, action: restoreAction,
completion, completion,
}; };

View File

@ -6,12 +6,17 @@
import { MessageType, HistoryItemStats } from '../types.js'; import { MessageType, HistoryItemStats } from '../types.js';
import { formatDuration } from '../utils/formatters.js'; import { formatDuration } from '../utils/formatters.js';
import { type CommandContext, type SlashCommand } from './types.js'; import {
type CommandContext,
type SlashCommand,
CommandKind,
} from './types.js';
export const statsCommand: SlashCommand = { export const statsCommand: SlashCommand = {
name: 'stats', name: 'stats',
altName: 'usage', altNames: ['usage'],
description: 'check session stats. Usage: /stats [model|tools]', description: 'check session stats. Usage: /stats [model|tools]',
kind: CommandKind.BUILT_IN,
action: (context: CommandContext) => { action: (context: CommandContext) => {
const now = new Date(); const now = new Date();
const { sessionStartTime } = context.session.stats; const { sessionStartTime } = context.session.stats;
@ -38,6 +43,7 @@ export const statsCommand: SlashCommand = {
{ {
name: 'model', name: 'model',
description: 'Show model-specific usage statistics.', description: 'Show model-specific usage statistics.',
kind: CommandKind.BUILT_IN,
action: (context: CommandContext) => { action: (context: CommandContext) => {
context.ui.addItem( context.ui.addItem(
{ {
@ -50,6 +56,7 @@ export const statsCommand: SlashCommand = {
{ {
name: 'tools', name: 'tools',
description: 'Show tool-specific usage statistics.', description: 'Show tool-specific usage statistics.',
kind: CommandKind.BUILT_IN,
action: (context: CommandContext) => { action: (context: CommandContext) => {
context.ui.addItem( context.ui.addItem(
{ {

View File

@ -4,11 +4,12 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { OpenDialogActionReturn, SlashCommand } from './types.js'; import { CommandKind, OpenDialogActionReturn, SlashCommand } from './types.js';
export const themeCommand: SlashCommand = { export const themeCommand: SlashCommand = {
name: 'theme', name: 'theme',
description: 'change the theme', description: 'change the theme',
kind: CommandKind.BUILT_IN,
action: (_context, _args): OpenDialogActionReturn => ({ action: (_context, _args): OpenDialogActionReturn => ({
type: 'dialog', type: 'dialog',
dialog: 'theme', dialog: 'theme',

View File

@ -4,12 +4,17 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { type CommandContext, type SlashCommand } from './types.js'; import {
type CommandContext,
type SlashCommand,
CommandKind,
} from './types.js';
import { MessageType } from '../types.js'; import { MessageType } from '../types.js';
export const toolsCommand: SlashCommand = { export const toolsCommand: SlashCommand = {
name: 'tools', name: 'tools',
description: 'list available Gemini CLI tools', description: 'list available Gemini CLI tools',
kind: CommandKind.BUILT_IN,
action: async (context: CommandContext, args?: string): Promise<void> => { action: async (context: CommandContext, args?: string): Promise<void> => {
const subCommand = args?.trim(); const subCommand = args?.trim();

View File

@ -106,11 +106,18 @@ export type SlashCommandActionReturn =
| OpenDialogActionReturn | OpenDialogActionReturn
| LoadHistoryActionReturn; | LoadHistoryActionReturn;
export enum CommandKind {
BUILT_IN = 'built-in',
FILE = 'file',
}
// The standardized contract for any command in the system. // The standardized contract for any command in the system.
export interface SlashCommand { export interface SlashCommand {
name: string; name: string;
altName?: string; altNames?: string[];
description?: string; description: string;
kind: CommandKind;
// The action to run. Optional for parent commands that only group sub-commands. // The action to run. Optional for parent commands that only group sub-commands.
action?: ( action?: (

View File

@ -10,7 +10,7 @@ import { Colors } from '../colors.js';
import { SlashCommand } from '../commands/types.js'; import { SlashCommand } from '../commands/types.js';
interface Help { interface Help {
commands: SlashCommand[]; commands: readonly SlashCommand[];
} }
export const Help: React.FC<Help> = ({ commands }) => ( export const Help: React.FC<Help> = ({ commands }) => (

View File

@ -451,13 +451,13 @@ describe('InputPrompt', () => {
unmount(); unmount();
}); });
it('should complete a command based on its altName', async () => { it('should complete a command based on its altNames', async () => {
// Add a command with an altName to our mock for this test // Add a command with an altNames to our mock for this test
props.slashCommands.push({ props.slashCommands.push({
name: 'help', name: 'help',
altName: '?', altNames: ['?'],
description: '...', description: '...',
}); } as SlashCommand);
mockedUseCompletion.mockReturnValue({ mockedUseCompletion.mockReturnValue({
...mockCompletion, ...mockCompletion,

View File

@ -32,7 +32,7 @@ export interface InputPromptProps {
userMessages: readonly string[]; userMessages: readonly string[];
onClearScreen: () => void; onClearScreen: () => void;
config: Config; config: Config;
slashCommands: SlashCommand[]; slashCommands: readonly SlashCommand[];
commandContext: CommandContext; commandContext: CommandContext;
placeholder?: string; placeholder?: string;
focus?: boolean; focus?: boolean;
@ -180,18 +180,20 @@ export const InputPrompt: React.FC<InputPromptProps> = ({
// If there's no trailing space, we need to check if the current query // If there's no trailing space, we need to check if the current query
// is already a complete path to a parent command. // is already a complete path to a parent command.
if (!hasTrailingSpace) { if (!hasTrailingSpace) {
let currentLevel: SlashCommand[] | undefined = slashCommands; let currentLevel: readonly SlashCommand[] | undefined = slashCommands;
for (let i = 0; i < parts.length; i++) { for (let i = 0; i < parts.length; i++) {
const part = parts[i]; const part = parts[i];
const found: SlashCommand | undefined = currentLevel?.find( const found: SlashCommand | undefined = currentLevel?.find(
(cmd) => cmd.name === part || cmd.altName === part, (cmd) => cmd.name === part || cmd.altNames?.includes(part),
); );
if (found) { if (found) {
if (i === parts.length - 1 && found.subCommands) { if (i === parts.length - 1 && found.subCommands) {
isParentPath = true; isParentPath = true;
} }
currentLevel = found.subCommands; currentLevel = found.subCommands as
| readonly SlashCommand[]
| undefined;
} else { } else {
// Path is invalid, so it can't be a parent path. // Path is invalid, so it can't be a parent path.
currentLevel = undefined; currentLevel = undefined;

View File

@ -11,413 +11,169 @@ const { mockProcessExit } = vi.hoisted(() => ({
vi.mock('node:process', () => ({ vi.mock('node:process', () => ({
default: { default: {
exit: mockProcessExit, exit: mockProcessExit,
cwd: vi.fn(() => '/mock/cwd'),
get env() {
return process.env;
}, },
platform: 'test-platform', }));
version: 'test-node-version',
memoryUsage: vi.fn(() => ({ const mockLoadCommands = vi.fn();
rss: 12345678, vi.mock('../../services/BuiltinCommandLoader.js', () => ({
heapTotal: 23456789, BuiltinCommandLoader: vi.fn().mockImplementation(() => ({
heapUsed: 10234567, loadCommands: mockLoadCommands,
external: 1234567,
arrayBuffers: 123456,
})),
},
exit: mockProcessExit,
cwd: vi.fn(() => '/mock/cwd'),
get env() {
return process.env;
},
platform: 'test-platform',
version: 'test-node-version',
memoryUsage: vi.fn(() => ({
rss: 12345678,
heapTotal: 23456789,
heapUsed: 10234567,
external: 1234567,
arrayBuffers: 123456,
})), })),
})); }));
vi.mock('node:fs/promises', () => ({
readFile: vi.fn(),
writeFile: vi.fn(),
mkdir: vi.fn(),
}));
const mockGetCliVersionFn = vi.fn(() => Promise.resolve('0.1.0'));
vi.mock('../../utils/version.js', () => ({
getCliVersion: (...args: []) => mockGetCliVersionFn(...args),
}));
import { act, renderHook } from '@testing-library/react';
import { vi, describe, it, expect, beforeEach, beforeAll, Mock } from 'vitest';
import open from 'open';
import { useSlashCommandProcessor } from './slashCommandProcessor.js';
import { SlashCommandProcessorResult } from '../types.js';
import { Config, GeminiClient } from '@google/gemini-cli-core';
import { useSessionStats } from '../contexts/SessionContext.js';
import { LoadedSettings } from '../../config/settings.js';
import * as ShowMemoryCommandModule from './useShowMemoryCommand.js';
import { CommandService } from '../../services/CommandService.js';
import { SlashCommand } from '../commands/types.js';
vi.mock('../contexts/SessionContext.js', () => ({ vi.mock('../contexts/SessionContext.js', () => ({
useSessionStats: vi.fn(), useSessionStats: vi.fn(() => ({ stats: {} })),
})); }));
vi.mock('../../services/CommandService.js'); import { act, renderHook, waitFor } from '@testing-library/react';
import { vi, describe, it, expect, beforeEach, type Mock } from 'vitest';
vi.mock('./useShowMemoryCommand.js', () => ({ import { useSlashCommandProcessor } from './slashCommandProcessor.js';
SHOW_MEMORY_COMMAND_NAME: '/memory show', import { SlashCommand } from '../commands/types.js';
createShowMemoryAction: vi.fn(() => vi.fn()), import { Config } from '@google/gemini-cli-core';
})); import { LoadedSettings } from '../../config/settings.js';
import { MessageType } from '../types.js';
vi.mock('open', () => ({ import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js';
default: vi.fn(),
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
};
});
describe('useSlashCommandProcessor', () => { describe('useSlashCommandProcessor', () => {
let mockAddItem: ReturnType<typeof vi.fn>; const mockAddItem = vi.fn();
let mockClearItems: ReturnType<typeof vi.fn>; const mockClearItems = vi.fn();
let mockLoadHistory: ReturnType<typeof vi.fn>; const mockLoadHistory = vi.fn();
let mockRefreshStatic: ReturnType<typeof vi.fn>; const mockSetShowHelp = vi.fn();
let mockSetShowHelp: ReturnType<typeof vi.fn>; const mockOpenAuthDialog = vi.fn();
let mockOnDebugMessage: ReturnType<typeof vi.fn>; const mockSetQuittingMessages = vi.fn();
let mockOpenThemeDialog: ReturnType<typeof vi.fn>;
let mockOpenAuthDialog: ReturnType<typeof vi.fn>; const mockConfig = {
let mockOpenEditorDialog: ReturnType<typeof vi.fn>; getProjectRoot: () => '/mock/cwd',
let mockSetQuittingMessages: ReturnType<typeof vi.fn>; getSessionId: () => 'test-session',
let mockTryCompressChat: ReturnType<typeof vi.fn>; getGeminiClient: () => ({
let mockGeminiClient: GeminiClient; setHistory: vi.fn().mockResolvedValue(undefined),
let mockConfig: Config; }),
let mockCorgiMode: ReturnType<typeof vi.fn>; } as unknown as Config;
const mockUseSessionStats = useSessionStats as Mock;
const mockSettings = {} as LoadedSettings;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
(vi.mocked(BuiltinCommandLoader) as Mock).mockClear();
mockAddItem = vi.fn(); mockLoadCommands.mockResolvedValue([]);
mockClearItems = vi.fn();
mockLoadHistory = vi.fn();
mockRefreshStatic = vi.fn();
mockSetShowHelp = vi.fn();
mockOnDebugMessage = vi.fn();
mockOpenThemeDialog = vi.fn();
mockOpenAuthDialog = vi.fn();
mockOpenEditorDialog = vi.fn();
mockSetQuittingMessages = vi.fn();
mockTryCompressChat = vi.fn();
mockGeminiClient = {
tryCompressChat: mockTryCompressChat,
} as unknown as GeminiClient;
mockConfig = {
getDebugMode: vi.fn(() => false),
getGeminiClient: () => mockGeminiClient,
getSandbox: vi.fn(() => 'test-sandbox'),
getModel: vi.fn(() => 'test-model'),
getProjectRoot: vi.fn(() => '/test/dir'),
getCheckpointingEnabled: vi.fn(() => true),
getBugCommand: vi.fn(() => undefined),
getSessionId: vi.fn(() => 'test-session-id'),
getIdeMode: vi.fn(() => false),
} as unknown as Config;
mockCorgiMode = vi.fn();
mockUseSessionStats.mockReturnValue({
stats: {
sessionStartTime: new Date('2025-01-01T00:00:00.000Z'),
cumulative: {
promptCount: 0,
promptTokenCount: 0,
candidatesTokenCount: 0,
totalTokenCount: 0,
cachedContentTokenCount: 0,
toolUsePromptTokenCount: 0,
thoughtsTokenCount: 0,
},
},
}); });
(open as Mock).mockClear(); const setupProcessorHook = (commands: SlashCommand[] = []) => {
mockProcessExit.mockClear(); mockLoadCommands.mockResolvedValue(Object.freeze(commands));
(ShowMemoryCommandModule.createShowMemoryAction as Mock).mockClear(); const { result } = renderHook(() =>
process.env = { ...globalThis.process.env };
});
const getProcessorHook = () => {
const settings = {
merged: {
contextFileName: 'GEMINI.md',
},
} as unknown as LoadedSettings;
return renderHook(() =>
useSlashCommandProcessor( useSlashCommandProcessor(
mockConfig, mockConfig,
settings, mockSettings,
mockAddItem, mockAddItem,
mockClearItems, mockClearItems,
mockLoadHistory, mockLoadHistory,
mockRefreshStatic, vi.fn(), // refreshStatic
mockSetShowHelp, mockSetShowHelp,
mockOnDebugMessage, vi.fn(), // onDebugMessage
mockOpenThemeDialog, vi.fn(), // openThemeDialog
mockOpenAuthDialog, mockOpenAuthDialog,
mockOpenEditorDialog, vi.fn(), // openEditorDialog
mockCorgiMode, vi.fn(), // toggleCorgiMode
mockSetQuittingMessages, mockSetQuittingMessages,
vi.fn(), // mockOpenPrivacyNotice vi.fn(), // openPrivacyNotice
), ),
); );
return result;
}; };
describe('Command Processing', () => { describe('Initialization and Command Loading', () => {
let ActualCommandService: typeof CommandService; it('should initialize CommandService with BuiltinCommandLoader', () => {
setupProcessorHook();
beforeAll(async () => { expect(BuiltinCommandLoader).toHaveBeenCalledTimes(1);
const actual = (await vi.importActual( expect(BuiltinCommandLoader).toHaveBeenCalledWith(mockConfig);
'../../services/CommandService.js',
)) as { CommandService: typeof CommandService };
ActualCommandService = actual.CommandService;
}); });
beforeEach(() => { it('should call loadCommands and populate state after mounting', async () => {
vi.clearAllMocks(); const testCommand: SlashCommand = {
name: 'test',
description: 'a test command',
kind: 'built-in',
};
const result = setupProcessorHook([testCommand]);
await waitFor(() => {
expect(result.current.slashCommands).toHaveLength(1);
}); });
it('should execute a registered command', async () => { expect(result.current.slashCommands[0]?.name).toBe('test');
const mockAction = vi.fn(); expect(mockLoadCommands).toHaveBeenCalledTimes(1);
const newCommand: SlashCommand = { name: 'test', action: mockAction };
const mockLoader = async () => [newCommand];
// We create the instance outside the mock implementation.
const commandServiceInstance = new ActualCommandService(
mockConfig,
mockLoader,
);
// This mock ensures the hook uses our pre-configured instance.
vi.mocked(CommandService).mockImplementation(
() => commandServiceInstance,
);
const { result } = getProcessorHook();
await vi.waitFor(() => {
// We check that the `slashCommands` array, which is the public API
// of our hook, eventually contains the command we injected.
expect(
result.current.slashCommands.some((c) => c.name === 'test'),
).toBe(true);
}); });
let commandResult: SlashCommandProcessorResult | false = false; it('should provide an immutable array of commands to consumers', async () => {
const testCommand: SlashCommand = {
name: 'test',
description: 'a test command',
kind: 'built-in',
};
const result = setupProcessorHook([testCommand]);
await waitFor(() => {
expect(result.current.slashCommands).toHaveLength(1);
});
const commands = result.current.slashCommands;
expect(() => {
// @ts-expect-error - We are intentionally testing a violation of the readonly type.
commands.push({
name: 'rogue',
description: 'a rogue command',
kind: 'built-in',
});
}).toThrow(TypeError);
});
});
describe('Command Execution Logic', () => {
it('should display an error for an unknown command', async () => {
const result = setupProcessorHook();
await waitFor(() => expect(result.current.slashCommands).toBeDefined());
await act(async () => { await act(async () => {
commandResult = await result.current.handleSlashCommand('/test'); await result.current.handleSlashCommand('/nonexistent');
}); });
expect(mockAction).toHaveBeenCalledTimes(1); // Expect 2 calls: one for the user's input, one for the error message.
expect(commandResult).toEqual({ type: 'handled' }); expect(mockAddItem).toHaveBeenCalledTimes(2);
}); expect(mockAddItem).toHaveBeenLastCalledWith(
it('should return "schedule_tool" for a command returning a tool action', async () => {
const mockAction = vi.fn().mockResolvedValue({
type: 'tool',
toolName: 'my_tool',
toolArgs: { arg1: 'value1' },
});
const newCommand: SlashCommand = { name: 'test', action: mockAction };
const mockLoader = async () => [newCommand];
const commandServiceInstance = new ActualCommandService(
mockConfig,
mockLoader,
);
vi.mocked(CommandService).mockImplementation(
() => commandServiceInstance,
);
const { result } = getProcessorHook();
await vi.waitFor(() => {
expect(
result.current.slashCommands.some((c) => c.name === 'test'),
).toBe(true);
});
const commandResult = await result.current.handleSlashCommand('/test');
expect(mockAction).toHaveBeenCalledTimes(1);
expect(commandResult).toEqual({
type: 'schedule_tool',
toolName: 'my_tool',
toolArgs: { arg1: 'value1' },
});
});
it('should return "handled" for a command returning a message action', async () => {
const mockAction = vi.fn().mockResolvedValue({
type: 'message',
messageType: 'info',
content: 'This is a message',
});
const newCommand: SlashCommand = { name: 'test', action: mockAction };
const mockLoader = async () => [newCommand];
const commandServiceInstance = new ActualCommandService(
mockConfig,
mockLoader,
);
vi.mocked(CommandService).mockImplementation(
() => commandServiceInstance,
);
const { result } = getProcessorHook();
await vi.waitFor(() => {
expect(
result.current.slashCommands.some((c) => c.name === 'test'),
).toBe(true);
});
const commandResult = await result.current.handleSlashCommand('/test');
expect(mockAction).toHaveBeenCalledTimes(1);
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
type: 'info', type: MessageType.ERROR,
text: 'This is a message', text: 'Unknown command: /nonexistent',
}), }),
expect.any(Number), expect.any(Number),
); );
expect(commandResult).toEqual({ type: 'handled' });
}); });
it('should return "handled" for a command returning a dialog action', async () => { it('should display help for a parent command invoked without a subcommand', async () => {
const mockAction = vi.fn().mockResolvedValue({
type: 'dialog',
dialog: 'help',
});
const newCommand: SlashCommand = { name: 'test', action: mockAction };
const mockLoader = async () => [newCommand];
const commandServiceInstance = new ActualCommandService(
mockConfig,
mockLoader,
);
vi.mocked(CommandService).mockImplementation(
() => commandServiceInstance,
);
const { result } = getProcessorHook();
await vi.waitFor(() => {
expect(
result.current.slashCommands.some((c) => c.name === 'test'),
).toBe(true);
});
const commandResult = await result.current.handleSlashCommand('/test');
expect(mockAction).toHaveBeenCalledTimes(1);
expect(mockSetShowHelp).toHaveBeenCalledWith(true);
expect(commandResult).toEqual({ type: 'handled' });
});
it('should open the auth dialog for a command returning an auth dialog action', async () => {
const mockAction = vi.fn().mockResolvedValue({
type: 'dialog',
dialog: 'auth',
});
const newAuthCommand: SlashCommand = { name: 'auth', action: mockAction };
const mockLoader = async () => [newAuthCommand];
const commandServiceInstance = new ActualCommandService(
mockConfig,
mockLoader,
);
vi.mocked(CommandService).mockImplementation(
() => commandServiceInstance,
);
const { result } = getProcessorHook();
await vi.waitFor(() => {
expect(
result.current.slashCommands.some((c) => c.name === 'auth'),
).toBe(true);
});
const commandResult = await result.current.handleSlashCommand('/auth');
expect(mockAction).toHaveBeenCalledTimes(1);
expect(mockOpenAuthDialog).toHaveBeenCalledWith();
expect(commandResult).toEqual({ type: 'handled' });
});
it('should open the theme dialog for a command returning a theme dialog action', async () => {
const mockAction = vi.fn().mockResolvedValue({
type: 'dialog',
dialog: 'theme',
});
const newCommand: SlashCommand = { name: 'test', action: mockAction };
const mockLoader = async () => [newCommand];
const commandServiceInstance = new ActualCommandService(
mockConfig,
mockLoader,
);
vi.mocked(CommandService).mockImplementation(
() => commandServiceInstance,
);
const { result } = getProcessorHook();
await vi.waitFor(() => {
expect(
result.current.slashCommands.some((c) => c.name === 'test'),
).toBe(true);
});
const commandResult = await result.current.handleSlashCommand('/test');
expect(mockAction).toHaveBeenCalledTimes(1);
expect(mockOpenThemeDialog).toHaveBeenCalledWith();
expect(commandResult).toEqual({ type: 'handled' });
});
it('should show help for a parent command with no action', async () => {
const parentCommand: SlashCommand = { const parentCommand: SlashCommand = {
name: 'parent', name: 'parent',
description: 'a parent command',
kind: 'built-in',
subCommands: [ subCommands: [
{ name: 'child', description: 'A child.', action: vi.fn() }, {
name: 'child1',
description: 'First child.',
kind: 'built-in',
},
], ],
}; };
const result = setupProcessorHook([parentCommand]);
const mockLoader = async () => [parentCommand]; await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
const commandServiceInstance = new ActualCommandService(
mockConfig,
mockLoader,
);
vi.mocked(CommandService).mockImplementation(
() => commandServiceInstance,
);
const { result } = getProcessorHook();
await vi.waitFor(() => {
expect(
result.current.slashCommands.some((c) => c.name === 'parent'),
).toBe(true);
});
await act(async () => { await act(async () => {
await result.current.handleSlashCommand('/parent'); await result.current.handleSlashCommand('/parent');
}); });
expect(mockAddItem).toHaveBeenCalledWith( expect(mockAddItem).toHaveBeenCalledTimes(2);
expect(mockAddItem).toHaveBeenLastCalledWith(
expect.objectContaining({ expect.objectContaining({
type: 'info', type: MessageType.INFO,
text: expect.stringContaining( text: expect.stringContaining(
"Command '/parent' requires a subcommand.", "Command '/parent' requires a subcommand.",
), ),
@ -425,5 +181,218 @@ describe('useSlashCommandProcessor', () => {
expect.any(Number), expect.any(Number),
); );
}); });
it('should correctly find and execute a nested subcommand', async () => {
const childAction = vi.fn();
const parentCommand: SlashCommand = {
name: 'parent',
description: 'a parent command',
kind: 'built-in',
subCommands: [
{
name: 'child',
description: 'a child command',
kind: 'built-in',
action: childAction,
},
],
};
const result = setupProcessorHook([parentCommand]);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
await result.current.handleSlashCommand('/parent child with args');
});
expect(childAction).toHaveBeenCalledTimes(1);
expect(childAction).toHaveBeenCalledWith(
expect.objectContaining({
services: expect.objectContaining({
config: mockConfig,
}),
ui: expect.objectContaining({
addItem: mockAddItem,
}),
}),
'with args',
);
});
});
describe('Action Result Handling', () => {
it('should handle "dialog: help" action', async () => {
const command: SlashCommand = {
name: 'helpcmd',
description: 'a help command',
kind: 'built-in',
action: vi.fn().mockResolvedValue({ type: 'dialog', dialog: 'help' }),
};
const result = setupProcessorHook([command]);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
await result.current.handleSlashCommand('/helpcmd');
});
expect(mockSetShowHelp).toHaveBeenCalledWith(true);
});
it('should handle "load_history" action', async () => {
const command: SlashCommand = {
name: 'load',
description: 'a load command',
kind: 'built-in',
action: vi.fn().mockResolvedValue({
type: 'load_history',
history: [{ type: MessageType.USER, text: 'old prompt' }],
clientHistory: [{ role: 'user', parts: [{ text: 'old prompt' }] }],
}),
};
const result = setupProcessorHook([command]);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
await result.current.handleSlashCommand('/load');
});
expect(mockClearItems).toHaveBeenCalledTimes(1);
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({ type: 'user', text: 'old prompt' }),
expect.any(Number),
);
});
describe('with fake timers', () => {
// This test needs to let the async `waitFor` complete with REAL timers
// before switching to FAKE timers to test setTimeout.
it('should handle a "quit" action', async () => {
const quitAction = vi
.fn()
.mockResolvedValue({ type: 'quit', messages: [] });
const command: SlashCommand = {
name: 'exit',
description: 'an exit command',
kind: 'built-in',
action: quitAction,
};
const result = setupProcessorHook([command]);
await waitFor(() =>
expect(result.current.slashCommands).toHaveLength(1),
);
vi.useFakeTimers();
try {
await act(async () => {
await result.current.handleSlashCommand('/exit');
});
await act(async () => {
await vi.advanceTimersByTimeAsync(200);
});
expect(mockSetQuittingMessages).toHaveBeenCalledWith([]);
expect(mockProcessExit).toHaveBeenCalledWith(0);
} finally {
vi.useRealTimers();
}
});
});
});
describe('Command Parsing and Matching', () => {
it('should be case-sensitive', async () => {
const command: SlashCommand = {
name: 'test',
description: 'a test command',
kind: 'built-in',
};
const result = setupProcessorHook([command]);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
// Use uppercase when command is lowercase
await result.current.handleSlashCommand('/Test');
});
// It should fail and call addItem with an error
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: MessageType.ERROR,
text: 'Unknown command: /Test',
}),
expect.any(Number),
);
});
it('should correctly match an altName', async () => {
const action = vi.fn();
const command: SlashCommand = {
name: 'main',
altNames: ['alias'],
description: 'a command with an alias',
kind: 'built-in',
action,
};
const result = setupProcessorHook([command]);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
await result.current.handleSlashCommand('/alias');
});
expect(action).toHaveBeenCalledTimes(1);
expect(mockAddItem).not.toHaveBeenCalledWith(
expect.objectContaining({ type: MessageType.ERROR }),
);
});
it('should handle extra whitespace around the command', async () => {
const action = vi.fn();
const command: SlashCommand = {
name: 'test',
description: 'a test command',
kind: 'built-in',
action,
};
const result = setupProcessorHook([command]);
await waitFor(() => expect(result.current.slashCommands).toHaveLength(1));
await act(async () => {
await result.current.handleSlashCommand(' /test with-args ');
});
expect(action).toHaveBeenCalledWith(expect.anything(), 'with-args');
});
});
describe('Lifecycle', () => {
it('should abort command loading when the hook unmounts', async () => {
const abortSpy = vi.spyOn(AbortController.prototype, 'abort');
const { unmount } = renderHook(() =>
useSlashCommandProcessor(
mockConfig,
mockSettings,
mockAddItem,
mockClearItems,
mockLoadHistory,
vi.fn(), // refreshStatic
mockSetShowHelp,
vi.fn(), // onDebugMessage
vi.fn(), // openThemeDialog
mockOpenAuthDialog,
vi.fn(), // openEditorDialog
vi.fn(), // toggleCorgiMode
mockSetQuittingMessages,
vi.fn(), // openPrivacyNotice
),
);
unmount();
expect(abortSpy).toHaveBeenCalledTimes(1);
});
}); });
}); });

View File

@ -21,6 +21,7 @@ import {
import { LoadedSettings } from '../../config/settings.js'; import { LoadedSettings } from '../../config/settings.js';
import { type CommandContext, type SlashCommand } from '../commands/types.js'; import { type CommandContext, type SlashCommand } from '../commands/types.js';
import { CommandService } from '../../services/CommandService.js'; import { CommandService } from '../../services/CommandService.js';
import { BuiltinCommandLoader } from '../../services/BuiltinCommandLoader.js';
/** /**
* Hook to define and process slash commands (e.g., /help, /clear). * Hook to define and process slash commands (e.g., /help, /clear).
@ -42,7 +43,7 @@ export const useSlashCommandProcessor = (
openPrivacyNotice: () => void, openPrivacyNotice: () => void,
) => { ) => {
const session = useSessionStats(); const session = useSessionStats();
const [commands, setCommands] = useState<SlashCommand[]>([]); const [commands, setCommands] = useState<readonly SlashCommand[]>([]);
const gitService = useMemo(() => { const gitService = useMemo(() => {
if (!config?.getProjectRoot()) { if (!config?.getProjectRoot()) {
return; return;
@ -158,16 +159,24 @@ export const useSlashCommandProcessor = (
], ],
); );
const commandService = useMemo(() => new CommandService(config), [config]);
useEffect(() => { useEffect(() => {
const controller = new AbortController();
const load = async () => { const load = async () => {
await commandService.loadCommands(); // TODO - Add other loaders for custom commands.
const loaders = [new BuiltinCommandLoader(config)];
const commandService = await CommandService.create(
loaders,
controller.signal,
);
setCommands(commandService.getCommands()); setCommands(commandService.getCommands());
}; };
load(); load();
}, [commandService]);
return () => {
controller.abort();
};
}, [config]);
const handleSlashCommand = useCallback( const handleSlashCommand = useCallback(
async ( async (
@ -199,7 +208,7 @@ export const useSlashCommandProcessor = (
for (const part of commandPath) { for (const part of commandPath) {
const foundCommand = currentCommands.find( const foundCommand = currentCommands.find(
(cmd) => cmd.name === part || cmd.altName === part, (cmd) => cmd.name === part || cmd.altNames?.includes(part),
); );
if (foundCommand) { if (foundCommand) {

View File

@ -53,13 +53,13 @@ describe('useCompletion git-aware filtering integration', () => {
const mockSlashCommands: SlashCommand[] = [ const mockSlashCommands: SlashCommand[] = [
{ {
name: 'help', name: 'help',
altName: '?', altNames: ['?'],
description: 'Show help', description: 'Show help',
action: vi.fn(), action: vi.fn(),
}, },
{ {
name: 'stats', name: 'stats',
altName: 'usage', altNames: ['usage'],
description: 'check session stats. Usage: /stats [model|tools]', description: 'check session stats. Usage: /stats [model|tools]',
action: vi.fn(), action: vi.fn(),
}, },
@ -553,7 +553,7 @@ describe('useCompletion git-aware filtering integration', () => {
}); });
it.each([['/?'], ['/usage']])( it.each([['/?'], ['/usage']])(
'should not suggest commands when altName is fully typed', 'should not suggest commands when altNames is fully typed',
async (altName) => { async (altName) => {
const { result } = renderHook(() => const { result } = renderHook(() =>
useCompletion( useCompletion(
@ -569,7 +569,7 @@ describe('useCompletion git-aware filtering integration', () => {
}, },
); );
it('should suggest commands based on partial altName matches', async () => { it('should suggest commands based on partial altNames matches', async () => {
const { result } = renderHook(() => const { result } = renderHook(() =>
useCompletion( useCompletion(
'/usag', // part of the word "usage" '/usag', // part of the word "usage"

View File

@ -66,13 +66,13 @@ describe('useCompletion', () => {
mockSlashCommands = [ mockSlashCommands = [
{ {
name: 'help', name: 'help',
altName: '?', altNames: ['?'],
description: 'Show help', description: 'Show help',
action: vi.fn(), action: vi.fn(),
}, },
{ {
name: 'stats', name: 'stats',
altName: 'usage', altNames: ['usage'],
description: 'check session stats. Usage: /stats [model|tools]', description: 'check session stats. Usage: /stats [model|tools]',
action: vi.fn(), action: vi.fn(),
}, },
@ -410,7 +410,7 @@ describe('useCompletion', () => {
}); });
it.each([['/?'], ['/usage']])( it.each([['/?'], ['/usage']])(
'should not suggest commands when altName is fully typed', 'should not suggest commands when altNames is fully typed',
(altName) => { (altName) => {
const { result } = renderHook(() => const { result } = renderHook(() =>
useCompletion( useCompletion(
@ -427,7 +427,7 @@ describe('useCompletion', () => {
}, },
); );
it('should suggest commands based on partial altName matches', () => { it('should suggest commands based on partial altNames matches', () => {
const { result } = renderHook(() => const { result } = renderHook(() =>
useCompletion( useCompletion(
'/usag', // part of the word "usage" '/usag', // part of the word "usage"

View File

@ -41,7 +41,7 @@ export function useCompletion(
query: string, query: string,
cwd: string, cwd: string,
isActive: boolean, isActive: boolean,
slashCommands: SlashCommand[], slashCommands: readonly SlashCommand[],
commandContext: CommandContext, commandContext: CommandContext,
config?: Config, config?: Config,
): UseCompletionReturn { ): UseCompletionReturn {
@ -151,7 +151,7 @@ export function useCompletion(
} }
// Traverse the Command Tree using the tentative completed path // Traverse the Command Tree using the tentative completed path
let currentLevel: SlashCommand[] | undefined = slashCommands; let currentLevel: readonly SlashCommand[] | undefined = slashCommands;
let leafCommand: SlashCommand | null = null; let leafCommand: SlashCommand | null = null;
for (const part of commandPathParts) { for (const part of commandPathParts) {
@ -161,11 +161,13 @@ export function useCompletion(
break; break;
} }
const found: SlashCommand | undefined = currentLevel.find( const found: SlashCommand | undefined = currentLevel.find(
(cmd) => cmd.name === part || cmd.altName === part, (cmd) => cmd.name === part || cmd.altNames?.includes(part),
); );
if (found) { if (found) {
leafCommand = found; leafCommand = found;
currentLevel = found.subCommands; currentLevel = found.subCommands as
| readonly SlashCommand[]
| undefined;
} else { } else {
leafCommand = null; leafCommand = null;
currentLevel = []; currentLevel = [];
@ -177,7 +179,7 @@ export function useCompletion(
if (!hasTrailingSpace && currentLevel) { if (!hasTrailingSpace && currentLevel) {
const exactMatchAsParent = currentLevel.find( const exactMatchAsParent = currentLevel.find(
(cmd) => (cmd) =>
(cmd.name === partial || cmd.altName === partial) && (cmd.name === partial || cmd.altNames?.includes(partial)) &&
cmd.subCommands, cmd.subCommands,
); );
@ -199,7 +201,8 @@ export function useCompletion(
// Case: /command subcommand<enter> // Case: /command subcommand<enter>
const perfectMatch = currentLevel.find( const perfectMatch = currentLevel.find(
(cmd) => (cmd) =>
(cmd.name === partial || cmd.altName === partial) && cmd.action, (cmd.name === partial || cmd.altNames?.includes(partial)) &&
cmd.action,
); );
if (perfectMatch) { if (perfectMatch) {
setIsPerfectMatch(true); setIsPerfectMatch(true);
@ -238,14 +241,15 @@ export function useCompletion(
let potentialSuggestions = commandsToSearch.filter( let potentialSuggestions = commandsToSearch.filter(
(cmd) => (cmd) =>
cmd.description && cmd.description &&
(cmd.name.startsWith(partial) || cmd.altName?.startsWith(partial)), (cmd.name.startsWith(partial) ||
cmd.altNames?.some((alt) => alt.startsWith(partial))),
); );
// If a user's input is an exact match and it is a leaf command, // If a user's input is an exact match and it is a leaf command,
// enter should submit immediately. // enter should submit immediately.
if (potentialSuggestions.length > 0 && !hasTrailingSpace) { if (potentialSuggestions.length > 0 && !hasTrailingSpace) {
const perfectMatch = potentialSuggestions.find( const perfectMatch = potentialSuggestions.find(
(s) => s.name === partial || s.altName === partial, (s) => s.name === partial || s.altNames?.includes(partial),
); );
if (perfectMatch && perfectMatch.action) { if (perfectMatch && perfectMatch.action) {
potentialSuggestions = []; potentialSuggestions = [];