feature(commands) - Refactor Slash Command + Vision For the Future (#3175)

This commit is contained in:
Abhi
2025-07-07 16:45:44 -04:00
committed by GitHub
parent 6eccb474c7
commit aa10ccba71
26 changed files with 2436 additions and 726 deletions

View File

@@ -56,11 +56,8 @@ vi.mock('../../utils/version.js', () => ({
import { act, renderHook } from '@testing-library/react';
import { vi, describe, it, expect, beforeEach, afterEach, Mock } from 'vitest';
import open from 'open';
import {
useSlashCommandProcessor,
type SlashCommandActionReturn,
} from './slashCommandProcessor.js';
import { MessageType } from '../types.js';
import { useSlashCommandProcessor } from './slashCommandProcessor.js';
import { MessageType, SlashCommandProcessorResult } from '../types.js';
import {
Config,
MCPDiscoveryState,
@@ -73,11 +70,15 @@ import { useSessionStats } from '../contexts/SessionContext.js';
import { LoadedSettings } from '../../config/settings.js';
import * as ShowMemoryCommandModule from './useShowMemoryCommand.js';
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
import { CommandService } from '../../services/CommandService.js';
import { SlashCommand } from '../commands/types.js';
vi.mock('../contexts/SessionContext.js', () => ({
useSessionStats: vi.fn(),
}));
vi.mock('../../services/CommandService.js');
vi.mock('./useShowMemoryCommand.js', () => ({
SHOW_MEMORY_COMMAND_NAME: '/memory show',
createShowMemoryAction: vi.fn(() => vi.fn()),
@@ -87,6 +88,16 @@ vi.mock('open', () => ({
default: vi.fn(),
}));
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual =
await importOriginal<typeof import('@google/gemini-cli-core')>();
return {
...actual,
getMCPServerStatus: vi.fn(),
getMCPDiscoveryState: vi.fn(),
};
});
describe('useSlashCommandProcessor', () => {
let mockAddItem: ReturnType<typeof vi.fn>;
let mockClearItems: ReturnType<typeof vi.fn>;
@@ -97,7 +108,6 @@ describe('useSlashCommandProcessor', () => {
let mockOpenThemeDialog: ReturnType<typeof vi.fn>;
let mockOpenAuthDialog: ReturnType<typeof vi.fn>;
let mockOpenEditorDialog: ReturnType<typeof vi.fn>;
let mockPerformMemoryRefresh: ReturnType<typeof vi.fn>;
let mockSetQuittingMessages: ReturnType<typeof vi.fn>;
let mockTryCompressChat: ReturnType<typeof vi.fn>;
let mockGeminiClient: GeminiClient;
@@ -106,6 +116,20 @@ describe('useSlashCommandProcessor', () => {
const mockUseSessionStats = useSessionStats as Mock;
beforeEach(() => {
// Reset all mocks to clear any previous state or calls.
vi.clearAllMocks();
// Default mock setup for CommandService for all the OLD tests.
// This makes them pass again by simulating the original behavior where
// the service is constructed but doesn't do much yet.
vi.mocked(CommandService).mockImplementation(
() =>
({
loadCommands: vi.fn().mockResolvedValue(undefined),
getCommands: vi.fn().mockReturnValue([]), // Return an empty array by default
}) as unknown as CommandService,
);
mockAddItem = vi.fn();
mockClearItems = vi.fn();
mockLoadHistory = vi.fn();
@@ -115,7 +139,6 @@ describe('useSlashCommandProcessor', () => {
mockOpenThemeDialog = vi.fn();
mockOpenAuthDialog = vi.fn();
mockOpenEditorDialog = vi.fn();
mockPerformMemoryRefresh = vi.fn().mockResolvedValue(undefined);
mockSetQuittingMessages = vi.fn();
mockTryCompressChat = vi.fn();
mockGeminiClient = {
@@ -129,6 +152,7 @@ describe('useSlashCommandProcessor', () => {
getProjectRoot: vi.fn(() => '/test/dir'),
getCheckpointingEnabled: vi.fn(() => true),
getBugCommand: vi.fn(() => undefined),
getSessionId: vi.fn(() => 'test-session-id'),
} as unknown as Config;
mockCorgiMode = vi.fn();
mockUseSessionStats.mockReturnValue({
@@ -149,7 +173,6 @@ describe('useSlashCommandProcessor', () => {
(open as Mock).mockClear();
mockProcessExit.mockClear();
(ShowMemoryCommandModule.createShowMemoryAction as Mock).mockClear();
mockPerformMemoryRefresh.mockClear();
process.env = { ...globalThis.process.env };
});
@@ -158,7 +181,7 @@ describe('useSlashCommandProcessor', () => {
merged: {
contextFileName: 'GEMINI.md',
},
} as LoadedSettings;
} as unknown as LoadedSettings;
return renderHook(() =>
useSlashCommandProcessor(
mockConfig,
@@ -173,10 +196,10 @@ describe('useSlashCommandProcessor', () => {
mockOpenThemeDialog,
mockOpenAuthDialog,
mockOpenEditorDialog,
mockPerformMemoryRefresh,
mockCorgiMode,
showToolDescriptions,
mockSetQuittingMessages,
vi.fn(), // mockOpenPrivacyNotice
),
);
};
@@ -184,115 +207,6 @@ describe('useSlashCommandProcessor', () => {
const getProcessor = (showToolDescriptions: boolean = false) =>
getProcessorHook(showToolDescriptions).result.current;
describe('/memory add', () => {
it('should return tool scheduling info on valid input', async () => {
const { handleSlashCommand } = getProcessor();
const fact = 'Remember this fact';
let commandResult: SlashCommandActionReturn | boolean = false;
await act(async () => {
commandResult = await handleSlashCommand(`/memory add ${fact}`);
});
expect(mockAddItem).toHaveBeenNthCalledWith(
1, // User message
expect.objectContaining({
type: MessageType.USER,
text: `/memory add ${fact}`,
}),
expect.any(Number),
);
expect(mockAddItem).toHaveBeenNthCalledWith(
2, // Info message about attempting to save
expect.objectContaining({
type: MessageType.INFO,
text: `Attempting to save to memory: "${fact}"`,
}),
expect.any(Number),
);
expect(commandResult).toEqual({
shouldScheduleTool: true,
toolName: 'save_memory',
toolArgs: { fact },
});
// performMemoryRefresh is no longer called directly here
expect(mockPerformMemoryRefresh).not.toHaveBeenCalled();
});
it('should show usage error and return true if no text is provided', async () => {
const { handleSlashCommand } = getProcessor();
let commandResult: SlashCommandActionReturn | boolean = false;
await act(async () => {
commandResult = await handleSlashCommand('/memory add ');
});
expect(mockAddItem).toHaveBeenNthCalledWith(
2, // After user message
expect.objectContaining({
type: MessageType.ERROR,
text: 'Usage: /memory add <text to remember>',
}),
expect.any(Number),
);
expect(commandResult).toBe(true); // Command was handled (by showing an error)
});
});
describe('/memory show', () => {
it('should call the showMemoryAction and return true', async () => {
const mockReturnedShowAction = vi.fn();
vi.mocked(ShowMemoryCommandModule.createShowMemoryAction).mockReturnValue(
mockReturnedShowAction,
);
const { handleSlashCommand } = getProcessor();
let commandResult: SlashCommandActionReturn | boolean = false;
await act(async () => {
commandResult = await handleSlashCommand('/memory show');
});
expect(
ShowMemoryCommandModule.createShowMemoryAction,
).toHaveBeenCalledWith(
mockConfig,
expect.any(Object),
expect.any(Function),
);
expect(mockReturnedShowAction).toHaveBeenCalled();
expect(commandResult).toBe(true);
});
});
describe('/memory refresh', () => {
it('should call performMemoryRefresh and return true', async () => {
const { handleSlashCommand } = getProcessor();
let commandResult: SlashCommandActionReturn | boolean = false;
await act(async () => {
commandResult = await handleSlashCommand('/memory refresh');
});
expect(mockPerformMemoryRefresh).toHaveBeenCalled();
expect(commandResult).toBe(true);
});
});
describe('Unknown /memory subcommand', () => {
it('should show an error for unknown /memory subcommand and return true', async () => {
const { handleSlashCommand } = getProcessor();
let commandResult: SlashCommandActionReturn | boolean = false;
await act(async () => {
commandResult = await handleSlashCommand('/memory foobar');
});
expect(mockAddItem).toHaveBeenNthCalledWith(
2,
expect.objectContaining({
type: MessageType.ERROR,
text: 'Unknown /memory command: foobar. Available: show, refresh, add',
}),
expect.any(Number),
);
expect(commandResult).toBe(true);
});
});
describe('/stats command', () => {
it('should show detailed session statistics', async () => {
// Arrange
@@ -376,7 +290,7 @@ describe('useSlashCommandProcessor', () => {
selectedAuthType: 'test-auth-type',
contextFileName: 'GEMINI.md',
},
} as LoadedSettings;
} as unknown as LoadedSettings;
const { result } = renderHook(() =>
useSlashCommandProcessor(
@@ -392,10 +306,10 @@ describe('useSlashCommandProcessor', () => {
mockOpenThemeDialog,
mockOpenAuthDialog,
mockOpenEditorDialog,
mockPerformMemoryRefresh,
mockCorgiMode,
false,
mockSetQuittingMessages,
vi.fn(), // mockOpenPrivacyNotice
),
);
@@ -447,45 +361,187 @@ describe('useSlashCommandProcessor', () => {
});
describe('Other commands', () => {
it('/help should open help and return true', async () => {
it('/editor should open editor dialog and return handled', async () => {
const { handleSlashCommand } = getProcessor();
let commandResult: SlashCommandActionReturn | boolean = false;
await act(async () => {
commandResult = await handleSlashCommand('/help');
});
expect(mockSetShowHelp).toHaveBeenCalledWith(true);
expect(commandResult).toBe(true);
});
it('/clear should clear items, reset chat, and refresh static', async () => {
const mockResetChat = vi.fn();
mockConfig = {
...mockConfig,
getGeminiClient: () => ({
resetChat: mockResetChat,
}),
} as unknown as Config;
const { handleSlashCommand } = getProcessor();
let commandResult: SlashCommandActionReturn | boolean = false;
await act(async () => {
commandResult = await handleSlashCommand('/clear');
});
expect(mockClearItems).toHaveBeenCalled();
expect(mockResetChat).toHaveBeenCalled();
expect(mockRefreshStatic).toHaveBeenCalled();
expect(commandResult).toBe(true);
});
it('/editor should open editor dialog and return true', async () => {
const { handleSlashCommand } = getProcessor();
let commandResult: SlashCommandActionReturn | boolean = false;
let commandResult: SlashCommandProcessorResult | false = false;
await act(async () => {
commandResult = await handleSlashCommand('/editor');
});
expect(mockOpenEditorDialog).toHaveBeenCalled();
expect(commandResult).toBe(true);
expect(commandResult).toEqual({ type: 'handled' });
});
});
describe('New command registry', () => {
let ActualCommandService: typeof CommandService;
beforeAll(async () => {
const actual = (await vi.importActual(
'../../services/CommandService.js',
)) as { CommandService: typeof CommandService };
ActualCommandService = actual.CommandService;
});
beforeEach(() => {
vi.clearAllMocks();
});
it('should execute a command from the new registry', async () => {
const mockAction = vi.fn();
const newCommand: SlashCommand = { name: 'test', action: mockAction };
const mockLoader = async () => [newCommand];
// We create the instance outside the mock implementation.
const commandServiceInstance = new ActualCommandService(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;
await act(async () => {
commandResult = await result.current.handleSlashCommand('/test');
});
expect(mockAction).toHaveBeenCalledTimes(1);
expect(commandResult).toEqual({ type: 'handled' });
});
it('should return "schedule_tool" when a new command returns 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(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" when a new command returns 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(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({
type: 'info',
text: 'This is a message',
}),
expect.any(Number),
);
expect(commandResult).toEqual({ type: 'handled' });
});
it('should return "handled" when a new command returns a dialog action', 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(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 show help for a parent command with no action', async () => {
const parentCommand: SlashCommand = {
name: 'parent',
subCommands: [
{ name: 'child', description: 'A child.', action: vi.fn() },
],
};
const mockLoader = async () => [parentCommand];
const commandServiceInstance = new ActualCommandService(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 result.current.handleSlashCommand('/parent');
});
expect(mockAddItem).toHaveBeenCalledWith(
expect.objectContaining({
type: 'info',
text: expect.stringContaining(
"Command '/parent' requires a subcommand.",
),
}),
expect.any(Number),
);
});
});
@@ -498,6 +554,7 @@ describe('useSlashCommandProcessor', () => {
});
afterEach(() => {
vi.useRealTimers();
process.env = originalEnv;
});
@@ -547,14 +604,14 @@ describe('useSlashCommandProcessor', () => {
process.env.SEATBELT_PROFILE,
'test-version',
);
let commandResult: SlashCommandActionReturn | boolean = false;
let commandResult: SlashCommandProcessorResult | false = false;
await act(async () => {
commandResult = await handleSlashCommand(`/bug ${bugDescription}`);
});
expect(mockAddItem).toHaveBeenCalledTimes(2);
expect(open).toHaveBeenCalledWith(expectedUrl);
expect(commandResult).toBe(true);
expect(commandResult).toEqual({ type: 'handled' });
});
it('should use the custom bug command URL from config if available', async () => {
@@ -585,14 +642,14 @@ describe('useSlashCommandProcessor', () => {
.replace('{title}', encodeURIComponent(bugDescription))
.replace('{info}', encodeURIComponent(info));
let commandResult: SlashCommandActionReturn | boolean = false;
let commandResult: SlashCommandProcessorResult | false = false;
await act(async () => {
commandResult = await handleSlashCommand(`/bug ${bugDescription}`);
});
expect(mockAddItem).toHaveBeenCalledTimes(2);
expect(open).toHaveBeenCalledWith(expectedUrl);
expect(commandResult).toBe(true);
expect(commandResult).toEqual({ type: 'handled' });
});
});
@@ -640,9 +697,9 @@ describe('useSlashCommandProcessor', () => {
});
describe('Unknown command', () => {
it('should show an error and return true for a general unknown command', async () => {
it('should show an error and return handled for a general unknown command', async () => {
const { handleSlashCommand } = getProcessor();
let commandResult: SlashCommandActionReturn | boolean = false;
let commandResult: SlashCommandProcessorResult | false = false;
await act(async () => {
commandResult = await handleSlashCommand('/unknowncommand');
});
@@ -654,7 +711,7 @@ describe('useSlashCommandProcessor', () => {
}),
expect.any(Number),
);
expect(commandResult).toBe(true);
expect(commandResult).toEqual({ type: 'handled' });
});
});
@@ -665,7 +722,7 @@ describe('useSlashCommandProcessor', () => {
getToolRegistry: vi.fn().mockResolvedValue(undefined),
} as unknown as Config;
const { handleSlashCommand } = getProcessor();
let commandResult: SlashCommandActionReturn | boolean = false;
let commandResult: SlashCommandProcessorResult | false = false;
await act(async () => {
commandResult = await handleSlashCommand('/tools');
});
@@ -678,7 +735,7 @@ describe('useSlashCommandProcessor', () => {
}),
expect.any(Number),
);
expect(commandResult).toBe(true);
expect(commandResult).toEqual({ type: 'handled' });
});
it('should show an error if getAllTools returns undefined', async () => {
@@ -689,7 +746,7 @@ describe('useSlashCommandProcessor', () => {
}),
} as unknown as Config;
const { handleSlashCommand } = getProcessor();
let commandResult: SlashCommandActionReturn | boolean = false;
let commandResult: SlashCommandProcessorResult | false = false;
await act(async () => {
commandResult = await handleSlashCommand('/tools');
});
@@ -702,7 +759,7 @@ describe('useSlashCommandProcessor', () => {
}),
expect.any(Number),
);
expect(commandResult).toBe(true);
expect(commandResult).toEqual({ type: 'handled' });
});
it('should display only Gemini CLI tools (filtering out MCP tools)', async () => {
@@ -722,7 +779,7 @@ describe('useSlashCommandProcessor', () => {
} as unknown as Config;
const { handleSlashCommand } = getProcessor();
let commandResult: SlashCommandActionReturn | boolean = false;
let commandResult: SlashCommandProcessorResult | false = false;
await act(async () => {
commandResult = await handleSlashCommand('/tools');
});
@@ -731,7 +788,7 @@ describe('useSlashCommandProcessor', () => {
const message = mockAddItem.mock.calls[1][0].text;
expect(message).toContain('Tool1');
expect(message).toContain('Tool2');
expect(commandResult).toBe(true);
expect(commandResult).toEqual({ type: 'handled' });
});
it('should display a message when no Gemini CLI tools are available', async () => {
@@ -749,14 +806,14 @@ describe('useSlashCommandProcessor', () => {
} as unknown as Config;
const { handleSlashCommand } = getProcessor();
let commandResult: SlashCommandActionReturn | boolean = false;
let commandResult: SlashCommandProcessorResult | false = false;
await act(async () => {
commandResult = await handleSlashCommand('/tools');
});
const message = mockAddItem.mock.calls[1][0].text;
expect(message).toContain('No tools available');
expect(commandResult).toBe(true);
expect(commandResult).toEqual({ type: 'handled' });
});
it('should display tool descriptions when /tools desc is used', async () => {
@@ -781,7 +838,7 @@ describe('useSlashCommandProcessor', () => {
} as unknown as Config;
const { handleSlashCommand } = getProcessor();
let commandResult: SlashCommandActionReturn | boolean = false;
let commandResult: SlashCommandProcessorResult | false = false;
await act(async () => {
commandResult = await handleSlashCommand('/tools desc');
});
@@ -791,40 +848,18 @@ describe('useSlashCommandProcessor', () => {
expect(message).toContain('Description for Tool1');
expect(message).toContain('Tool2');
expect(message).toContain('Description for Tool2');
expect(commandResult).toBe(true);
expect(commandResult).toEqual({ type: 'handled' });
});
});
describe('/mcp command', () => {
beforeEach(() => {
// Mock the core module with getMCPServerStatus and getMCPDiscoveryState
vi.mock('@google/gemini-cli-core', async (importOriginal) => {
const actual = await importOriginal();
return {
...actual,
MCPServerStatus: {
CONNECTED: 'connected',
CONNECTING: 'connecting',
DISCONNECTED: 'disconnected',
},
MCPDiscoveryState: {
NOT_STARTED: 'not_started',
IN_PROGRESS: 'in_progress',
COMPLETED: 'completed',
},
getMCPServerStatus: vi.fn(),
getMCPDiscoveryState: vi.fn(),
};
});
});
it('should show an error if tool registry is not available', async () => {
mockConfig = {
...mockConfig,
getToolRegistry: vi.fn().mockResolvedValue(undefined),
} as unknown as Config;
const { handleSlashCommand } = getProcessor();
let commandResult: SlashCommandActionReturn | boolean = false;
let commandResult: SlashCommandProcessorResult | false = false;
await act(async () => {
commandResult = await handleSlashCommand('/mcp');
});
@@ -837,7 +872,7 @@ describe('useSlashCommandProcessor', () => {
}),
expect.any(Number),
);
expect(commandResult).toBe(true);
expect(commandResult).toEqual({ type: 'handled' });
});
it('should display a message with a URL when no MCP servers are configured in a sandbox', async () => {
@@ -851,7 +886,7 @@ describe('useSlashCommandProcessor', () => {
} as unknown as Config;
const { handleSlashCommand } = getProcessor();
let commandResult: SlashCommandActionReturn | boolean = false;
let commandResult: SlashCommandProcessorResult | false = false;
await act(async () => {
commandResult = await handleSlashCommand('/mcp');
});
@@ -864,7 +899,7 @@ describe('useSlashCommandProcessor', () => {
}),
expect.any(Number),
);
expect(commandResult).toBe(true);
expect(commandResult).toEqual({ type: 'handled' });
delete process.env.SANDBOX;
});
@@ -878,7 +913,7 @@ describe('useSlashCommandProcessor', () => {
} as unknown as Config;
const { handleSlashCommand } = getProcessor();
let commandResult: SlashCommandActionReturn | boolean = false;
let commandResult: SlashCommandProcessorResult | false = false;
await act(async () => {
commandResult = await handleSlashCommand('/mcp');
});
@@ -892,7 +927,7 @@ describe('useSlashCommandProcessor', () => {
expect.any(Number),
);
expect(open).toHaveBeenCalledWith('https://goo.gle/gemini-cli-docs-mcp');
expect(commandResult).toBe(true);
expect(commandResult).toEqual({ type: 'handled' });
});
it('should display configured MCP servers with status indicators and their tools', async () => {
@@ -941,7 +976,7 @@ describe('useSlashCommandProcessor', () => {
} as unknown as Config;
const { handleSlashCommand } = getProcessor();
let commandResult: SlashCommandActionReturn | boolean = false;
let commandResult: SlashCommandProcessorResult | false = false;
await act(async () => {
commandResult = await handleSlashCommand('/mcp');
});
@@ -976,7 +1011,7 @@ describe('useSlashCommandProcessor', () => {
);
expect(message).toContain('\u001b[36mserver3_tool1\u001b[0m');
expect(commandResult).toBe(true);
expect(commandResult).toEqual({ type: 'handled' });
});
it('should display tool descriptions when showToolDescriptions is true', async () => {
@@ -1014,7 +1049,7 @@ describe('useSlashCommandProcessor', () => {
} as unknown as Config;
const { handleSlashCommand } = getProcessor(true);
let commandResult: SlashCommandActionReturn | boolean = false;
let commandResult: SlashCommandProcessorResult | false = false;
await act(async () => {
commandResult = await handleSlashCommand('/mcp');
});
@@ -1046,7 +1081,7 @@ describe('useSlashCommandProcessor', () => {
'\u001b[32mThis is tool 2 description\u001b[0m',
);
expect(commandResult).toBe(true);
expect(commandResult).toEqual({ type: 'handled' });
});
it('should indicate when a server has no tools', async () => {
@@ -1071,7 +1106,7 @@ describe('useSlashCommandProcessor', () => {
// Mock tools from each server - server2 has no tools
const mockServer1Tools = [{ name: 'server1_tool1' }];
const mockServer2Tools = [];
const mockServer2Tools: Array<{ name: string }> = [];
const mockGetToolsByServer = vi.fn().mockImplementation((serverName) => {
if (serverName === 'server1') return mockServer1Tools;
@@ -1088,7 +1123,7 @@ describe('useSlashCommandProcessor', () => {
} as unknown as Config;
const { handleSlashCommand } = getProcessor();
let commandResult: SlashCommandActionReturn | boolean = false;
let commandResult: SlashCommandProcessorResult | false = false;
await act(async () => {
commandResult = await handleSlashCommand('/mcp');
});
@@ -1113,7 +1148,7 @@ describe('useSlashCommandProcessor', () => {
);
expect(message).toContain('No tools available');
expect(commandResult).toBe(true);
expect(commandResult).toEqual({ type: 'handled' });
});
it('should show startup indicator when servers are connecting', async () => {
@@ -1154,7 +1189,7 @@ describe('useSlashCommandProcessor', () => {
} as unknown as Config;
const { handleSlashCommand } = getProcessor();
let commandResult: SlashCommandActionReturn | boolean = false;
let commandResult: SlashCommandProcessorResult | false = false;
await act(async () => {
commandResult = await handleSlashCommand('/mcp');
});
@@ -1177,7 +1212,7 @@ describe('useSlashCommandProcessor', () => {
'🔄 \u001b[1mserver2\u001b[0m - Starting... (first startup may take longer) (tools will appear when ready)',
);
expect(commandResult).toBe(true);
expect(commandResult).toEqual({ type: 'handled' });
});
});
@@ -1229,7 +1264,7 @@ describe('useSlashCommandProcessor', () => {
} as unknown as Config;
const { handleSlashCommand } = getProcessor(true);
let commandResult: SlashCommandActionReturn | boolean = false;
let commandResult: SlashCommandProcessorResult | false = false;
await act(async () => {
commandResult = await handleSlashCommand('/mcp schema');
});
@@ -1257,30 +1292,16 @@ describe('useSlashCommandProcessor', () => {
expect(message).toContain('param2');
expect(message).toContain('number');
expect(commandResult).toBe(true);
expect(commandResult).toEqual({ type: 'handled' });
});
});
describe('/compress command', () => {
it('should call tryCompressChat(true)', async () => {
const hook = getProcessorHook();
mockTryCompressChat.mockImplementationOnce(async (force?: boolean) => {
expect(force).toBe(true);
await act(async () => {
hook.rerender();
});
expect(hook.result.current.pendingHistoryItems).toContainEqual({
type: MessageType.COMPRESSION,
compression: {
isPending: true,
originalTokenCount: null,
newTokenCount: null,
},
});
return {
originalTokenCount: 100,
newTokenCount: 50,
};
mockTryCompressChat.mockResolvedValue({
originalTokenCount: 100,
newTokenCount: 50,
});
await act(async () => {

View File

@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/
import { useCallback, useMemo } from 'react';
import { useCallback, useMemo, useEffect, useState } from 'react';
import { type PartListUnion } from '@google/genai';
import open from 'open';
import process from 'node:process';
@@ -25,23 +25,24 @@ import {
MessageType,
HistoryItemWithoutId,
HistoryItem,
SlashCommandProcessorResult,
} from '../types.js';
import { promises as fs } from 'fs';
import path from 'path';
import { createShowMemoryAction } from './useShowMemoryCommand.js';
import { GIT_COMMIT_INFO } from '../../generated/git-commit.js';
import { formatDuration, formatMemoryUsage } from '../utils/formatters.js';
import { getCliVersion } from '../../utils/version.js';
import { LoadedSettings } from '../../config/settings.js';
import {
type CommandContext,
type SlashCommandActionReturn,
type SlashCommand,
} from '../commands/types.js';
import { CommandService } from '../../services/CommandService.js';
export interface SlashCommandActionReturn {
shouldScheduleTool?: boolean;
toolName?: string;
toolArgs?: Record<string, unknown>;
message?: string; // For simple messages or errors
}
export interface SlashCommand {
// This interface is for the old, inline command definitions.
// It will be removed once all commands are migrated to the new system.
export interface LegacySlashCommand {
name: string;
altName?: string;
description?: string;
@@ -53,7 +54,7 @@ export interface SlashCommand {
) =>
| void
| SlashCommandActionReturn
| Promise<void | SlashCommandActionReturn>; // Action can now return this object
| Promise<void | SlashCommandActionReturn>;
}
/**
@@ -72,13 +73,13 @@ export const useSlashCommandProcessor = (
openThemeDialog: () => void,
openAuthDialog: () => void,
openEditorDialog: () => void,
performMemoryRefresh: () => Promise<void>,
toggleCorgiMode: () => void,
showToolDescriptions: boolean = false,
setQuittingMessages: (message: HistoryItem[]) => void,
openPrivacyNotice: () => void,
) => {
const session = useSessionStats();
const [commands, setCommands] = useState<SlashCommand[]>([]);
const gitService = useMemo(() => {
if (!config?.getProjectRoot()) {
return;
@@ -86,12 +87,23 @@ export const useSlashCommandProcessor = (
return new GitService(config.getProjectRoot());
}, [config]);
const pendingHistoryItems: HistoryItemWithoutId[] = [];
const logger = useMemo(() => {
const l = new Logger(config?.getSessionId() || '');
// The logger's initialize is async, but we can create the instance
// synchronously. Commands that use it will await its initialization.
return l;
}, [config]);
const [pendingCompressionItemRef, setPendingCompressionItem] =
useStateAndRef<HistoryItemWithoutId | null>(null);
if (pendingCompressionItemRef.current != null) {
pendingHistoryItems.push(pendingCompressionItemRef.current);
}
const pendingHistoryItems = useMemo(() => {
const items: HistoryItemWithoutId[] = [];
if (pendingCompressionItemRef.current != null) {
items.push(pendingCompressionItemRef.current);
}
return items;
}, [pendingCompressionItemRef]);
const addMessage = useCallback(
(message: Message) => {
@@ -141,41 +153,51 @@ export const useSlashCommandProcessor = (
[addItem],
);
const showMemoryAction = useCallback(async () => {
const actionFn = createShowMemoryAction(config, settings, addMessage);
await actionFn();
}, [config, settings, addMessage]);
const addMemoryAction = useCallback(
(
_mainCommand: string,
_subCommand?: string,
args?: string,
): SlashCommandActionReturn | void => {
if (!args || args.trim() === '') {
addMessage({
type: MessageType.ERROR,
content: 'Usage: /memory add <text to remember>',
timestamp: new Date(),
});
return;
}
// UI feedback for attempting to schedule
addMessage({
type: MessageType.INFO,
content: `Attempting to save to memory: "${args.trim()}"`,
timestamp: new Date(),
});
// Return info for scheduling the tool call
return {
shouldScheduleTool: true,
toolName: 'save_memory',
toolArgs: { fact: args.trim() },
};
},
[addMessage],
const commandContext = useMemo(
(): CommandContext => ({
services: {
config,
settings,
git: gitService,
logger,
},
ui: {
addItem,
clear: () => {
clearItems();
console.clear();
refreshStatic();
},
setDebugMessage: onDebugMessage,
},
session: {
stats: session.stats,
},
}),
[
config,
settings,
gitService,
logger,
addItem,
clearItems,
refreshStatic,
session.stats,
onDebugMessage,
],
);
const commandService = useMemo(() => new CommandService(), []);
useEffect(() => {
const load = async () => {
await commandService.loadCommands();
setCommands(commandService.getCommands());
};
load();
}, [commandService]);
const savedChatTags = useCallback(async () => {
const geminiDir = config?.getProjectTempDir();
if (!geminiDir) {
@@ -193,17 +215,12 @@ export const useSlashCommandProcessor = (
}
}, [config]);
const slashCommands: SlashCommand[] = useMemo(() => {
const commands: SlashCommand[] = [
{
name: 'help',
altName: '?',
description: 'for help on gemini-cli',
action: (_mainCommand, _subCommand, _args) => {
onDebugMessage('Opening help.');
setShowHelp(true);
},
},
// Define legacy commands
// This list contains all commands that have NOT YET been migrated to the
// new system. As commands are migrated, they are removed from this list.
const legacyCommands: LegacySlashCommand[] = useMemo(() => {
const commands: LegacySlashCommand[] = [
// `/help` and `/clear` have been migrated and REMOVED from this list.
{
name: 'docs',
description: 'open full Gemini CLI documentation in your browser',
@@ -225,17 +242,6 @@ export const useSlashCommandProcessor = (
}
},
},
{
name: 'clear',
description: 'clear the screen and conversation history',
action: async (_mainCommand, _subCommand, _args) => {
onDebugMessage('Clearing terminal and resetting chat.');
clearItems();
await config?.getGeminiClient()?.resetChat();
console.clear();
refreshStatic();
},
},
{
name: 'theme',
description: 'change the theme',
@@ -246,23 +252,17 @@ export const useSlashCommandProcessor = (
{
name: 'auth',
description: 'change the auth method',
action: (_mainCommand, _subCommand, _args) => {
openAuthDialog();
},
action: (_mainCommand, _subCommand, _args) => openAuthDialog(),
},
{
name: 'editor',
description: 'set external editor preference',
action: (_mainCommand, _subCommand, _args) => {
openEditorDialog();
},
action: (_mainCommand, _subCommand, _args) => openEditorDialog(),
},
{
name: 'privacy',
description: 'display the privacy notice',
action: (_mainCommand, _subCommand, _args) => {
openPrivacyNotice();
},
action: (_mainCommand, _subCommand, _args) => openPrivacyNotice(),
},
{
name: 'stats',
@@ -493,38 +493,6 @@ export const useSlashCommandProcessor = (
});
},
},
{
name: 'memory',
description:
'manage memory. Usage: /memory <show|refresh|add> [text for add]',
action: (mainCommand, subCommand, args) => {
switch (subCommand) {
case 'show':
showMemoryAction();
return;
case 'refresh':
performMemoryRefresh();
return;
case 'add':
return addMemoryAction(mainCommand, subCommand, args); // Return the object
case undefined:
addMessage({
type: MessageType.ERROR,
content:
'Missing command\nUsage: /memory <show|refresh|add> [text for add]',
timestamp: new Date(),
});
return;
default:
addMessage({
type: MessageType.ERROR,
content: `Unknown /memory command: ${subCommand}. Available: show, refresh, add`,
timestamp: new Date(),
});
return;
}
},
},
{
name: 'tools',
description: 'list available Gemini CLI tools',
@@ -1020,7 +988,7 @@ export const useSlashCommandProcessor = (
}
return {
shouldScheduleTool: true,
type: 'tool',
toolName: toolCallData.toolCall.name,
toolArgs: toolCallData.toolCall.args,
};
@@ -1036,17 +1004,11 @@ export const useSlashCommandProcessor = (
}
return commands;
}, [
onDebugMessage,
setShowHelp,
refreshStatic,
addMessage,
openThemeDialog,
openAuthDialog,
openEditorDialog,
clearItems,
performMemoryRefresh,
showMemoryAction,
addMemoryAction,
addMessage,
openPrivacyNotice,
toggleCorgiMode,
savedChatTags,
config,
@@ -1059,20 +1021,23 @@ export const useSlashCommandProcessor = (
setQuittingMessages,
pendingCompressionItemRef,
setPendingCompressionItem,
openPrivacyNotice,
clearItems,
refreshStatic,
]);
const handleSlashCommand = useCallback(
async (
rawQuery: PartListUnion,
): Promise<SlashCommandActionReturn | boolean> => {
): Promise<SlashCommandProcessorResult | false> => {
if (typeof rawQuery !== 'string') {
return false;
}
const trimmed = rawQuery.trim();
if (!trimmed.startsWith('/') && !trimmed.startsWith('?')) {
return false;
}
const userMessageTimestamp = Date.now();
if (trimmed !== '/quit' && trimmed !== '/exit') {
addItem(
@@ -1081,35 +1046,128 @@ export const useSlashCommandProcessor = (
);
}
let subCommand: string | undefined;
let args: string | undefined;
const parts = trimmed.substring(1).trim().split(/\s+/);
const commandPath = parts.filter((p) => p); // The parts of the command, e.g., ['memory', 'add']
const commandToMatch = (() => {
if (trimmed.startsWith('?')) {
return 'help';
}
const parts = trimmed.substring(1).trim().split(/\s+/);
if (parts.length > 1) {
subCommand = parts[1];
}
if (parts.length > 2) {
args = parts.slice(2).join(' ');
}
return parts[0];
})();
// --- Start of New Tree Traversal Logic ---
const mainCommand = commandToMatch;
let currentCommands = commands;
let commandToExecute: SlashCommand | undefined;
let pathIndex = 0;
for (const cmd of slashCommands) {
if (mainCommand === cmd.name || mainCommand === cmd.altName) {
const actionResult = await cmd.action(mainCommand, subCommand, args);
if (
typeof actionResult === 'object' &&
actionResult?.shouldScheduleTool
) {
return actionResult; // Return the object for useGeminiStream
for (const part of commandPath) {
const foundCommand = currentCommands.find(
(cmd) => cmd.name === part || cmd.altName === part,
);
if (foundCommand) {
commandToExecute = foundCommand;
pathIndex++;
if (foundCommand.subCommands) {
currentCommands = foundCommand.subCommands;
} else {
break;
}
return true; // Command was handled, but no tool to schedule
} else {
break;
}
}
if (commandToExecute) {
const args = parts.slice(pathIndex).join(' ');
if (commandToExecute.action) {
const result = await commandToExecute.action(commandContext, args);
if (result) {
switch (result.type) {
case 'tool':
return {
type: 'schedule_tool',
toolName: result.toolName,
toolArgs: result.toolArgs,
};
case 'message':
addItem(
{
type:
result.messageType === 'error'
? MessageType.ERROR
: MessageType.INFO,
text: result.content,
},
Date.now(),
);
return { type: 'handled' };
case 'dialog':
switch (result.dialog) {
case 'help':
setShowHelp(true);
return { type: 'handled' };
default: {
const unhandled: never = result.dialog;
throw new Error(
`Unhandled slash command result: ${unhandled}`,
);
}
}
default: {
const unhandled: never = result;
throw new Error(`Unhandled slash command result: ${unhandled}`);
}
}
}
return { type: 'handled' };
} else if (commandToExecute.subCommands) {
const helpText = `Command '/${commandToExecute.name}' requires a subcommand. Available:\n${commandToExecute.subCommands
.map((sc) => ` - ${sc.name}: ${sc.description || ''}`)
.join('\n')}`;
addMessage({
type: MessageType.INFO,
content: helpText,
timestamp: new Date(),
});
return { type: 'handled' };
}
}
// --- End of New Tree Traversal Logic ---
// --- Legacy Fallback Logic (for commands not yet migrated) ---
const mainCommand = parts[0];
const subCommand = parts[1];
const legacyArgs = parts.slice(2).join(' ');
for (const cmd of legacyCommands) {
if (mainCommand === cmd.name || mainCommand === cmd.altName) {
const actionResult = await cmd.action(
mainCommand,
subCommand,
legacyArgs,
);
if (actionResult?.type === 'tool') {
return {
type: 'schedule_tool',
toolName: actionResult.toolName,
toolArgs: actionResult.toolArgs,
};
}
if (actionResult?.type === 'message') {
addItem(
{
type:
actionResult.messageType === 'error'
? MessageType.ERROR
: MessageType.INFO,
text: actionResult.content,
},
Date.now(),
);
}
return { type: 'handled' };
}
}
@@ -1118,10 +1176,51 @@ export const useSlashCommandProcessor = (
content: `Unknown command: ${trimmed}`,
timestamp: new Date(),
});
return true; // Indicate command was processed (even if unknown)
return { type: 'handled' };
},
[addItem, slashCommands, addMessage],
[
addItem,
setShowHelp,
commands,
legacyCommands,
commandContext,
addMessage,
],
);
return { handleSlashCommand, slashCommands, pendingHistoryItems };
const allCommands = useMemo(() => {
// Adapt legacy commands to the new SlashCommand interface
const adaptedLegacyCommands: SlashCommand[] = legacyCommands.map(
(legacyCmd) => ({
name: legacyCmd.name,
altName: legacyCmd.altName,
description: legacyCmd.description,
action: async (_context: CommandContext, args: string) => {
const parts = args.split(/\s+/);
const subCommand = parts[0] || undefined;
const restOfArgs = parts.slice(1).join(' ') || undefined;
return legacyCmd.action(legacyCmd.name, subCommand, restOfArgs);
},
completion: legacyCmd.completion
? async (_context: CommandContext, _partialArg: string) =>
legacyCmd.completion!()
: undefined,
}),
);
const newCommandNames = new Set(commands.map((c) => c.name));
const filteredAdaptedLegacy = adaptedLegacyCommands.filter(
(c) => !newCommandNames.has(c.name),
);
return [...commands, ...filteredAdaptedLegacy];
}, [commands, legacyCommands]);
return {
handleSlashCommand,
slashCommands: allCommands,
pendingHistoryItems,
commandContext,
};
};

View File

@@ -9,8 +9,15 @@ import type { Mocked } from 'vitest';
import { renderHook, act } from '@testing-library/react';
import { useCompletion } from './useCompletion.js';
import * as fs from 'fs/promises';
import { FileDiscoveryService } from '@google/gemini-cli-core';
import { glob } from 'glob';
import { CommandContext, SlashCommand } from '../commands/types.js';
import { Config, FileDiscoveryService } from '@google/gemini-cli-core';
interface MockConfig {
getFileFilteringRespectGitIgnore: () => boolean;
getEnableRecursiveFileSearch: () => boolean;
getFileService: () => FileDiscoveryService | null;
}
// Mock dependencies
vi.mock('fs/promises');
@@ -29,23 +36,83 @@ vi.mock('glob');
describe('useCompletion git-aware filtering integration', () => {
let mockFileDiscoveryService: Mocked<FileDiscoveryService>;
let mockConfig: {
fileFiltering?: { enabled?: boolean; respectGitignore?: boolean };
};
let mockConfig: MockConfig;
const testCwd = '/test/project';
const slashCommands = [
{ name: 'help', description: 'Show help', action: vi.fn() },
{ name: 'clear', description: 'Clear screen', action: vi.fn() },
];
// A minimal mock is sufficient for these tests.
const mockCommandContext = {} as CommandContext;
const mockSlashCommands: SlashCommand[] = [
{
name: 'help',
altName: '?',
description: 'Show help',
action: vi.fn(),
},
{
name: 'clear',
description: 'Clear the screen',
action: vi.fn(),
},
{
name: 'memory',
description: 'Manage memory',
// This command is a parent, no action.
subCommands: [
{
name: 'show',
description: 'Show memory',
action: vi.fn(),
},
{
name: 'add',
description: 'Add to memory',
action: vi.fn(),
},
],
},
{
name: 'chat',
description: 'Manage chat history',
subCommands: [
{
name: 'save',
description: 'Save chat',
action: vi.fn(),
},
{
name: 'resume',
description: 'Resume a saved chat',
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(() => []),
};
getGeminiIgnorePatterns: vi.fn(),
projectRoot: '',
gitIgnoreFilter: null,
geminiIgnoreFilter: null,
} as unknown as Mocked<FileDiscoveryService>;
mockConfig = {
getFileFilteringRespectGitIgnore: vi.fn(() => true),
@@ -81,7 +148,14 @@ describe('useCompletion git-aware filtering integration', () => {
);
const { result } = renderHook(() =>
useCompletion('@d', testCwd, true, slashCommands, mockConfig),
useCompletion(
'@d',
testCwd,
true,
slashCommands,
mockCommandContext,
mockConfig as Config,
),
);
// Wait for async operations to complete
@@ -104,7 +178,7 @@ describe('useCompletion git-aware filtering integration', () => {
{ name: 'dist', isDirectory: () => true },
{ name: 'README.md', isDirectory: () => false },
{ name: '.env', isDirectory: () => false },
] as Array<{ name: string; isDirectory: () => boolean }>);
] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
// Mock git ignore service to ignore certain files
mockFileDiscoveryService.shouldGitIgnoreFile.mockImplementation(
@@ -123,7 +197,14 @@ describe('useCompletion git-aware filtering integration', () => {
);
const { result } = renderHook(() =>
useCompletion('@', testCwd, true, slashCommands, mockConfig),
useCompletion(
'@',
testCwd,
true,
slashCommands,
mockCommandContext,
mockConfig as Config,
),
);
// Wait for async operations to complete
@@ -182,7 +263,14 @@ describe('useCompletion git-aware filtering integration', () => {
);
const { result } = renderHook(() =>
useCompletion('@t', testCwd, true, slashCommands, mockConfig),
useCompletion(
'@t',
testCwd,
true,
slashCommands,
mockCommandContext,
mockConfig as Config,
),
);
// Wait for async operations to complete
@@ -206,15 +294,22 @@ describe('useCompletion git-aware filtering integration', () => {
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 Array<{ name: string; isDirectory: () => boolean }>);
] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
renderHook(() =>
useCompletion('@d', testCwd, true, slashCommands, mockConfigNoRecursive),
useCompletion(
'@d',
testCwd,
true,
slashCommands,
mockCommandContext,
mockConfigNoRecursive,
),
);
await act(async () => {
@@ -232,10 +327,17 @@ describe('useCompletion git-aware filtering integration', () => {
{ name: 'src', isDirectory: () => true },
{ name: 'node_modules', isDirectory: () => true },
{ name: 'README.md', isDirectory: () => false },
] as Array<{ name: string; isDirectory: () => boolean }>);
] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
const { result } = renderHook(() =>
useCompletion('@', testCwd, true, slashCommands, undefined),
useCompletion(
'@',
testCwd,
true,
slashCommands,
mockCommandContext,
undefined,
),
);
await act(async () => {
@@ -257,12 +359,19 @@ describe('useCompletion git-aware filtering integration', () => {
vi.mocked(fs.readdir).mockResolvedValue([
{ name: 'src', isDirectory: () => true },
{ name: 'README.md', isDirectory: () => false },
] as Array<{ name: string; isDirectory: () => boolean }>);
] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const { result } = renderHook(() =>
useCompletion('@', testCwd, true, slashCommands, mockConfig),
useCompletion(
'@',
testCwd,
true,
slashCommands,
mockCommandContext,
mockConfig as Config,
),
);
await act(async () => {
@@ -283,7 +392,7 @@ describe('useCompletion git-aware filtering integration', () => {
{ name: 'component.tsx', isDirectory: () => false },
{ name: 'temp.log', isDirectory: () => false },
{ name: 'index.ts', isDirectory: () => false },
] as Array<{ name: string; isDirectory: () => boolean }>);
] as unknown as Awaited<ReturnType<typeof fs.readdir>>);
mockFileDiscoveryService.shouldGitIgnoreFile.mockImplementation(
(path: string) => path.includes('.log'),
@@ -298,7 +407,14 @@ describe('useCompletion git-aware filtering integration', () => {
);
const { result } = renderHook(() =>
useCompletion('@src/comp', testCwd, true, slashCommands, mockConfig),
useCompletion(
'@src/comp',
testCwd,
true,
slashCommands,
mockCommandContext,
mockConfig as Config,
),
);
await act(async () => {
@@ -316,7 +432,14 @@ describe('useCompletion git-aware filtering integration', () => {
vi.mocked(glob).mockResolvedValue(globResults);
const { result } = renderHook(() =>
useCompletion('@s', testCwd, true, slashCommands, mockConfig),
useCompletion(
'@s',
testCwd,
true,
slashCommands,
mockCommandContext,
mockConfig as Config,
),
);
await act(async () => {
@@ -344,7 +467,14 @@ describe('useCompletion git-aware filtering integration', () => {
vi.mocked(glob).mockResolvedValue(globResults);
const { result } = renderHook(() =>
useCompletion('@.', testCwd, true, slashCommands, mockConfig),
useCompletion(
'@.',
testCwd,
true,
slashCommands,
mockCommandContext,
mockConfig as Config,
),
);
await act(async () => {
@@ -363,4 +493,263 @@ describe('useCompletion git-aware filtering integration', () => {
{ label: 'src/index.ts', value: 'src/index.ts' },
]);
});
it('should suggest top-level command names based on partial input', async () => {
const { result } = renderHook(() =>
useCompletion(
'/mem',
'/test/cwd',
true,
mockSlashCommands,
mockCommandContext,
),
);
expect(result.current.suggestions).toEqual([
{ label: 'memory', value: 'memory', description: 'Manage memory' },
]);
expect(result.current.showSuggestions).toBe(true);
});
it('should suggest commands based on altName', async () => {
const { result } = renderHook(() =>
useCompletion(
'/?',
'/test/cwd',
true,
mockSlashCommands,
mockCommandContext,
),
);
expect(result.current.suggestions).toEqual([
{ label: 'help', value: 'help', description: 'Show help' },
]);
});
it('should suggest sub-command names for a parent command', async () => {
const { result } = renderHook(() =>
useCompletion(
'/memory a',
'/test/cwd',
true,
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(() =>
useCompletion(
'/memory ',
'/test/cwd',
true,
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(() =>
useCompletion(
'/chat resume my-ch',
'/test/cwd',
true,
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(() =>
useCompletion(
'/clear ',
'/test/cwd',
true,
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(() =>
useCompletion(
'/unknown-command',
'/test/cwd',
true,
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(() =>
useCompletion(
'/memory', // Note: no trailing space
'/test/cwd',
true,
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(() =>
useCompletion(
'/clear', // No trailing space
'/test/cwd',
true,
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(() =>
useCompletion(
'/chat resume ', // Trailing space, no partial argument
'/test/cwd',
true,
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(() =>
useCompletion(
'/',
'/test/cwd',
true,
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']),
);
});
it('should provide no suggestions for an invalid sub-command', async () => {
const { result } = renderHook(() =>
useCompletion(
'/memory dothisnow',
'/test/cwd',
true,
mockSlashCommands,
mockCommandContext,
),
);
expect(result.current.suggestions).toHaveLength(0);
expect(result.current.showSuggestions).toBe(false);
});
});

View File

@@ -20,7 +20,7 @@ import {
MAX_SUGGESTIONS_TO_SHOW,
Suggestion,
} from '../components/SuggestionsDisplay.js';
import { SlashCommand } from './slashCommandProcessor.js';
import { CommandContext, SlashCommand } from '../commands/types.js';
export interface UseCompletionReturn {
suggestions: Suggestion[];
@@ -40,6 +40,7 @@ export function useCompletion(
cwd: string,
isActive: boolean,
slashCommands: SlashCommand[],
commandContext: CommandContext,
config?: Config,
): UseCompletionReturn {
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
@@ -123,75 +124,129 @@ export function useCompletion(
return;
}
const trimmedQuery = query.trimStart(); // Trim leading whitespace
const trimmedQuery = query.trimStart();
// --- Handle Slash Command Completion ---
if (trimmedQuery.startsWith('/')) {
const parts = trimmedQuery.substring(1).split(' ');
const commandName = parts[0];
const subCommand = parts.slice(1).join(' ');
const fullPath = trimmedQuery.substring(1);
const hasTrailingSpace = trimmedQuery.endsWith(' ');
const command = slashCommands.find(
(cmd) => cmd.name === commandName || cmd.altName === commandName,
);
// Get all non-empty parts of the command.
const rawParts = fullPath.split(/\s+/).filter((p) => p);
// Continue to show command help until user types past command name.
if (command && command.completion && parts.length > 1) {
let commandPathParts = rawParts;
let partial = '';
// If there's no trailing space, the last part is potentially a partial segment.
// We tentatively separate it.
if (!hasTrailingSpace && rawParts.length > 0) {
partial = rawParts[rawParts.length - 1];
commandPathParts = rawParts.slice(0, -1);
}
// Traverse the Command Tree using the tentative completed path
let currentLevel: SlashCommand[] | undefined = slashCommands;
let leafCommand: SlashCommand | null = null;
for (const part of commandPathParts) {
if (!currentLevel) {
leafCommand = null;
currentLevel = [];
break;
}
const found: SlashCommand | undefined = currentLevel.find(
(cmd) => cmd.name === part || cmd.altName === part,
);
if (found) {
leafCommand = found;
currentLevel = found.subCommands;
} else {
leafCommand = null;
currentLevel = [];
break;
}
}
// Handle the Ambiguous Case
if (!hasTrailingSpace && currentLevel) {
const exactMatchAsParent = currentLevel.find(
(cmd) =>
(cmd.name === partial || cmd.altName === partial) &&
cmd.subCommands,
);
if (exactMatchAsParent) {
// It's a perfect match for a parent command. Override our initial guess.
// Treat it as a completed command path.
leafCommand = exactMatchAsParent;
currentLevel = exactMatchAsParent.subCommands;
partial = ''; // We now want to suggest ALL of its sub-commands.
}
}
const depth = commandPathParts.length;
// Provide Suggestions based on the now-corrected context
// Argument Completion
if (
leafCommand?.completion &&
(hasTrailingSpace ||
(rawParts.length > depth && depth > 0 && partial !== ''))
) {
const fetchAndSetSuggestions = async () => {
setIsLoadingSuggestions(true);
if (command.completion) {
const results = await command.completion();
const filtered = results.filter((r) => r.startsWith(subCommand));
const newSuggestions = filtered.map((s) => ({
label: s,
value: s,
}));
setSuggestions(newSuggestions);
setShowSuggestions(newSuggestions.length > 0);
setActiveSuggestionIndex(newSuggestions.length > 0 ? 0 : -1);
}
const argString = rawParts.slice(depth).join(' ');
const results =
(await leafCommand!.completion!(commandContext, argString)) || [];
const finalSuggestions = results.map((s) => ({ label: s, value: s }));
setSuggestions(finalSuggestions);
setShowSuggestions(finalSuggestions.length > 0);
setActiveSuggestionIndex(finalSuggestions.length > 0 ? 0 : -1);
setIsLoadingSuggestions(false);
};
fetchAndSetSuggestions();
return;
}
const partialCommand = trimmedQuery.substring(1);
const filteredSuggestions = slashCommands
.filter(
// Command/Sub-command Completion
const commandsToSearch = currentLevel || [];
if (commandsToSearch.length > 0) {
let potentialSuggestions = commandsToSearch.filter(
(cmd) =>
cmd.name.startsWith(partialCommand) ||
cmd.altName?.startsWith(partialCommand),
)
// Filter out ? and any other single character commands unless it's the only char
.filter((cmd) => {
const nameMatch = cmd.name.startsWith(partialCommand);
const altNameMatch = cmd.altName?.startsWith(partialCommand);
if (partialCommand.length === 1) {
return nameMatch || altNameMatch; // Allow single char match if query is single char
}
return (
(nameMatch && cmd.name.length > 1) ||
(altNameMatch && cmd.altName && cmd.altName.length > 1)
);
})
.filter((cmd) => cmd.description)
.map((cmd) => ({
label: cmd.name, // Always show the main name as label
value: cmd.name, // Value should be the main command name for execution
description: cmd.description,
}))
.sort((a, b) => a.label.localeCompare(b.label));
cmd.description &&
(cmd.name.startsWith(partial) || cmd.altName?.startsWith(partial)),
);
setSuggestions(filteredSuggestions);
setShowSuggestions(filteredSuggestions.length > 0);
setActiveSuggestionIndex(filteredSuggestions.length > 0 ? 0 : -1);
setVisibleStartIndex(0);
setIsLoadingSuggestions(false);
// If a user's input is an exact match and it is a leaf command,
// enter should submit immediately.
if (potentialSuggestions.length > 0 && !hasTrailingSpace) {
const perfectMatch = potentialSuggestions.find(
(s) => s.name === partial,
);
if (perfectMatch && !perfectMatch.subCommands) {
potentialSuggestions = [];
}
}
const finalSuggestions = potentialSuggestions.map((cmd) => ({
label: cmd.name,
value: cmd.name,
description: cmd.description,
}));
setSuggestions(finalSuggestions);
setShowSuggestions(finalSuggestions.length > 0);
setActiveSuggestionIndex(finalSuggestions.length > 0 ? 0 : -1);
setIsLoadingSuggestions(false);
return;
}
// If we fall through, no suggestions are available.
resetCompletionState();
return;
}
// --- Handle At Command Completion ---
// Handle At Command Completion
const atIndex = query.lastIndexOf('@');
if (atIndex === -1) {
resetCompletionState();
@@ -451,7 +506,15 @@ export function useCompletion(
isMounted = false;
clearTimeout(debounceTimeout);
};
}, [query, cwd, isActive, resetCompletionState, slashCommands, config]);
}, [
query,
cwd,
isActive,
resetCompletionState,
slashCommands,
commandContext,
config,
]);
return {
suggestions,

View File

@@ -19,7 +19,12 @@ import {
import { Config, EditorType, AuthType } from '@google/gemini-cli-core';
import { Part, PartListUnion } from '@google/genai';
import { UseHistoryManagerReturn } from './useHistoryManager.js';
import { HistoryItem, MessageType, StreamingState } from '../types.js';
import {
HistoryItem,
MessageType,
SlashCommandProcessorResult,
StreamingState,
} from '../types.js';
import { Dispatch, SetStateAction } from 'react';
import { LoadedSettings } from '../../config/settings.js';
@@ -360,10 +365,7 @@ describe('useGeminiStream', () => {
onDebugMessage: (message: string) => void;
handleSlashCommand: (
cmd: PartListUnion,
) => Promise<
| import('./slashCommandProcessor.js').SlashCommandActionReturn
| boolean
>;
) => Promise<SlashCommandProcessorResult | false>;
shellModeActive: boolean;
loadedSettings: LoadedSettings;
toolCalls?: TrackedToolCall[]; // Allow passing updated toolCalls
@@ -396,10 +398,7 @@ describe('useGeminiStream', () => {
onDebugMessage: mockOnDebugMessage,
handleSlashCommand: mockHandleSlashCommand as unknown as (
cmd: PartListUnion,
) => Promise<
| import('./slashCommandProcessor.js').SlashCommandActionReturn
| boolean
>,
) => Promise<SlashCommandProcessorResult | false>,
shellModeActive: false,
loadedSettings: mockLoadedSettings,
toolCalls: initialToolCalls,
@@ -966,83 +965,52 @@ describe('useGeminiStream', () => {
});
});
describe('Client-Initiated Tool Calls', () => {
it('should execute a client-initiated tool without sending a response to Gemini', async () => {
const clientToolRequest = {
shouldScheduleTool: true,
describe('Slash Command Handling', () => {
it('should schedule a tool call when the command processor returns a schedule_tool action', async () => {
const clientToolRequest: SlashCommandProcessorResult = {
type: 'schedule_tool',
toolName: 'save_memory',
toolArgs: { fact: 'test fact' },
};
mockHandleSlashCommand.mockResolvedValue(clientToolRequest);
const completedToolCall: TrackedCompletedToolCall = {
request: {
callId: 'client-call-1',
name: clientToolRequest.toolName,
args: clientToolRequest.toolArgs,
isClientInitiated: true,
},
status: 'success',
responseSubmittedToGemini: false,
response: {
callId: 'client-call-1',
responseParts: [{ text: 'Memory saved' }],
resultDisplay: 'Success: Memory saved',
error: undefined,
},
tool: {
name: clientToolRequest.toolName,
description: 'Saves memory',
getDescription: vi.fn(),
} as any,
};
const { result } = renderTestHook();
// Capture the onComplete callback
let capturedOnComplete:
| ((completedTools: TrackedToolCall[]) => Promise<void>)
| null = null;
mockUseReactToolScheduler.mockImplementation((onComplete) => {
capturedOnComplete = onComplete;
return [[], mockScheduleToolCalls, mockMarkToolsAsSubmitted];
});
const { result } = renderHook(() =>
useGeminiStream(
new MockedGeminiClientClass(mockConfig),
[],
mockAddItem,
mockSetShowHelp,
mockConfig,
mockOnDebugMessage,
mockHandleSlashCommand,
false,
() => 'vscode' as EditorType,
() => {},
() => Promise.resolve(),
),
);
// --- User runs the slash command ---
await act(async () => {
await result.current.submitQuery('/memory add "test fact"');
});
// Trigger the onComplete callback with the completed client-initiated tool
await waitFor(() => {
expect(mockScheduleToolCalls).toHaveBeenCalledWith(
[
expect.objectContaining({
name: 'save_memory',
args: { fact: 'test fact' },
isClientInitiated: true,
}),
],
expect.any(AbortSignal),
);
expect(mockSendMessageStream).not.toHaveBeenCalled();
});
});
it('should stop processing and not call Gemini when a command is handled without a tool call', async () => {
const uiOnlyCommandResult: SlashCommandProcessorResult = {
type: 'handled',
};
mockHandleSlashCommand.mockResolvedValue(uiOnlyCommandResult);
const { result } = renderTestHook();
await act(async () => {
if (capturedOnComplete) {
await capturedOnComplete([completedToolCall]);
}
await result.current.submitQuery('/help');
});
// --- Assert the outcome ---
await waitFor(() => {
// The tool should be marked as submitted locally
expect(mockMarkToolsAsSubmitted).toHaveBeenCalledWith([
'client-call-1',
]);
// Crucially, no message should be sent to the Gemini API
expect(mockSendMessageStream).not.toHaveBeenCalled();
expect(mockHandleSlashCommand).toHaveBeenCalledWith('/help');
expect(mockScheduleToolCalls).not.toHaveBeenCalled();
expect(mockSendMessageStream).not.toHaveBeenCalled(); // No LLM call made
});
});
});

View File

@@ -32,6 +32,7 @@ import {
HistoryItemWithoutId,
HistoryItemToolGroup,
MessageType,
SlashCommandProcessorResult,
ToolCallStatus,
} from '../types.js';
import { isAtCommand } from '../utils/commandUtils.js';
@@ -83,9 +84,7 @@ export const useGeminiStream = (
onDebugMessage: (message: string) => void,
handleSlashCommand: (
cmd: PartListUnion,
) => Promise<
import('./slashCommandProcessor.js').SlashCommandActionReturn | boolean
>,
) => Promise<SlashCommandProcessorResult | false>,
shellModeActive: boolean,
getPreferredEditor: () => EditorType | undefined,
onAuthError: () => void,
@@ -225,16 +224,10 @@ export const useGeminiStream = (
// Handle UI-only commands first
const slashCommandResult = await handleSlashCommand(trimmedQuery);
if (typeof slashCommandResult === 'boolean' && slashCommandResult) {
// Command was handled, and it doesn't require a tool call from here
return { queryToSend: null, shouldProceed: false };
} else if (
typeof slashCommandResult === 'object' &&
slashCommandResult.shouldScheduleTool
) {
// Slash command wants to schedule a tool call (e.g., /memory add)
const { toolName, toolArgs } = slashCommandResult;
if (toolName && toolArgs) {
if (slashCommandResult) {
if (slashCommandResult.type === 'schedule_tool') {
const { toolName, toolArgs } = slashCommandResult;
const toolCallRequest: ToolCallRequestInfo = {
callId: `${toolName}-${Date.now()}-${Math.random().toString(16).slice(2)}`,
name: toolName,
@@ -243,7 +236,8 @@ export const useGeminiStream = (
};
scheduleToolCalls([toolCallRequest], abortSignal);
}
return { queryToSend: null, shouldProceed: false }; // Handled by scheduling the tool
return { queryToSend: null, shouldProceed: false };
}
if (shellModeActive && handleShellCommand(trimmedQuery, abortSignal)) {